test_phase_three_contracts.py 8.5 KB

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