Quellcode durchsuchen

测试:增加真实 Runner 的阶段二动态创作全流程回归

SamLee vor 1 Tag
Ursprung
Commit
a7dde55f31
1 geänderte Dateien mit 2039 neuen und 0 gelöschten Zeilen
  1. 2039 0
      script_build_host/tests/test_phase_two_real_runner_e2e.py

+ 2039 - 0
script_build_host/tests/test_phase_two_real_runner_e2e.py

@@ -0,0 +1,2039 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from collections.abc import Mapping, Sequence
+from pathlib import Path
+from typing import Any, cast
+
+import httpx
+import pytest
+from agent import AgentRunner, FileSystemArtifactStore, FileSystemTaskStore, FileSystemTraceStore
+from agent.orchestration import ArtifactRef, DecisionAction, TaskStatus, ValidationVerdict
+from sqlalchemy import select
+
+from script_build_host.agents.prompts import (
+    phase_one_prompt_manifest,
+    script_build_prompt_manifest,
+)
+from script_build_host.application.input_snapshot_service import ScriptInputSnapshotService
+from script_build_host.application.mission_service import PHASE_ONE_CAPABILITY_BOUNDARY
+from script_build_host.application.phase_two_planning import (
+    PHASE_TWO_CANDIDATE_PORTFOLIO_READY,
+)
+from script_build_host.composition import HostDependencies, compose_host
+from script_build_host.domain.artifacts import ArtifactKind
+from script_build_host.domain.input_snapshot import ScriptBuildInput
+from script_build_host.domain.records import BuildStatus, Principal
+from script_build_host.infrastructure.canonical_json import canonical_sha256
+from script_build_host.infrastructure.legacy_tables import (
+    script_build_paragraph,
+    script_build_record,
+)
+from script_build_host.infrastructure.tables import (
+    artifact_version_table,
+    input_snapshot_table,
+    mission_binding_table,
+)
+from script_build_host.infrastructure.task_contract_store import FileScriptTaskContractStore
+from script_build_host.repositories.legacy_state import SqlAlchemyLegacyBuildStateRepository
+from script_build_host.repositories.sqlalchemy import (
+    SqlAlchemyInputSnapshotRepository,
+    SqlAlchemyMissionBindingRepository,
+    SqlAlchemyPublicationRepository,
+    SqlAlchemyScriptBusinessArtifactRepository,
+)
+from script_build_host.repositories.workspace import SqlAlchemyCandidateWorkspaceRepository
+from script_build_host.tools.gateway import RetrievalResult
+
+ROOT = "phase-two-real-runner-root"
+FULL_SCOPE = "script-build://scopes/full"
+
+
+def _tool(call_id: str, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
+    return {
+        "content": "",
+        "tool_calls": [
+            {
+                "id": call_id,
+                "type": "function",
+                "function": {"name": name, "arguments": json.dumps(arguments)},
+            }
+        ],
+        "finish_reason": "tool_calls",
+    }
+
+
+def _role_prompt(messages: Sequence[Mapping[str, Any]]) -> Mapping[str, Any]:
+    for message in reversed(messages):
+        if message.get("role") != "user" or not isinstance(message.get("content"), str):
+            continue
+        try:
+            value = json.loads(cast(str, message["content"]))
+        except ValueError:
+            continue
+        if isinstance(value, dict) and "task_spec" in value:
+            return value
+    raise AssertionError("role trace does not contain the protected Task prompt")
+
+
+def _last_tool_value(messages: Sequence[Mapping[str, Any]]) -> Any:
+    for message in reversed(messages):
+        if message.get("role") != "tool" or not isinstance(message.get("content"), str):
+            continue
+        try:
+            return json.loads(cast(str, message["content"]))
+        except ValueError:
+            return None
+    return None
+
+
+def _artifact_ref_payload(ref: ArtifactRef) -> dict[str, Any]:
+    return {
+        "uri": ref.uri,
+        "kind": ref.kind,
+        "version": ref.version,
+        "digest": ref.digest,
+        "summary": ref.summary,
+        "metadata": ref.metadata,
+    }
+
+
+def _decision_ref(
+    decision_id: str,
+    artifact_ref: ArtifactRef,
+    *,
+    scope_ref: str,
+    task_kind: str,
+) -> dict[str, Any]:
+    return {
+        "decision_id": decision_id,
+        "artifact_ref": _artifact_ref_payload(artifact_ref),
+        "scope_ref": scope_ref,
+        "expected_task_kind": task_kind,
+    }
+
+
+def _contract(
+    task_kind: str,
+    *,
+    scope_ref: str,
+    write_scope: Sequence[str],
+    objective: str | None = None,
+    input_decision_refs: Sequence[Mapping[str, Any]] = (),
+    base_artifact_ref: Mapping[str, Any] | None = None,
+    closure: Sequence[Mapping[str, Any]] = (),
+    adopted: Sequence[str] = (),
+    held: Sequence[str] = (),
+    compose_order: Sequence[str] = (),
+    comparison_decision_refs: Sequence[Mapping[str, Any]] = (),
+    supersedes_decision_ids: Sequence[str] = (),
+) -> dict[str, Any]:
+    output_schema = {
+        "direction": "script-direction/v1",
+        "decode-retrieval": "evidence-record/v1",
+        "structure": "structure-artifact/v1",
+        "paragraph": "paragraph-artifact/v1",
+        "element-set": "element-set-artifact/v1",
+        "compare": "comparison-artifact/v1",
+        "compose": "structured-script/v1",
+        "candidate-portfolio": "candidate-portfolio/v1",
+    }[task_kind]
+    return {
+        "schema_version": "script-task-contract/v1",
+        "task_kind": task_kind,
+        "scope_ref": scope_ref,
+        "intent_class": (
+            "compose"
+            if task_kind == "compose"
+            else "portfolio"
+            if task_kind == "candidate-portfolio"
+            else "explore"
+        ),
+        "objective": objective or f"produce one concrete and independently verifiable {task_kind}",
+        "input_decision_refs": list(input_decision_refs),
+        "base_artifact_ref": base_artifact_ref,
+        "write_scope": list(write_scope),
+        "gap_ref": None,
+        "output_schema": output_schema,
+        "criteria": [
+            {
+                "criterion_id": f"{task_kind}-closed",
+                "description": "the immutable output is concrete and causally closed",
+                "hard": True,
+            }
+        ],
+        "budget": {
+            "max_attempts": 4,
+            "max_tokens": 32_000,
+            "max_seconds": 900,
+            "max_external_queries": 40,
+            "max_no_improvement": 3,
+        },
+        "supersedes_decision_ids": list(supersedes_decision_ids),
+        "candidate_closure_decision_refs": list(closure),
+        "adopted_decision_ids": list(adopted),
+        "held_or_rejected_decision_ids": list(held),
+        "compose_order": list(compose_order),
+        "comparison_decision_refs": list(comparison_decision_refs),
+    }
+
+
+def _task_kind(task: Any) -> str:
+    values = [
+        item.rsplit("/", 1)[-1] for item in task.current_spec.context_refs if "/task-kinds/" in item
+    ]
+    assert len(values) == 1
+    return values[0]
+
+
+def _accepted_ref(ledger: Any, task: Any) -> tuple[str, ArtifactRef]:
+    decision_id = task.decision_ids[-1]
+    decision = ledger.decisions[decision_id]
+    attempt = ledger.attempts[decision.attempt_id]
+    refs = [*attempt.submission.artifact_refs, *attempt.submission.evidence_refs]
+    assert len(refs) == 1
+    return decision_id, refs[0]
+
+
+class _PhaseTwoScriptedLLM:
+    """Exercise phase-two through the actual preset/tool/LocalExecutor surface."""
+
+    def __init__(self, store: FileSystemTaskStore) -> None:
+        self.store = store
+        self.calls = 0
+        self.submit_attempt_arguments: list[dict[str, Any]] = []
+        self.worker_kinds: list[str] = []
+        self.validator_kinds: list[str] = []
+        self.phase_two_active = False
+        self.compose_candidates_saved = 0
+
+    def _call(self, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
+        self.calls += 1
+        if name == "submit_attempt":
+            self.submit_attempt_arguments.append(dict(arguments))
+        return _tool(f"phase2-call-{self.calls}", name, arguments)
+
+    async def __call__(self, messages, tools, **_kwargs):
+        self.phase_two_active = self.phase_two_active or any(
+            PHASE_TWO_CANDIDATE_PORTFOLIO_READY in str(item.get("content", "")) for item in messages
+        )
+        names = {item["function"]["name"] for item in tools or []}
+        if "plan_script_tasks" in names:
+            return await self._planner()
+        if "submit_validation" in names:
+            return self._validator(messages)
+        if "submit_attempt" in names:
+            return self._worker(messages, names)
+        raise AssertionError(f"unexpected tool surface: {sorted(names)}")
+
+    async def _planner(self) -> dict[str, Any]:
+        ledger = await self.store.load(ROOT)
+        root = ledger.tasks[ledger.root_task_id]
+        tasks = [task for task in ledger.tasks.values() if task.task_id != ledger.root_task_id]
+
+        def by_kind(kind: str) -> list[Any]:
+            return [task for task in tasks if _task_kind(task) == kind]
+
+        def by_objective(value: str) -> Any | None:
+            return next(
+                (task for task in tasks if task.current_spec.objective == value),
+                None,
+            )
+
+        direction = next(iter(by_kind("direction")), None)
+        if direction is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "contracts": [
+                        _contract(
+                            "direction",
+                            scope_ref=FULL_SCOPE,
+                            write_scope=("script-build://writes/direction",),
+                        )
+                    ]
+                },
+            )
+        retrieval = next(
+            (
+                task
+                for task in by_kind("decode-retrieval")
+                if task.parent_task_id == direction.task_id
+            ),
+            None,
+        )
+        if retrieval is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "parent_task_id": direction.task_id,
+                    "contracts": [
+                        _contract(
+                            "decode-retrieval",
+                            scope_ref="script-build://scopes/full/direction-evidence",
+                            write_scope=(),
+                        )
+                    ],
+                },
+            )
+        if retrieval.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [retrieval.task_id]})
+        if retrieval.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(retrieval, ledger)
+        if direction.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [direction.task_id]})
+        if direction.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(direction, ledger)
+        if not self.phase_two_active:
+            return self._call(
+                "decide_script_task",
+                {
+                    "task_id": root.task_id,
+                    "action": "block",
+                    "reason": PHASE_ONE_CAPABILITY_BOUNDARY,
+                },
+            )
+
+        portfolio = next(iter(by_kind("candidate-portfolio")), None)
+        if portfolio is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "contracts": [
+                        _contract(
+                            "candidate-portfolio",
+                            scope_ref=FULL_SCOPE,
+                            write_scope=("script-build://writes",),
+                        )
+                    ]
+                },
+            )
+        compose = next(iter(by_kind("compose")), None)
+        if compose is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "parent_task_id": portfolio.task_id,
+                    "contracts": [
+                        _contract(
+                            "compose",
+                            scope_ref=FULL_SCOPE,
+                            write_scope=("script-build://writes",),
+                        )
+                    ],
+                },
+            )
+        structure = next(iter(by_kind("structure")), None)
+        if structure is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "parent_task_id": compose.task_id,
+                    "contracts": [
+                        _contract(
+                            "structure",
+                            scope_ref="script-build://scopes/full/opening",
+                            write_scope=("script-build://writes/paragraphs/opening",),
+                        )
+                    ],
+                },
+            )
+        if structure.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [structure.task_id]})
+        if structure.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(structure, ledger)
+
+        structure_decision, structure_ref = _accepted_ref(ledger, structure)
+        first_paragraph_objective = "draft opening candidate A"
+        replacement_objective = "replace the placeholder opening with retrieved evidence"
+        paragraph = by_objective(first_paragraph_objective)
+        if paragraph is None:
+            structure_pin = _decision_ref(
+                structure_decision,
+                structure_ref,
+                scope_ref="script-build://scopes/full/opening",
+                task_kind="structure",
+            )
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "parent_task_id": compose.task_id,
+                    "contracts": [
+                        _contract(
+                            "paragraph",
+                            scope_ref="script-build://scopes/full/opening",
+                            write_scope=("script-build://writes/paragraphs/opening",),
+                            objective=first_paragraph_objective,
+                            input_decision_refs=(structure_pin,),
+                            base_artifact_ref=_artifact_ref_payload(structure_ref),
+                        )
+                    ],
+                },
+            )
+        if paragraph.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [paragraph.task_id]})
+        if paragraph.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(paragraph, ledger)
+
+        direction_decision, direction_ref = _accepted_ref(ledger, direction)
+        paragraph_decision, paragraph_ref = _accepted_ref(ledger, paragraph)
+        if compose.current_spec_version == 1:
+            structure_pin = _decision_ref(
+                structure_decision,
+                structure_ref,
+                scope_ref="script-build://scopes/full/opening",
+                task_kind="structure",
+            )
+            paragraph_pin = _decision_ref(
+                paragraph_decision,
+                paragraph_ref,
+                scope_ref="script-build://scopes/full/opening",
+                task_kind="paragraph",
+            )
+            return self._call(
+                "decide_script_task",
+                {
+                    "task_id": compose.task_id,
+                    "action": "revise",
+                    "reason": "pin the adopted paragraph and formal compose order",
+                    "replacement_contract": _contract(
+                        "compose",
+                        scope_ref=FULL_SCOPE,
+                        write_scope=("script-build://writes",),
+                        input_decision_refs=(
+                            _decision_ref(
+                                direction_decision,
+                                direction_ref,
+                                scope_ref=FULL_SCOPE,
+                                task_kind="direction",
+                            ),
+                        ),
+                        closure=(structure_pin, paragraph_pin),
+                        adopted=(structure_decision, paragraph_decision),
+                        compose_order=(structure_decision, paragraph_decision),
+                    ),
+                },
+            )
+        if compose.status is TaskStatus.PENDING:
+            return self._call("dispatch_script_tasks", {"task_ids": [compose.task_id]})
+        if compose.status is TaskStatus.AWAITING_DECISION:
+            validation = ledger.validations[compose.validation_ids[-1]]
+            if validation.verdict is ValidationVerdict.PASSED:
+                return self._accept(compose, ledger)
+            return self._call(
+                "decide_script_task",
+                {
+                    "task_id": compose.task_id,
+                    "validation_id": validation.validation_id,
+                    "action": "split",
+                    "reason": "replace only the unrealized opening after targeted retrieval",
+                    "child_contracts": [
+                        _contract(
+                            "paragraph",
+                            scope_ref="script-build://scopes/full/opening",
+                            write_scope=("script-build://writes/paragraphs/opening",),
+                            objective=replacement_objective,
+                            input_decision_refs=(
+                                _decision_ref(
+                                    structure_decision,
+                                    structure_ref,
+                                    scope_ref="script-build://scopes/full/opening",
+                                    task_kind="structure",
+                                ),
+                                _decision_ref(
+                                    paragraph_decision,
+                                    paragraph_ref,
+                                    scope_ref="script-build://scopes/full/opening",
+                                    task_kind="paragraph",
+                                ),
+                            ),
+                            base_artifact_ref=_artifact_ref_payload(structure_ref),
+                            supersedes_decision_ids=(paragraph_decision,),
+                        )
+                    ],
+                },
+            )
+
+        replacement = by_objective(replacement_objective)
+        if replacement is None:
+            raise AssertionError("failed Compose must SPLIT one bounded replacement Task")
+        phase_two_retrieval = next(
+            (
+                task
+                for task in by_kind("decode-retrieval")
+                if task.parent_task_id == replacement.task_id
+            ),
+            None,
+        )
+        if phase_two_retrieval is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "parent_task_id": replacement.task_id,
+                    "contracts": [
+                        _contract(
+                            "decode-retrieval",
+                            scope_ref="script-build://scopes/full/opening/evidence",
+                            write_scope=(),
+                            objective="retrieve evidence for the replacement opening",
+                        )
+                    ],
+                },
+            )
+        if phase_two_retrieval.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [phase_two_retrieval.task_id]})
+        if phase_two_retrieval.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(phase_two_retrieval, ledger)
+        if replacement.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [replacement.task_id]})
+        if replacement.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(replacement, ledger)
+
+        replacement_decision, replacement_ref = _accepted_ref(ledger, replacement)
+        comparison = next(iter(by_kind("compare")), None)
+        if comparison is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "parent_task_id": compose.task_id,
+                    "contracts": [
+                        _contract(
+                            "compare",
+                            scope_ref="script-build://scopes/full/opening",
+                            write_scope=(),
+                            objective="compare opening candidates A and B symmetrically",
+                            comparison_decision_refs=(
+                                _decision_ref(
+                                    paragraph_decision,
+                                    paragraph_ref,
+                                    scope_ref="script-build://scopes/full/opening",
+                                    task_kind="paragraph",
+                                ),
+                                _decision_ref(
+                                    replacement_decision,
+                                    replacement_ref,
+                                    scope_ref="script-build://scopes/full/opening",
+                                    task_kind="paragraph",
+                                ),
+                            ),
+                        )
+                    ],
+                },
+            )
+        if comparison.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [comparison.task_id]})
+        if comparison.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(comparison, ledger)
+
+        comparison_decision, comparison_ref = _accepted_ref(ledger, comparison)
+        if compose.status is TaskStatus.NEEDS_REPLAN:
+            structure_pin = _decision_ref(
+                structure_decision,
+                structure_ref,
+                scope_ref="script-build://scopes/full/opening",
+                task_kind="structure",
+            )
+            paragraph_pin = _decision_ref(
+                paragraph_decision,
+                paragraph_ref,
+                scope_ref="script-build://scopes/full/opening",
+                task_kind="paragraph",
+            )
+            replacement_pin = _decision_ref(
+                replacement_decision,
+                replacement_ref,
+                scope_ref="script-build://scopes/full/opening",
+                task_kind="paragraph",
+            )
+            return self._call(
+                "decide_script_task",
+                {
+                    "task_id": compose.task_id,
+                    "action": "revise",
+                    "reason": "pin the accepted replacement and the symmetric comparison",
+                    "replacement_contract": _contract(
+                        "compose",
+                        scope_ref=FULL_SCOPE,
+                        write_scope=("script-build://writes",),
+                        input_decision_refs=(
+                            _decision_ref(
+                                direction_decision,
+                                direction_ref,
+                                scope_ref=FULL_SCOPE,
+                                task_kind="direction",
+                            ),
+                        ),
+                        closure=(structure_pin, paragraph_pin, replacement_pin),
+                        adopted=(structure_decision, replacement_decision),
+                        held=(paragraph_decision,),
+                        compose_order=(structure_decision, replacement_decision),
+                        comparison_decision_refs=(
+                            _decision_ref(
+                                comparison_decision,
+                                comparison_ref,
+                                scope_ref="script-build://scopes/full/opening",
+                                task_kind="compare",
+                            ),
+                        ),
+                    ),
+                },
+            )
+        if compose.status is TaskStatus.PENDING:
+            return self._call("dispatch_script_tasks", {"task_ids": [compose.task_id]})
+        if compose.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(compose, ledger)
+
+        compose_decision, compose_ref = _accepted_ref(ledger, compose)
+        if portfolio.current_spec_version == 1:
+            compose_pin = _decision_ref(
+                compose_decision,
+                compose_ref,
+                scope_ref=FULL_SCOPE,
+                task_kind="compose",
+            )
+            return self._call(
+                "decide_script_task",
+                {
+                    "task_id": portfolio.task_id,
+                    "action": "revise",
+                    "reason": "pin the uniquely adopted StructuredScript",
+                    "replacement_contract": _contract(
+                        "candidate-portfolio",
+                        scope_ref=FULL_SCOPE,
+                        write_scope=("script-build://writes",),
+                        closure=(compose_pin,),
+                        adopted=(compose_decision,),
+                        compose_order=(compose_decision,),
+                    ),
+                },
+            )
+        if portfolio.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [portfolio.task_id]})
+        if portfolio.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(portfolio, ledger)
+
+        assert portfolio.status is TaskStatus.COMPLETED
+        return self._call(
+            "decide_script_task",
+            {
+                "task_id": root.task_id,
+                "action": "block",
+                "reason": PHASE_TWO_CANDIDATE_PORTFOLIO_READY,
+            },
+        )
+
+    def _accept(self, task: Any, ledger: Any) -> dict[str, Any]:
+        validation = ledger.validations[task.validation_ids[-1]]
+        assert validation.verdict is ValidationVerdict.PASSED
+        return self._call(
+            "decide_script_task",
+            {
+                "task_id": task.task_id,
+                "validation_id": validation.validation_id,
+                "action": "accept",
+                "reason": "the independent Validator passed the frozen artifact",
+            },
+        )
+
+    def _worker(self, messages, names: set[str]) -> dict[str, Any]:
+        prompt = _role_prompt(messages)
+        spec = cast(Mapping[str, Any], prompt["task_spec"])
+        refs = cast(Sequence[str], spec["context_refs"])
+        kind = next(item.rsplit("/", 1)[-1] for item in refs if "/task-kinds/" in item)
+        if kind not in self.worker_kinds:
+            self.worker_kinds.append(kind)
+        last = _last_tool_value(messages)
+        if kind == "decode-retrieval":
+            if not isinstance(last, dict) or "artifact_ref" not in last:
+                return self._call(
+                    "search_script_decode_case",
+                    {"return_field": "主脉络", "keyword": "observable reversal", "top_k": 3},
+                )
+            return self._call("submit_attempt", {})
+        if kind == "direction":
+            if not isinstance(last, dict) or "artifact_ref" not in last:
+                accepted = cast(Sequence[Mapping[str, Any]], prompt["accepted_child_results"])
+                evidence_refs = cast(
+                    Sequence[Mapping[str, Any]],
+                    cast(Mapping[str, Any], accepted[0]["submission"])["evidence_refs"],
+                )
+                return self._call(
+                    "save_direction_candidate",
+                    {
+                        "goals": [
+                            {
+                                "goal_id": "direction-goal",
+                                "statement": "open with one observable reversal",
+                                "rationale": "the frozen topic benefits from a concrete hook",
+                            }
+                        ],
+                        "criteria": [
+                            {
+                                "criterion_id": "grounded-direction",
+                                "description": "the direction is concrete and testable",
+                            }
+                        ],
+                        "legacy_markdown": "# Direction\n\nOpen with one observable reversal.",
+                        "evidence_refs": [evidence_refs[0]["uri"]],
+                    },
+                )
+            return self._call("submit_attempt", {})
+        if kind == "structure":
+            if not isinstance(last, dict):
+                return self._call(
+                    "create_script_paragraph",
+                    {
+                        "paragraph_index": 1,
+                        "name": "Opening structure",
+                        "content_range": {"scope": "opening", "beats": ["setup", "reversal"]},
+                        "theme_elements": [
+                            {
+                                "原子点": "assumption changes after evidence",
+                                "维度": "claim",
+                                "维度类型": "主维度",
+                            }
+                        ],
+                        "form_elements": [
+                            {
+                                "原子点": "setup then concrete reversal",
+                                "维度": "shape",
+                                "维度类型": "主维度",
+                            }
+                        ],
+                        "function_elements": [
+                            {
+                                "原子点": "create curiosity without abstraction",
+                                "维度": "job",
+                                "维度类型": "主维度",
+                            }
+                        ],
+                        "feeling_elements": [{"原子点": "measured surprise", "维度": "tone"}],
+                    },
+                )
+            return self._call("submit_attempt", {})
+        if kind == "paragraph":
+            if isinstance(last, dict) and last.get("updated") == 1:
+                return self._call("submit_attempt", {})
+            if not isinstance(last, dict) or "paragraphs" not in last:
+                return self._call("read_attempt_workspace", {})
+            paragraphs = cast(Sequence[Mapping[str, Any]], last["paragraphs"])
+            assert len(paragraphs) == 1
+            replacement = "replace the placeholder" in str(spec["objective"])
+            return self._call(
+                "batch_update_script_paragraphs",
+                {
+                    "updates": [
+                        {
+                            "paragraph_id": paragraphs[0]["paragraph_id"],
+                            "theme": (
+                                "The retrieved observable reversal changes the opening claim."
+                                if replacement
+                                else "A familiar expectation changes after a visible detail."
+                            ),
+                            "form": "A concrete setup followed by one concise reversal.",
+                            "function": "Give the audience an immediate reason to continue.",
+                            "feeling": "Measured surprise grounded in observation.",
+                            "description": "The opening overturns one assumption with evidence.",
+                            "full_description": (
+                                "Use the accepted retrieval evidence to replace the unrealized "
+                                "opening and show exactly how the observed detail changes the "
+                                "interpretation."
+                                if replacement
+                                else "Start from the familiar expectation, name the visible "
+                                "detail, and show exactly how that detail changes the "
+                                "interpretation."
+                            ),
+                        }
+                    ]
+                },
+            )
+        if kind == "compare":
+            if isinstance(last, dict) and "artifact_ref" in last:
+                return self._call("submit_attempt", {})
+            if not isinstance(last, list):
+                return self._call("read_pinned_candidates", {})
+            assert len(last) == 2
+            candidates = [str(item["artifact_ref"]["uri"]) for item in last]
+            return self._call(
+                "save_comparison_candidate",
+                {
+                    "criterion_results": [
+                        {
+                            "criterion_id": "compare-closed",
+                            "candidate_results": [
+                                {
+                                    "artifact_ref": candidate,
+                                    "reason": "reviewed against the same frozen criterion",
+                                }
+                                for candidate in candidates
+                            ],
+                        }
+                    ],
+                    "conflicts": [
+                        "candidate B realizes the retrieved evidence while candidate A does not"
+                    ],
+                    "recommendation": candidates[-1],
+                },
+            )
+        if kind == "compose":
+            if isinstance(last, dict) and "artifact_ref" in last:
+                return self._call("submit_attempt", {})
+            if not isinstance(last, dict) or last.get("schema_version") != "active-frontier/v1":
+                return self._call("read_active_frontier", {})
+            self.compose_candidates_saved += 1
+            return self._call(
+                "save_structured_script_candidate",
+                {
+                    "acceptance_notes": [
+                        "TODO replace this placeholder opening with retrieved evidence."
+                        if self.compose_candidates_saved == 1
+                        else "The adopted replacement is concrete and fully rendered."
+                    ]
+                },
+            )
+        if kind == "candidate-portfolio":
+            if isinstance(last, dict) and "artifact_ref" in last:
+                return self._call("submit_attempt", {})
+            if not isinstance(last, list):
+                return self._call("read_pinned_candidates", {})
+            assert len(last) == 1
+            return self._call(
+                "save_candidate_portfolio",
+                {
+                    "superseded_decision_ids": [],
+                    "unresolved_defects": [],
+                },
+            )
+        raise AssertionError(f"unexpected Worker kind {kind}; tools={sorted(names)}")
+
+    def _validator(self, messages) -> dict[str, Any]:
+        prompt = _role_prompt(messages)
+        spec = cast(Mapping[str, Any], prompt["task_spec"])
+        refs = cast(Sequence[str], spec["context_refs"])
+        kind = next(item.rsplit("/", 1)[-1] for item in refs if "/task-kinds/" in item)
+        if kind not in self.validator_kinds:
+            self.validator_kinds.append(kind)
+        last = _last_tool_value(messages)
+        if not isinstance(last, dict) or "rule_results" not in last:
+            return self._call("deterministic_precheck", {})
+        criteria = cast(Sequence[Mapping[str, Any]], spec["acceptance_criteria"])
+        failed_rules = [item for item in last["rule_results"] if item["verdict"] != "passed"]
+        if failed_rules:
+            artifact_snapshot = cast(Mapping[str, Any], prompt["artifact_snapshot"])
+            refs = cast(Sequence[Mapping[str, Any]], artifact_snapshot["artifact_refs"])
+            assert len(refs) == 1
+            return self._call(
+                "submit_validation",
+                {
+                    "verdict": "failed",
+                    "criterion_results": [
+                        {
+                            "criterion_id": item["criterion_id"],
+                            "verdict": "failed",
+                            "reason": "the rendered candidate still contains a placeholder",
+                        }
+                        for item in criteria
+                    ],
+                    "defects": [
+                        {
+                            "defect_code": "REALIZATION_PLACEHOLDER",
+                            "criterion_id": criteria[0]["criterion_id"],
+                            "scope_ref": "script-build://scopes/full/opening",
+                            "observed_excerpt": (
+                                "TODO replace this placeholder opening with retrieved evidence."
+                            ),
+                            "evidence_refs": [dict(refs[0])],
+                            "severity": "hard",
+                            "invalidated_inputs": [],
+                            "recommended_action_class": "split",
+                        }
+                    ],
+                    "recommendation": "split",
+                },
+            )
+        assert all(item["verdict"] == "passed" for item in last["rule_results"])
+        return self._call(
+            "submit_validation",
+            {
+                "verdict": "passed",
+                "criterion_results": [
+                    {
+                        "criterion_id": item["criterion_id"],
+                        "verdict": "passed",
+                        "reason": "the immutable artifact passed deterministic and semantic review",
+                    }
+                    for item in criteria
+                ],
+                "defects": [],
+                "recommendation": "accept",
+            },
+        )
+
+
+class _OrderedExplorationScriptedLLM(_PhaseTwoScriptedLLM):
+    """Close Phase2 after exercising each creative preset in a chosen order."""
+
+    def __init__(self, store: FileSystemTaskStore, order: tuple[str, str, str]) -> None:
+        super().__init__(store)
+        self.order = order
+        # This scenario checks ordering, not placeholder repair. The parent Worker
+        # increments this before saving, so its first Compose is fully realized.
+        self.compose_candidates_saved = 1
+
+    async def _planner(self) -> dict[str, Any]:
+        ledger = await self.store.load(ROOT)
+        tasks = [task for task in ledger.tasks.values() if task.task_id != ledger.root_task_id]
+        direction = next((task for task in tasks if _task_kind(task) == "direction"), None)
+        if (
+            direction is None
+            or direction.status is not TaskStatus.COMPLETED
+            or not self.phase_two_active
+        ):
+            return await super()._planner()
+
+        root = ledger.tasks[ledger.root_task_id]
+
+        def by_objective(value: str) -> Any | None:
+            return next((task for task in tasks if task.current_spec.objective == value), None)
+
+        portfolio = next(
+            (task for task in tasks if _task_kind(task) == "candidate-portfolio"), None
+        )
+        if portfolio is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "contracts": [
+                        _contract(
+                            "candidate-portfolio",
+                            scope_ref=FULL_SCOPE,
+                            write_scope=("script-build://writes",),
+                        )
+                    ]
+                },
+            )
+        compose = next((task for task in tasks if _task_kind(task) == "compose"), None)
+        if compose is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "parent_task_id": portfolio.task_id,
+                    "contracts": [
+                        _contract(
+                            "compose",
+                            scope_ref=FULL_SCOPE,
+                            write_scope=("script-build://writes",),
+                        )
+                    ],
+                },
+            )
+
+        exploratory: dict[str, Any] = {}
+        scopes = {
+            "structure": "script-build://scopes/full/opening/structure-sketch",
+            "paragraph": "script-build://scopes/full/opening",
+            "element-set": "script-build://scopes/full/opening/elements",
+        }
+        write_scopes = {
+            "structure": ("script-build://writes/paragraphs/opening",),
+            "paragraph": ("script-build://writes/paragraphs/opening",),
+            "element-set": ("script-build://writes/elements",),
+        }
+        for kind in self.order:
+            objective = f"explore {kind} before formal adoption"
+            task = by_objective(objective)
+            if task is None:
+                return self._call(
+                    "plan_script_tasks",
+                    {
+                        "parent_task_id": compose.task_id,
+                        "contracts": [
+                            _contract(
+                                kind,
+                                scope_ref=scopes[kind],
+                                write_scope=write_scopes[kind],
+                                objective=objective,
+                            )
+                        ],
+                    },
+                )
+            exploratory[kind] = task
+            if task.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+                return self._call("dispatch_script_tasks", {"task_ids": [task.task_id]})
+            if task.status is TaskStatus.AWAITING_DECISION:
+                return self._accept(task, ledger)
+
+        structure_decision, structure_ref = _accepted_ref(ledger, exploratory["structure"])
+        paragraph_decision, paragraph_ref = _accepted_ref(ledger, exploratory["paragraph"])
+        element_decision, element_ref = _accepted_ref(ledger, exploratory["element-set"])
+
+        paragraph_objective = "adopt structure and paragraph as one logical opening"
+        paragraph_replacement = by_objective(paragraph_objective)
+        if paragraph_replacement is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "parent_task_id": compose.task_id,
+                    "contracts": [
+                        _contract(
+                            "paragraph",
+                            scope_ref="script-build://scopes/full/opening",
+                            write_scope=("script-build://writes/paragraphs/opening",),
+                            objective=paragraph_objective,
+                            input_decision_refs=(
+                                _decision_ref(
+                                    structure_decision,
+                                    structure_ref,
+                                    scope_ref=scopes["structure"],
+                                    task_kind="structure",
+                                ),
+                                _decision_ref(
+                                    paragraph_decision,
+                                    paragraph_ref,
+                                    scope_ref="script-build://scopes/full/opening",
+                                    task_kind="paragraph",
+                                ),
+                            ),
+                            base_artifact_ref=_artifact_ref_payload(structure_ref),
+                            supersedes_decision_ids=(paragraph_decision,),
+                        )
+                    ],
+                },
+            )
+        if paragraph_replacement.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call(
+                "dispatch_script_tasks", {"task_ids": [paragraph_replacement.task_id]}
+            )
+        if paragraph_replacement.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(paragraph_replacement, ledger)
+        adopted_paragraph_decision, adopted_paragraph_ref = _accepted_ref(
+            ledger, paragraph_replacement
+        )
+
+        element_objective = "place the explored element on the adopted opening"
+        element_replacement = by_objective(element_objective)
+        if element_replacement is None:
+            return self._call(
+                "plan_script_tasks",
+                {
+                    "parent_task_id": compose.task_id,
+                    "contracts": [
+                        _contract(
+                            "element-set",
+                            scope_ref=scopes["element-set"],
+                            write_scope=("script-build://writes/elements",),
+                            objective=element_objective,
+                            input_decision_refs=(
+                                _decision_ref(
+                                    element_decision,
+                                    element_ref,
+                                    scope_ref=scopes["element-set"],
+                                    task_kind="element-set",
+                                ),
+                                _decision_ref(
+                                    adopted_paragraph_decision,
+                                    adopted_paragraph_ref,
+                                    scope_ref="script-build://scopes/full/opening",
+                                    task_kind="paragraph",
+                                ),
+                            ),
+                            base_artifact_ref=_artifact_ref_payload(adopted_paragraph_ref),
+                            supersedes_decision_ids=(element_decision,),
+                        )
+                    ],
+                },
+            )
+        if element_replacement.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [element_replacement.task_id]})
+        if element_replacement.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(element_replacement, ledger)
+        adopted_element_decision, adopted_element_ref = _accepted_ref(ledger, element_replacement)
+
+        direction_decision, direction_ref = _accepted_ref(ledger, direction)
+        pins = (
+            _decision_ref(
+                structure_decision,
+                structure_ref,
+                scope_ref=scopes["structure"],
+                task_kind="structure",
+            ),
+            _decision_ref(
+                paragraph_decision,
+                paragraph_ref,
+                scope_ref="script-build://scopes/full/opening",
+                task_kind="paragraph",
+            ),
+            _decision_ref(
+                element_decision,
+                element_ref,
+                scope_ref=scopes["element-set"],
+                task_kind="element-set",
+            ),
+            _decision_ref(
+                adopted_paragraph_decision,
+                adopted_paragraph_ref,
+                scope_ref="script-build://scopes/full/opening",
+                task_kind="paragraph",
+            ),
+            _decision_ref(
+                adopted_element_decision,
+                adopted_element_ref,
+                scope_ref=scopes["element-set"],
+                task_kind="element-set",
+            ),
+        )
+        if compose.current_spec_version == 1:
+            return self._call(
+                "decide_script_task",
+                {
+                    "task_id": compose.task_id,
+                    "action": "revise",
+                    "reason": "formal adoption follows immutable lineage, not exploration order",
+                    "replacement_contract": _contract(
+                        "compose",
+                        scope_ref=FULL_SCOPE,
+                        write_scope=("script-build://writes",),
+                        input_decision_refs=(
+                            _decision_ref(
+                                direction_decision,
+                                direction_ref,
+                                scope_ref=FULL_SCOPE,
+                                task_kind="direction",
+                            ),
+                        ),
+                        closure=pins,
+                        adopted=(
+                            structure_decision,
+                            adopted_paragraph_decision,
+                            adopted_element_decision,
+                        ),
+                        held=(paragraph_decision, element_decision),
+                        compose_order=(
+                            structure_decision,
+                            adopted_paragraph_decision,
+                            adopted_element_decision,
+                        ),
+                    ),
+                },
+            )
+        if compose.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [compose.task_id]})
+        if compose.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(compose, ledger)
+
+        compose_decision, compose_ref = _accepted_ref(ledger, compose)
+        if portfolio.current_spec_version == 1:
+            return self._call(
+                "decide_script_task",
+                {
+                    "task_id": portfolio.task_id,
+                    "action": "revise",
+                    "reason": "pin the unique accepted StructuredScript",
+                    "replacement_contract": _contract(
+                        "candidate-portfolio",
+                        scope_ref=FULL_SCOPE,
+                        write_scope=("script-build://writes",),
+                        closure=(
+                            _decision_ref(
+                                compose_decision,
+                                compose_ref,
+                                scope_ref=FULL_SCOPE,
+                                task_kind="compose",
+                            ),
+                        ),
+                        adopted=(compose_decision,),
+                        compose_order=(compose_decision,),
+                    ),
+                },
+            )
+        if portfolio.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
+            return self._call("dispatch_script_tasks", {"task_ids": [portfolio.task_id]})
+        if portfolio.status is TaskStatus.AWAITING_DECISION:
+            return self._accept(portfolio, ledger)
+        return self._call(
+            "decide_script_task",
+            {
+                "task_id": root.task_id,
+                "action": "block",
+                "reason": PHASE_TWO_CANDIDATE_PORTFOLIO_READY,
+            },
+        )
+
+    def _worker(self, messages, names: set[str]) -> dict[str, Any]:
+        prompt = _role_prompt(messages)
+        spec = cast(Mapping[str, Any], prompt["task_spec"])
+        refs = cast(Sequence[str], spec["context_refs"])
+        kind = next(item.rsplit("/", 1)[-1] for item in refs if "/task-kinds/" in item)
+        last = _last_tool_value(messages)
+        if kind == "paragraph":
+            assert "create_script_paragraph" in names
+            assert "create_script_element" not in names
+            if kind not in self.worker_kinds:
+                self.worker_kinds.append(kind)
+            if isinstance(last, dict) and last.get("updated") == 1:
+                return self._call("submit_attempt", {})
+            if not isinstance(last, dict) or "paragraphs" not in last:
+                return self._call("read_attempt_workspace", {})
+            paragraphs = cast(Sequence[Mapping[str, Any]], last["paragraphs"])
+            if not paragraphs:
+                return self._call(
+                    "create_script_paragraph",
+                    {
+                        "paragraph_index": 1,
+                        "name": "Exploratory opening",
+                        "content_range": {"scope": "opening", "beats": ["setup", "reversal"]},
+                    },
+                )
+            return self._call(
+                "batch_update_script_paragraphs",
+                {
+                    "updates": [
+                        {
+                            "paragraph_id": paragraphs[0]["paragraph_id"],
+                            "theme": "One visible detail overturns the familiar assumption.",
+                            "form": "Concrete setup followed by a concise reversal.",
+                            "function": "Give the audience a grounded reason to continue.",
+                            "feeling": "Measured surprise.",
+                            "description": "A complete opening increment.",
+                            "full_description": (
+                                "The opening names the visible detail and explains exactly how "
+                                "it changes the audience's initial interpretation."
+                            ),
+                        }
+                    ]
+                },
+            )
+        if kind == "element-set":
+            assert "create_script_element" in names
+            assert "batch_link_paragraph_elements" in names
+            assert "create_script_paragraph" not in names
+            if kind not in self.worker_kinds:
+                self.worker_kinds.append(kind)
+            if isinstance(last, dict) and "artifact_ref" in last:
+                return self._call("submit_attempt", {})
+            if isinstance(last, dict) and last.get("linked") == 1:
+                return self._call("submit_attempt", {})
+            if not isinstance(last, dict):
+                return self._call(
+                    "create_script_element",
+                    {
+                        "name": "Observable reversal",
+                        "dimension_primary": "实质",
+                        "dimension_secondary": "opening evidence",
+                        "commonality_analysis": {"summary": "visible detail changes belief"},
+                        "topic_support": {"strength": "high"},
+                        "weight_score": {"value": 0.9},
+                        "support_elements": [{"ref": "decode-index://fixture/opening-reversal"}],
+                    },
+                )
+            if "element_id" in last:
+                return self._call("read_attempt_workspace", {})
+            if "paragraphs" in last and "elements" in last:
+                paragraphs = cast(Sequence[Mapping[str, Any]], last["paragraphs"])
+                elements = cast(Sequence[Mapping[str, Any]], last["elements"])
+                assert len(elements) == 1
+                if not paragraphs:
+                    return self._call("submit_attempt", {})
+                return self._call(
+                    "batch_link_paragraph_elements",
+                    {
+                        "links": [
+                            {
+                                "paragraph_id": paragraphs[0]["paragraph_id"],
+                                "element_ids": [elements[0]["element_id"]],
+                            }
+                        ]
+                    },
+                )
+        if kind == "structure":
+            assert "create_script_paragraph" in names
+            assert "create_script_element" not in names
+        return super()._worker(messages, names)
+
+
+class _PrincipalProvider:
+    async def current(self, request_context: Any = None) -> Principal:
+        del request_context
+        return Principal("phase-two-owner")
+
+
+class _Authorizer:
+    async def require_source_access(self, _principal: Principal, **_source: Any) -> None:
+        return None
+
+    async def require_access(self, _principal: Principal, _script_build_id: int) -> None:
+        return None
+
+
+class _DecodeAdapter:
+    async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
+        del snapshot
+        return RetrievalResult(
+            source_refs=("decode-index://fixture/opening-reversal",),
+            summary=f"A grounded opening reverses one expectation: {query['keyword']}",
+            supports=("direction",),
+            confidence="high",
+            metadata={"rank": 1, "score": 0.93},
+        )
+
+    async def require_access(self, _principal: Principal, _script_build_id: int) -> None:
+        return None
+
+
+class _PromptCatalog:
+    def __init__(self) -> None:
+        self._entries = {
+            (str(item["preset"]), str(item["role"])): dict(item)
+            for item in script_build_prompt_manifest()
+        }
+
+    async def load(self, requests: Sequence[Any]) -> tuple[dict[str, Any], ...]:
+        return tuple(dict(self._entries[(str(item.preset), str(item.role))]) for item in requests)
+
+
+class _BlockedAfterPolicyLLM(_PhaseTwoScriptedLLM):
+    """Hold the first Phase2 model call after policy persistence and UNBLOCK."""
+
+    def __init__(self, store: FileSystemTaskStore) -> None:
+        super().__init__(store)
+        self.phase_two_model_call = asyncio.Event()
+        self._never_release = asyncio.Event()
+
+    async def __call__(self, messages, tools, **kwargs):
+        del kwargs
+        if any("script-build-phase-two/v1" in str(item.get("content", "")) for item in messages):
+            self.phase_two_model_call.set()
+            await self._never_release.wait()
+        return await super().__call__(messages, tools)
+
+
+def _persisted_host(
+    *,
+    sessions: Any,
+    tmp_path: Path,
+    llm: Any,
+    enabled_phase: int,
+    model_manifest: Mapping[str, object],
+) -> Any:
+    snapshots = SqlAlchemyInputSnapshotRepository(sessions)
+    input_service = ScriptInputSnapshotService(
+        legacy_input=cast(Any, None),
+        persona_source=cast(Any, None),
+        strategy_source=cast(Any, None),
+        prompt_source=_PromptCatalog(),
+        snapshots=snapshots,
+    )
+    artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
+    task_store = FileSystemTaskStore(str(tmp_path / "ledger"))
+    runner = AgentRunner(
+        trace_store=FileSystemTraceStore(str(tmp_path / "trace")),
+        llm_call=llm,
+    )
+    return compose_host(
+        HostDependencies(
+            runner=runner,
+            task_store=task_store,
+            framework_artifact_store=FileSystemArtifactStore(str(tmp_path / "framework-artifacts")),
+            input_snapshot_service=input_service,
+            input_snapshots=snapshots,
+            bindings=SqlAlchemyMissionBindingRepository(sessions),
+            business_artifacts=artifacts,
+            publications=SqlAlchemyPublicationRepository(sessions),
+            legacy_state=SqlAlchemyLegacyBuildStateRepository(sessions),
+            principal_provider=_PrincipalProvider(),
+            build_authorizer=_Authorizer(),
+            retrieval_adapters={"decode": _DecodeAdapter()},
+            enabled_phase=enabled_phase,
+            agent_data_root=tmp_path / "agent-data",
+            candidate_workspaces=SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts),
+            default_model_manifest=model_manifest,
+        )
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "exploration_order",
+    [
+        pytest.param(("element-set", "paragraph", "structure"), id="element-first"),
+        pytest.param(("paragraph", "element-set", "structure"), id="paragraph-first"),
+        pytest.param(("structure", "paragraph", "element-set"), id="structure-first"),
+    ],
+)
+async def test_real_runner_creative_exploration_order_does_not_choose_adoption_order(
+    database: Any,
+    tmp_path: Path,
+    exploration_order: tuple[str, str, str],
+) -> None:
+    _, sessions = database
+    build_states = SqlAlchemyLegacyBuildStateRepository(sessions)
+    script_build_id = await build_states.create(
+        execution_id=711,
+        topic_build_id=712,
+        topic_id=713,
+        agent_type="script_planner",
+        agent_config={},
+        data_source_url=None,
+        strategies_config={},
+    )
+    prompt_manifest = script_build_prompt_manifest()
+    model_manifest = {
+        "presets": {
+            str(item["preset"]): {
+                "model": "scripted-fake-model",
+                "temperature": 0,
+                "max_iterations": 80,
+            }
+            for item in prompt_manifest
+        }
+    }
+    snapshot_repository = SqlAlchemyInputSnapshotRepository(sessions)
+    snapshot = await snapshot_repository.freeze(
+        ScriptBuildInput(
+            script_build_id=script_build_id,
+            execution_id=711,
+            topic_build_id=712,
+            topic_id=713,
+            topic={"topic": {"id": 713, "result": "Order must not determine adoption"}},
+            account={"account_name": "fixture-account"},
+            prompt_manifest=tuple(dict(item) for item in prompt_manifest),
+            model_manifest=model_manifest,
+        )
+    )
+    bindings = SqlAlchemyMissionBindingRepository(sessions)
+    await bindings.create(
+        script_build_id=script_build_id,
+        root_trace_id=ROOT,
+        input_snapshot_id=int(snapshot.snapshot_id),
+        engine_version="phase-two-ordered-real-runner-test",
+        schema_version="v2",
+    )
+    task_store = FileSystemTaskStore(str(tmp_path / "ledger"))
+    trace_store = FileSystemTraceStore(str(tmp_path / "trace"))
+    llm = _OrderedExplorationScriptedLLM(task_store, exploration_order)
+    runner = AgentRunner(trace_store=trace_store, llm_call=llm)
+    business_artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
+    composition = compose_host(
+        HostDependencies(
+            runner=runner,
+            task_store=task_store,
+            framework_artifact_store=FileSystemArtifactStore(str(tmp_path / "framework-artifacts")),
+            input_snapshot_service=cast(Any, snapshot_repository),
+            input_snapshots=snapshot_repository,
+            bindings=bindings,
+            business_artifacts=business_artifacts,
+            publications=SqlAlchemyPublicationRepository(sessions),
+            legacy_state=build_states,
+            principal_provider=_PrincipalProvider(),
+            build_authorizer=_Authorizer(),
+            retrieval_adapters={"decode": _DecodeAdapter()},
+            enabled_phase=2,
+            agent_data_root=tmp_path / "agent-data",
+            candidate_workspaces=SqlAlchemyCandidateWorkspaceRepository(
+                sessions, business_artifacts
+            ),
+            default_model_manifest=model_manifest,
+        )
+    )
+
+    await build_states.set_status(script_build_id, BuildStatus.RUNNING)
+    await composition.mission_service.run(script_build_id)
+
+    ledger = await task_store.load(ROOT)
+    root = ledger.tasks[ledger.root_task_id]
+    assert root.status is TaskStatus.BLOCKED
+    assert root.blocked_reason == PHASE_TWO_CANDIDATE_PORTFOLIO_READY
+    assert await build_states.get_status(script_build_id) is BuildStatus.PARTIAL
+    assert tuple(llm.worker_kinds[2:5]) == exploration_order
+    assert tuple(llm.validator_kinds[2:5]) == exploration_order
+
+    task_by_objective = {
+        task.current_spec.objective: task
+        for task in ledger.tasks.values()
+        if task.task_id != ledger.root_task_id
+    }
+    expected_presets = {
+        "structure": "script_structure_worker",
+        "paragraph": "script_paragraph_worker",
+        "element-set": "script_element_set_worker",
+    }
+    reopened_traces = FileSystemTraceStore(str(tmp_path / "trace"))
+    for kind in exploration_order:
+        task = task_by_objective[f"explore {kind} before formal adoption"]
+        attempt = ledger.attempts[task.attempt_ids[-1]]
+        assert attempt.worker_preset == expected_presets[kind]
+        worker_trace = await reopened_traces.get_trace(attempt.worker_trace_id)
+        assert worker_trace is not None and worker_trace.status == "completed"
+        assert await reopened_traces.get_trace_messages(attempt.worker_trace_id)
+        validation = next(
+            ledger.validations[item]
+            for item in task.validation_ids
+            if ledger.validations[item].attempt_id == attempt.attempt_id
+        )
+        assert validation.verdict is ValidationVerdict.PASSED
+        validator_trace = await reopened_traces.get_trace(validation.validator_trace_id)
+        assert validator_trace is not None and validator_trace.status == "completed"
+        assert await reopened_traces.get_trace_messages(validation.validator_trace_id)
+
+    element_replacement = task_by_objective["place the explored element on the adopted opening"]
+    element_decision, element_ref = _accepted_ref(ledger, element_replacement)
+    element_version = await business_artifacts.read_by_ref(
+        element_ref,
+        script_build_id=script_build_id,
+        task_id=element_replacement.task_id,
+        attempt_id=cast(str, ledger.decisions[element_decision].attempt_id),
+    )
+    assert element_version.artifact_type is ArtifactKind.ELEMENT_SET
+    assert len(cast(Any, element_version.artifact).paragraph_element_links) == 1
+
+    compose = next(
+        task
+        for task in ledger.tasks.values()
+        if task.task_id != ledger.root_task_id and _task_kind(task) == "compose"
+    )
+    assert compose.status is TaskStatus.COMPLETED
+    contract_ref = next(
+        item
+        for item in compose.current_spec.context_refs
+        if item.startswith("script-build://task-contracts/sha256/")
+    )
+    compose_contract = await FileScriptTaskContractStore(tmp_path / "agent-data").read(
+        ROOT, contract_ref
+    )
+    adopted_kinds = tuple(
+        _task_kind(ledger.tasks[ledger.decisions[decision_id].task_id])
+        for decision_id in compose_contract.contract.compose_order
+    )
+    assert adopted_kinds == ("structure", "paragraph", "element-set")
+    portfolio = next(
+        task
+        for task in ledger.tasks.values()
+        if task.task_id != ledger.root_task_id and _task_kind(task) == "candidate-portfolio"
+    )
+    assert portfolio.status is TaskStatus.COMPLETED
+
+
+@pytest.mark.asyncio
+async def test_mission_service_real_runner_auto_continues_phase_one_to_phase_two_boundary(
+    database: Any, tmp_path: Path
+) -> None:
+    _, sessions = database
+    build_states = SqlAlchemyLegacyBuildStateRepository(sessions)
+    script_build_id = await build_states.create(
+        execution_id=701,
+        topic_build_id=702,
+        topic_id=703,
+        agent_type="script_planner",
+        agent_config={},
+        data_source_url=None,
+        strategies_config={},
+    )
+    prompt_manifest = script_build_prompt_manifest()
+    model_manifest = {
+        "presets": {
+            str(item["preset"]): {
+                "model": "scripted-fake-model",
+                "temperature": 0,
+                "max_iterations": 80,
+            }
+            for item in prompt_manifest
+        }
+    }
+    snapshot_repository = SqlAlchemyInputSnapshotRepository(sessions)
+    snapshot = await snapshot_repository.freeze(
+        ScriptBuildInput(
+            script_build_id=script_build_id,
+            execution_id=701,
+            topic_build_id=702,
+            topic_id=703,
+            topic={"topic": {"id": 703, "result": "A concrete reversal changes a belief"}},
+            account={"account_name": "fixture-account"},
+            prompt_manifest=tuple(dict(item) for item in prompt_manifest),
+            model_manifest=model_manifest,
+        )
+    )
+    bindings = SqlAlchemyMissionBindingRepository(sessions)
+    await bindings.create(
+        script_build_id=script_build_id,
+        root_trace_id=ROOT,
+        input_snapshot_id=int(snapshot.snapshot_id),
+        engine_version="phase-two-real-runner-test",
+        schema_version="v2",
+    )
+    task_store = FileSystemTaskStore(str(tmp_path / "ledger"))
+    trace_store = FileSystemTraceStore(str(tmp_path / "trace"))
+    llm = _PhaseTwoScriptedLLM(task_store)
+    runner = AgentRunner(trace_store=trace_store, llm_call=llm)
+    framework_artifacts = FileSystemArtifactStore(str(tmp_path / "framework-artifacts"))
+    business_artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
+    workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, business_artifacts)
+    composition = compose_host(
+        HostDependencies(
+            runner=runner,
+            task_store=task_store,
+            framework_artifact_store=framework_artifacts,
+            input_snapshot_service=cast(Any, snapshot_repository),
+            input_snapshots=snapshot_repository,
+            bindings=bindings,
+            business_artifacts=business_artifacts,
+            publications=SqlAlchemyPublicationRepository(sessions),
+            legacy_state=build_states,
+            principal_provider=_PrincipalProvider(),
+            build_authorizer=_Authorizer(),
+            retrieval_adapters={"decode": _DecodeAdapter()},
+            enabled_phase=2,
+            agent_data_root=tmp_path / "agent-data",
+            candidate_workspaces=workspaces,
+            default_model_manifest=model_manifest,
+        )
+    )
+
+    await build_states.set_status(script_build_id, BuildStatus.RUNNING)
+    await composition.mission_service.run(script_build_id)
+
+    ledger = await task_store.load(ROOT)
+    root = ledger.tasks[ledger.root_task_id]
+    assert root.status is TaskStatus.BLOCKED
+    assert root.blocked_reason == PHASE_TWO_CANDIDATE_PORTFOLIO_READY
+    phase_two_blocks = [
+        item
+        for item in ledger.decisions.values()
+        if item.task_id == root.task_id
+        and item.action is DecisionAction.BLOCK
+        and item.reason == PHASE_TWO_CANDIDATE_PORTFOLIO_READY
+    ]
+    phase_one_blocks = [
+        item
+        for item in ledger.decisions.values()
+        if item.task_id == root.task_id
+        and item.action is DecisionAction.BLOCK
+        and item.reason == PHASE_ONE_CAPABILITY_BOUNDARY
+    ]
+    assert len(phase_one_blocks) == len(phase_two_blocks) == 1
+    assert await build_states.get_status(script_build_id) is BuildStatus.PARTIAL
+    assert llm.worker_kinds == [
+        "decode-retrieval",
+        "direction",
+        "structure",
+        "paragraph",
+        "compose",
+        "compare",
+        "candidate-portfolio",
+    ]
+    assert llm.validator_kinds == [
+        "decode-retrieval",
+        "direction",
+        "structure",
+        "paragraph",
+        "compose",
+        "compare",
+        "candidate-portfolio",
+    ]
+    assert llm.submit_attempt_arguments == [{}] * 10
+    assert llm.compose_candidates_saved == 2
+
+    compose_task = next(
+        task
+        for task in ledger.tasks.values()
+        if task.task_id != ledger.root_task_id and _task_kind(task) == "compose"
+    )
+    assert compose_task.current_spec_version == 3
+    assert len(compose_task.attempt_ids) == 2
+    assert [ledger.validations[item].verdict for item in compose_task.validation_ids] == [
+        ValidationVerdict.FAILED,
+        ValidationVerdict.PASSED,
+    ]
+    assert [ledger.decisions[item].action for item in compose_task.decision_ids] == [
+        DecisionAction.REVISE,
+        DecisionAction.SPLIT,
+        DecisionAction.REVISE,
+        DecisionAction.ACCEPT,
+    ]
+
+    paragraph_tasks = [
+        task
+        for task in ledger.tasks.values()
+        if task.task_id != ledger.root_task_id and _task_kind(task) == "paragraph"
+    ]
+    assert len(paragraph_tasks) == 2
+    replacement = next(
+        task for task in paragraph_tasks if "replace the placeholder" in task.current_spec.objective
+    )
+    phase_two_retrieval = next(
+        task
+        for task in ledger.tasks.values()
+        if task.task_id != ledger.root_task_id
+        and _task_kind(task) == "decode-retrieval"
+        and task.parent_task_id == replacement.task_id
+    )
+    replacement_attempt = ledger.attempts[replacement.attempt_ids[-1]]
+    assert replacement_attempt.accepted_child_decision_ids == (
+        phase_two_retrieval.decision_ids[-1],
+    )
+    assert (
+        len(
+            [
+                task
+                for task in ledger.tasks.values()
+                if task.task_id != ledger.root_task_id and _task_kind(task) == "compare"
+            ]
+        )
+        == 1
+    )
+
+    expected_identities = {
+        str(item["preset"]): str(item["content_sha256"]) for item in prompt_manifest
+    }
+    for attempt in ledger.attempts.values():
+        worker_trace = await trace_store.get_trace(attempt.worker_trace_id)
+        assert worker_trace is not None
+        assert (
+            worker_trace.context["role_prompt_identity"]
+            == expected_identities[attempt.worker_preset]
+        )
+        validations = [
+            ledger.validations[item]
+            for item in ledger.tasks[attempt.task_id].validation_ids
+            if ledger.validations[item].attempt_id == attempt.attempt_id
+        ]
+        assert len(validations) == 1
+        validator_trace = await trace_store.get_trace(validations[0].validator_trace_id)
+        assert validator_trace is not None
+        assert (
+            validator_trace.context["role_prompt_identity"]
+            == expected_identities[validations[0].validator_preset]
+        )
+
+    reopened_traces = FileSystemTraceStore(str(tmp_path / "trace"))
+    planner_trace = await reopened_traces.get_trace(ROOT)
+    assert planner_trace is not None
+    assert planner_trace.status == "incomplete"
+    assert planner_trace.error_message == "mission_blocked"
+    planner_messages = await reopened_traces.get_trace_messages(ROOT)
+    phase_two_policies = [
+        item
+        for item in planner_messages
+        if item.role == "system" and "script-build-phase-two/v1" in str(item.content)
+    ]
+    phase_two_continuations = [
+        item
+        for item in planner_messages
+        if item.role == "user"
+        and '"mission": "continue_toward_root"' in str(item.content)
+        and '"phase": 2' in str(item.content)
+    ]
+    assert len(phase_two_policies) == len(phase_two_continuations) == 1
+
+    portfolio_task = next(
+        task
+        for task in ledger.tasks.values()
+        if task.task_id != ledger.root_task_id and _task_kind(task) == "candidate-portfolio"
+    )
+
+    def belongs_to_phase_two(task: Any) -> bool:
+        current = task
+        while current.parent_task_id is not None:
+            if current.task_id == portfolio_task.task_id:
+                return True
+            current = ledger.tasks[current.parent_task_id]
+        return False
+
+    phase_two_attempts = [
+        attempt
+        for attempt in ledger.attempts.values()
+        if belongs_to_phase_two(ledger.tasks[attempt.task_id])
+    ]
+    assert phase_two_attempts
+    for attempt in phase_two_attempts:
+        worker_trace = await reopened_traces.get_trace(attempt.worker_trace_id)
+        assert worker_trace is not None and worker_trace.status == "completed"
+        assert await reopened_traces.get_trace_messages(attempt.worker_trace_id)
+        validation = next(
+            ledger.validations[item]
+            for item in ledger.tasks[attempt.task_id].validation_ids
+            if ledger.validations[item].attempt_id == attempt.attempt_id
+        )
+        validator_trace = await reopened_traces.get_trace(validation.validator_trace_id)
+        assert validator_trace is not None and validator_trace.status == "completed"
+        assert await reopened_traces.get_trace_messages(validation.validator_trace_id)
+
+    async with sessions() as session:
+        rows = (
+            (
+                await session.execute(
+                    select(artifact_version_table).where(
+                        artifact_version_table.c.script_build_id == script_build_id
+                    )
+                )
+            )
+            .mappings()
+            .all()
+        )
+        branch_ids = list(
+            await session.scalars(
+                select(script_build_paragraph.c.branch_id).where(
+                    script_build_paragraph.c.script_build_id == script_build_id
+                )
+            )
+        )
+        build = (
+            (
+                await session.execute(
+                    select(script_build_record).where(script_build_record.c.id == script_build_id)
+                )
+            )
+            .mappings()
+            .one()
+        )
+    kinds = {str(row["artifact_type"]) for row in rows}
+    assert {
+        ArtifactKind.DIRECTION.value,
+        ArtifactKind.PARAGRAPH.value,
+        ArtifactKind.COMPARISON.value,
+        ArtifactKind.STRUCTURED_SCRIPT.value,
+        ArtifactKind.CANDIDATE_PORTFOLIO.value,
+    } <= kinds
+    assert branch_ids and min(branch_ids) > 0 and 0 not in branch_ids
+    assert BuildStatus(str(build["status"])) is BuildStatus.PARTIAL
+    assert build["error_message"] == PHASE_TWO_CANDIDATE_PORTFOLIO_READY
+    assert build["reson_trace_id"] == ROOT
+
+
+@pytest.mark.asyncio
+async def test_historical_partial_advances_via_http_and_reenters_after_filestore_reload(
+    database: Any,
+    tmp_path: Path,
+) -> None:
+    """Exercise SQL v1->v2 CAS and the only pre-recovery crash-safe HTTP window."""
+
+    _, sessions = database
+    build_states = SqlAlchemyLegacyBuildStateRepository(sessions)
+    script_build_id = await build_states.create(
+        execution_id=801,
+        topic_build_id=802,
+        topic_id=803,
+        agent_type="script_planner",
+        agent_config={},
+        data_source_url=None,
+        strategies_config={},
+    )
+    phase_one_prompts = phase_one_prompt_manifest()
+    full_prompts = script_build_prompt_manifest()
+    phase_one_models = {
+        "presets": {
+            str(item["preset"]): {
+                "model": "scripted-fake-model",
+                "temperature": 0,
+                "max_iterations": 80,
+            }
+            for item in phase_one_prompts
+        }
+    }
+    full_models = {
+        "presets": {
+            str(item["preset"]): {
+                "model": "scripted-fake-model",
+                "temperature": 0,
+                "max_iterations": 80,
+            }
+            for item in full_prompts
+        }
+    }
+    snapshots = SqlAlchemyInputSnapshotRepository(sessions)
+    phase_one_snapshot = await snapshots.freeze(
+        ScriptBuildInput(
+            script_build_id=script_build_id,
+            execution_id=801,
+            topic_build_id=802,
+            topic_id=803,
+            topic={"topic": {"id": 803, "result": "A historical Phase1 checkpoint"}},
+            account={"account_name": "historical-fixture"},
+            prompt_manifest=tuple(dict(item) for item in phase_one_prompts),
+            model_manifest=phase_one_models,
+        )
+    )
+    bindings = SqlAlchemyMissionBindingRepository(sessions)
+    await bindings.create(
+        script_build_id=script_build_id,
+        root_trace_id=ROOT,
+        input_snapshot_id=int(phase_one_snapshot.snapshot_id),
+        engine_version="phase-two-http-reentry-test",
+        schema_version="v1",
+    )
+
+    phase_one_store = FileSystemTaskStore(str(tmp_path / "ledger"))
+    phase_one_host = _persisted_host(
+        sessions=sessions,
+        tmp_path=tmp_path,
+        llm=_PhaseTwoScriptedLLM(phase_one_store),
+        enabled_phase=1,
+        model_manifest=full_models,
+    )
+    await build_states.set_status(script_build_id, BuildStatus.RUNNING)
+    await phase_one_host.mission_service.run(script_build_id)
+
+    phase_one_ledger = await phase_one_store.load(ROOT)
+    phase_one_root = phase_one_ledger.tasks[phase_one_ledger.root_task_id]
+    assert phase_one_root.status is TaskStatus.BLOCKED
+    assert phase_one_root.blocked_reason == PHASE_ONE_CAPABILITY_BOUNDARY
+    assert await build_states.get_status(script_build_id) is BuildStatus.PARTIAL
+
+    # Reopen every file-backed component to represent the first Phase2 process.
+    transition_store = FileSystemTaskStore(str(tmp_path / "ledger"))
+    blocked_llm = _BlockedAfterPolicyLLM(transition_store)
+    transition_host = _persisted_host(
+        sessions=sessions,
+        tmp_path=tmp_path,
+        llm=blocked_llm,
+        enabled_phase=2,
+        model_manifest=full_models,
+    )
+    async with httpx.AsyncClient(
+        transport=httpx.ASGITransport(app=transition_host.app),
+        base_url="http://test",
+    ) as client:
+        response = await client.post(
+            f"/api/pattern/script_builds/{script_build_id}/phase-two/advance"
+        )
+    assert response.status_code == 200, response.text
+    first_advance = response.json()
+    assert first_advance["root_trace_id"] == ROOT
+    assert first_advance["input_snapshot_id"] != phase_one_snapshot.snapshot_id
+    phase_two_snapshot_id = str(first_advance["input_snapshot_id"])
+
+    await asyncio.wait_for(blocked_llm.phase_two_model_call.wait(), timeout=10)
+    transition_ledger = await transition_store.load(ROOT)
+    assert transition_ledger.tasks[transition_ledger.root_task_id].status is TaskStatus.NEEDS_REPLAN
+    assert await build_states.get_status(script_build_id) is BuildStatus.RUNNING
+    crashed_task = transition_host.mission_service._runs[script_build_id]
+    crashed_task.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await crashed_task
+
+    reopened_after_crash = FileSystemTraceStore(str(tmp_path / "trace"))
+    persisted_messages = await reopened_after_crash.get_trace_messages(ROOT)
+    persisted_events = await reopened_after_crash.get_events(ROOT, 0)
+    assert _phase_two_policy_count(persisted_messages) == 1
+    assert _phase_two_continuation_count(persisted_messages) == 1
+    assert _policy_migration_count(persisted_events) == 1
+
+    # A fresh Host must take the NEEDS_REPLAN reentry path without rewriting policy.
+    retry_store = FileSystemTaskStore(str(tmp_path / "ledger"))
+    retry_host = _persisted_host(
+        sessions=sessions,
+        tmp_path=tmp_path,
+        llm=_PhaseTwoScriptedLLM(retry_store),
+        enabled_phase=2,
+        model_manifest=full_models,
+    )
+    async with httpx.AsyncClient(
+        transport=httpx.ASGITransport(app=retry_host.app),
+        base_url="http://test",
+    ) as client:
+        retry_response = await client.post(
+            f"/api/pattern/script_builds/{script_build_id}/phase-two/advance"
+        )
+    assert retry_response.status_code == 200, retry_response.text
+    assert retry_response.json()["root_trace_id"] == ROOT
+    assert retry_response.json()["input_snapshot_id"] == phase_two_snapshot_id
+    retry_task = retry_host.mission_service._runs[script_build_id]
+    await asyncio.wait_for(asyncio.shield(retry_task), timeout=60)
+
+    final_binding = await bindings.get_by_build(script_build_id)
+    assert final_binding.root_trace_id == ROOT
+    assert str(final_binding.input_snapshot_id) == phase_two_snapshot_id
+    phase_two_snapshot = await snapshots.get(
+        phase_two_snapshot_id,
+        script_build_id=script_build_id,
+    )
+    assert phase_two_snapshot.parent_snapshot_id == phase_one_snapshot.snapshot_id
+    assert phase_two_snapshot.parent_snapshot_sha256 == phase_one_snapshot.canonical_sha256
+    assert (
+        phase_two_snapshot.business_input_sha256
+        == canonical_sha256(phase_one_snapshot.to_input().business_payload()).wire
+    )
+    assert {str(item["preset"]) for item in phase_two_snapshot.prompt_manifest} == {
+        str(item["preset"]) for item in full_prompts
+    }
+
+    async with sessions() as session:
+        snapshot_rows = (
+            (
+                await session.execute(
+                    select(input_snapshot_table)
+                    .where(input_snapshot_table.c.script_build_id == script_build_id)
+                    .order_by(input_snapshot_table.c.version)
+                )
+            )
+            .mappings()
+            .all()
+        )
+        binding_row = (
+            (
+                await session.execute(
+                    select(mission_binding_table).where(
+                        mission_binding_table.c.script_build_id == script_build_id
+                    )
+                )
+            )
+            .mappings()
+            .one()
+        )
+    assert [int(item["version"]) for item in snapshot_rows] == [1, 2]
+    assert int(binding_row["input_snapshot_id"]) == int(phase_two_snapshot_id)
+    assert str(binding_row["root_trace_id"]) == ROOT
+
+    final_ledger = await retry_store.load(ROOT)
+    final_root = final_ledger.tasks[final_ledger.root_task_id]
+    assert final_root.status is TaskStatus.BLOCKED
+    assert final_root.blocked_reason == PHASE_TWO_CANDIDATE_PORTFOLIO_READY
+    assert await build_states.get_status(script_build_id) is BuildStatus.PARTIAL
+    direction = next(
+        item
+        for item in final_ledger.tasks.values()
+        if item.task_id != final_ledger.root_task_id and _task_kind(item) == "direction"
+    )
+    direction_attempt = final_ledger.attempts[direction.attempt_ids[-1]]
+    direction_spec = next(
+        item for item in direction.specs if item.version == direction_attempt.spec_version
+    )
+    assert f"script-build://inputs/{phase_one_snapshot.snapshot_id}" in direction_spec.context_refs
+
+    final_traces = FileSystemTraceStore(str(tmp_path / "trace"))
+    final_messages = await final_traces.get_trace_messages(ROOT)
+    final_events = await final_traces.get_events(ROOT, 0)
+    assert _phase_two_policy_count(final_messages) == 1
+    assert _phase_two_continuation_count(final_messages) == 1
+    assert _policy_migration_count(final_events) == 1
+
+
+def _phase_two_policy_count(messages: Sequence[Any]) -> int:
+    return len(
+        [
+            item
+            for item in messages
+            if item.role == "system" and "script-build-phase-two/v1" in str(item.content)
+        ]
+    )
+
+
+def _phase_two_continuation_count(messages: Sequence[Any]) -> int:
+    return len(
+        [
+            item
+            for item in messages
+            if item.role == "user" and '"mission": "continue_toward_root"' in str(item.content)
+        ]
+    )
+
+
+def _policy_migration_count(events: Sequence[Mapping[str, Any]]) -> int:
+    return len(
+        [
+            item
+            for item in events
+            if item.get("event") == "planner_policy_migrated"
+            or item.get("event_type") == "planner_policy_migrated"
+        ]
+    )