test_phase_three_contracts.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from types import SimpleNamespace
  4. from unittest.mock import AsyncMock
  5. import pytest
  6. from agent.orchestration import TaskStatus
  7. from script_build_host.application.phase_three import (
  8. PHASE_THREE_EXECUTION_STARTED,
  9. PHASE_THREE_POLICY,
  10. PhaseThreeContinuationService,
  11. )
  12. from script_build_host.domain.errors import (
  13. LegacyCanonicalIdentityConflict,
  14. MissionRecoveryRequired,
  15. PhaseThreeBoundaryNotReady,
  16. ProtocolViolation,
  17. )
  18. from script_build_host.domain.phase_three_artifacts import (
  19. RootDeliveryManifestV1,
  20. canonicalize_legacy_projection,
  21. )
  22. from script_build_host.domain.phase_two_artifacts import (
  23. GoalCoverage,
  24. ScriptElementV1,
  25. ScriptParagraphElementLinkV1,
  26. ScriptParagraphV1,
  27. StructuredScriptArtifactV1,
  28. )
  29. from script_build_host.domain.records import BuildStatus
  30. _DIGEST = "sha256:" + "a" * 64
  31. def _script(
  32. *,
  33. root_id: int = 10,
  34. child_id: int = 20,
  35. element_id: int = 30,
  36. child_description: str = "child body",
  37. ) -> StructuredScriptArtifactV1:
  38. return StructuredScriptArtifactV1(
  39. direction_ref="script-build://artifact-versions/1",
  40. input_closure_digest=_DIGEST,
  41. paragraphs=(
  42. ScriptParagraphV1(
  43. paragraph_id=root_id,
  44. paragraph_index=1,
  45. level=1,
  46. parent_id=None,
  47. name="root",
  48. content_range={"start": 0, "end": 10},
  49. description="root body",
  50. ),
  51. ScriptParagraphV1(
  52. paragraph_id=child_id,
  53. paragraph_index=2,
  54. level=2,
  55. parent_id=root_id,
  56. name="child",
  57. content_range={"start": 10, "end": 20},
  58. description=child_description,
  59. ),
  60. ),
  61. elements=(
  62. ScriptElementV1(
  63. element_id=element_id,
  64. name="hook",
  65. dimension_primary="形式",
  66. dimension_secondary="opening",
  67. topic_support={"score": 1},
  68. ),
  69. ),
  70. paragraph_element_links=(
  71. ScriptParagraphElementLinkV1(
  72. paragraph_id=child_id,
  73. element_id=element_id,
  74. ),
  75. ),
  76. source_artifact_refs=("script-build://artifact-versions/2",),
  77. goal_coverage=(
  78. GoalCoverage("goal-1", ("script-build://artifact-versions/2",)),
  79. ),
  80. evidence_refs=(),
  81. acceptance_notes=(),
  82. )
  83. def test_legacy_canonical_digest_ignores_every_physical_identity() -> None:
  84. first = canonicalize_legacy_projection(_script(), direction="# D", summary="summary")
  85. second = canonicalize_legacy_projection(
  86. _script(root_id=101, child_id=202, element_id=303),
  87. direction="# D",
  88. summary="summary",
  89. )
  90. assert first.content_payload() == second.content_payload()
  91. assert first.canonical_sha256 == second.canonical_sha256
  92. def test_legacy_canonical_digest_changes_with_business_content() -> None:
  93. first = canonicalize_legacy_projection(_script(), direction="# D", summary="summary")
  94. changed_body = canonicalize_legacy_projection(
  95. _script(child_description="changed"), direction="# D", summary="summary"
  96. )
  97. changed_direction = canonicalize_legacy_projection(
  98. _script(), direction="# other", summary="summary"
  99. )
  100. assert first.canonical_sha256 != changed_body.canonical_sha256
  101. assert first.canonical_sha256 != changed_direction.canonical_sha256
  102. def test_legacy_canonical_rejects_parent_cycles_and_duplicate_natural_elements() -> None:
  103. cycle = replace(
  104. _script(),
  105. paragraphs=(
  106. ScriptParagraphV1(10, 1, 2, 20, "a", {}),
  107. ScriptParagraphV1(20, 2, 2, 10, "b", {}),
  108. ),
  109. )
  110. with pytest.raises(LegacyCanonicalIdentityConflict, match="cycle"):
  111. canonicalize_legacy_projection(cycle)
  112. duplicate_elements = replace(
  113. _script(),
  114. elements=(
  115. ScriptElementV1(30, "hook", "形式", "opening"),
  116. ScriptElementV1(31, "hook", "形式", "opening"),
  117. ),
  118. paragraph_element_links=(),
  119. )
  120. with pytest.raises(LegacyCanonicalIdentityConflict, match="duplicated"):
  121. canonicalize_legacy_projection(duplicate_elements)
  122. def test_root_delivery_manifest_is_strict_bounded_and_publishable() -> None:
  123. manifest = RootDeliveryManifestV1.from_payload(
  124. {
  125. "schema_version": "root-delivery-manifest/v1",
  126. "direction_ref": "script-build://artifact-versions/1",
  127. "candidate_portfolio_ref": "script-build://artifact-versions/2",
  128. "structured_script_ref": "script-build://artifact-versions/3",
  129. "input_closure_digest": _DIGEST,
  130. "legacy_projection_digest": _DIGEST,
  131. "build_summary": "ok",
  132. "blocking_defects": [],
  133. }
  134. ).with_digest()
  135. manifest.require_publishable()
  136. assert manifest.canonical_sha256.startswith("sha256:")
  137. with pytest.raises(ProtocolViolation, match="unknown fields"):
  138. RootDeliveryManifestV1.from_payload({**manifest.content_payload(), "unknown": True})
  139. with pytest.raises(ProtocolViolation, match="blocking defects"):
  140. replace(
  141. manifest, blocking_defects=("not ready",), canonical_sha256=""
  142. ).require_publishable()
  143. with pytest.raises(ProtocolViolation, match="2,000"):
  144. replace(manifest, build_summary="x" * 2_001, canonical_sha256="")
  145. @pytest.mark.asyncio
  146. async def test_phase_three_model_execution_marker_is_single_use() -> None:
  147. class TraceStore:
  148. def __init__(self) -> None:
  149. self.events: list[dict[str, str]] = []
  150. async def get_events(self, _trace_id: str, _since: int):
  151. return list(self.events)
  152. async def append_event(self, _trace_id: str, event: str, payload: dict[str, str]):
  153. self.events.append({"event": event, **payload})
  154. return len(self.events)
  155. service = object.__new__(PhaseThreeContinuationService)
  156. trace_store = TraceStore()
  157. service.runner = SimpleNamespace(trace_store=trace_store)
  158. await service._claim_policy_execution("root", "snapshot-3")
  159. assert trace_store.events == [
  160. {
  161. "event": PHASE_THREE_EXECUTION_STARTED,
  162. "policy_version": PHASE_THREE_POLICY,
  163. "input_snapshot_id": "snapshot-3",
  164. }
  165. ]
  166. with pytest.raises(MissionRecoveryRequired):
  167. await service._claim_policy_execution("root", "snapshot-3")
  168. @pytest.mark.asyncio
  169. async def test_phase_three_reentry_is_allowed_only_before_model_execution() -> None:
  170. class TraceStore:
  171. def __init__(self) -> None:
  172. self.events = [
  173. {
  174. "event": "planner_policy_migrated",
  175. "policy_version": PHASE_THREE_POLICY,
  176. "input_snapshot_id": "snapshot-3",
  177. }
  178. ]
  179. async def get_events(self, _trace_id: str, _since: int):
  180. return list(self.events)
  181. async def append_event(self, _trace_id: str, event: str, payload: dict[str, str]):
  182. self.events.append({"event": event, **payload})
  183. return len(self.events)
  184. service = object.__new__(PhaseThreeContinuationService)
  185. trace_store = TraceStore()
  186. service.runner = SimpleNamespace(trace_store=trace_store)
  187. assert await service._phase_three_reentry_ready("root") is True
  188. await service._claim_policy_execution("root", "snapshot-3")
  189. with pytest.raises(MissionRecoveryRequired):
  190. await service._phase_three_reentry_ready("root")
  191. @pytest.mark.asyncio
  192. async def test_completed_root_does_not_restore_a_failed_build() -> None:
  193. service = object.__new__(PhaseThreeContinuationService)
  194. service._verify_owner = AsyncMock()
  195. service._set_root_accepted_checkpoint = AsyncMock()
  196. service.bindings = SimpleNamespace(
  197. get_by_build=AsyncMock(
  198. return_value=SimpleNamespace(root_trace_id="root", input_snapshot_id=3)
  199. )
  200. )
  201. service.legacy_state = SimpleNamespace(get_status=AsyncMock(return_value=BuildStatus.FAILED))
  202. service.input_snapshots = SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace()))
  203. root = SimpleNamespace(status=TaskStatus.COMPLETED)
  204. service.coordinator = SimpleNamespace(
  205. task_store=SimpleNamespace(
  206. load=AsyncMock(
  207. return_value=SimpleNamespace(root_task_id="root-task", tasks={"root-task": root})
  208. )
  209. )
  210. )
  211. owner_token = SimpleNamespace(root_trace_id="root")
  212. with pytest.raises(PhaseThreeBoundaryNotReady, match="failed"):
  213. await service.run(7, owner_token=owner_token)
  214. service._set_root_accepted_checkpoint.assert_not_awaited()