test_phase_two_boundary.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. from dataclasses import replace
  5. from datetime import UTC, datetime
  6. import pytest
  7. from agent.orchestration import (
  8. AcceptanceCriterion,
  9. ArtifactRef,
  10. ArtifactSnapshot,
  11. AttemptStatus,
  12. AttemptSubmission,
  13. DecisionAction,
  14. PlannerDecision,
  15. TaskAttempt,
  16. TaskLedger,
  17. TaskRecord,
  18. TaskSpec,
  19. TaskStatus,
  20. ValidationReport,
  21. ValidationRunStatus,
  22. ValidationVerdict,
  23. )
  24. from script_build_host.application.phase_two_boundary import ScriptPhaseTwoBoundaryVerifier
  25. from script_build_host.application.phase_two_inputs import AcceptedInputResolver
  26. from script_build_host.domain.artifacts import ArtifactKind, ArtifactState, ArtifactVersion
  27. from script_build_host.domain.errors import PhaseTwoBoundaryNotReady
  28. from script_build_host.domain.phase_two_artifacts import (
  29. CandidatePortfolioArtifactV1,
  30. ScriptParagraphV1,
  31. StructuredScriptArtifactV1,
  32. )
  33. from script_build_host.domain.records import MissionBinding
  34. from script_build_host.domain.task_contracts import (
  35. AcceptedDecisionRef,
  36. ScriptCriterion,
  37. ScriptIntentClass,
  38. ScriptTaskBudget,
  39. ScriptTaskContractV1,
  40. ScriptTaskKind,
  41. )
  42. SNAPSHOT_ID = "11"
  43. INPUT_DIGEST = "sha256:" + "c" * 64
  44. SCRIPT_DIGEST = "sha256:" + "a" * 64
  45. PORTFOLIO_DIGEST = "sha256:" + "b" * 64
  46. SCOPE = "script-build://scopes/full"
  47. WRITE = "script-build://writes/full"
  48. def _task(task_id: str, parent: str | None, kind: str, status: TaskStatus) -> TaskRecord:
  49. return TaskRecord(
  50. task_id=task_id,
  51. goal_id=None,
  52. parent_task_id=parent,
  53. display_path=task_id,
  54. specs=[
  55. TaskSpec(
  56. version=1,
  57. objective="bounded",
  58. acceptance_criteria=(AcceptanceCriterion("closed", "closed"),),
  59. context_refs=(
  60. f"script-build://task-kinds/{kind}",
  61. f"script-build://inputs/{SNAPSHOT_ID}",
  62. ),
  63. )
  64. ],
  65. status=status,
  66. )
  67. def _compose_contract() -> ScriptTaskContractV1:
  68. return ScriptTaskContractV1(
  69. ScriptTaskKind.COMPOSE,
  70. SCOPE,
  71. ScriptIntentClass.COMPOSE,
  72. "compose one complete candidate",
  73. (),
  74. None,
  75. (WRITE,),
  76. None,
  77. "structured-script/v1",
  78. (ScriptCriterion("closed", "closed"),),
  79. ScriptTaskBudget(),
  80. )
  81. def _portfolio_contract(ref: ArtifactRef) -> ScriptTaskContractV1:
  82. accepted = AcceptedDecisionRef("decision-compose", ref, SCOPE, ScriptTaskKind.COMPOSE)
  83. return ScriptTaskContractV1(
  84. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  85. SCOPE,
  86. ScriptIntentClass.PORTFOLIO,
  87. "govern the closed candidate set",
  88. (accepted,),
  89. None,
  90. (WRITE,),
  91. None,
  92. "candidate-portfolio/v1",
  93. (ScriptCriterion("closed", "closed"),),
  94. ScriptTaskBudget(),
  95. candidate_closure_decision_refs=(accepted,),
  96. adopted_decision_ids=("decision-compose",),
  97. compose_order=("decision-compose",),
  98. )
  99. class _Store:
  100. def __init__(self, value: object) -> None:
  101. self.value = value
  102. async def load(self, root: str) -> TaskLedger:
  103. assert root == "root"
  104. assert isinstance(self.value, TaskLedger)
  105. return self.value
  106. async def get(self, root: str, snapshot: str) -> ArtifactSnapshot:
  107. assert root == "root"
  108. return self.value[snapshot] # type: ignore[index]
  109. class _Contracts:
  110. def __init__(self, values: dict[str, ScriptTaskContractV1]) -> None:
  111. self.values = values
  112. async def read_for_task(
  113. self, *, root_trace_id: str, task: TaskRecord, spec_version: int | None = None
  114. ) -> ScriptTaskContractV1:
  115. assert root_trace_id == "root" and spec_version == 1
  116. return self.values[task.task_id]
  117. class _Artifacts:
  118. def __init__(self, versions: dict[int, ArtifactVersion]) -> None:
  119. self.versions = versions
  120. async def read_by_ref(
  121. self,
  122. ref: ArtifactRef,
  123. *,
  124. script_build_id: int,
  125. task_id: str | None = None,
  126. attempt_id: str | None = None,
  127. ) -> ArtifactVersion:
  128. assert script_build_id == 7 and ref.version is not None
  129. value = self.versions[int(ref.version)]
  130. assert value.task_id == task_id and value.attempt_id == attempt_id
  131. assert value.canonical_sha256 == ref.digest
  132. return value
  133. async def get_by_id(self, identifier: int, *, script_build_id: int) -> ArtifactVersion:
  134. assert script_build_id == 7
  135. return self.versions[identifier]
  136. class _Bindings:
  137. async def get_by_build(self, script_build_id: int) -> MissionBinding:
  138. assert script_build_id == 7
  139. now = datetime.now(UTC)
  140. return MissionBinding(1, 7, "root", 11, None, None, "test", "v1", now, now)
  141. def _version(
  142. identifier: int,
  143. task_id: str,
  144. attempt_id: str,
  145. kind: ArtifactKind,
  146. digest: str,
  147. artifact: object,
  148. ) -> ArtifactVersion:
  149. now = datetime.now(UTC)
  150. return ArtifactVersion(
  151. identifier,
  152. 7,
  153. task_id,
  154. attempt_id,
  155. 1,
  156. kind,
  157. digest,
  158. ArtifactState.FROZEN,
  159. artifact, # type: ignore[arg-type]
  160. now,
  161. now,
  162. )
  163. async def _fixture() -> tuple[ScriptPhaseTwoBoundaryVerifier, TaskLedger]:
  164. script_ref = ArtifactRef(
  165. "script-build://artifact-versions/1",
  166. ArtifactKind.STRUCTURED_SCRIPT.value,
  167. "1",
  168. SCRIPT_DIGEST,
  169. )
  170. root = _task("root-task", None, "root", TaskStatus.BLOCKED)
  171. portfolio_task = _task("portfolio", "root-task", "candidate-portfolio", TaskStatus.RUNNING)
  172. compose_task = _task("compose", "portfolio", "compose", TaskStatus.COMPLETED)
  173. root.child_task_ids.append("portfolio")
  174. portfolio_task.child_task_ids.append("compose")
  175. compose_attempt = TaskAttempt(
  176. "attempt-compose",
  177. "compose",
  178. 1,
  179. "worker-compose",
  180. "script_compose_worker",
  181. "new",
  182. (),
  183. AttemptStatus.SUBMITTED,
  184. snapshot_id="snapshot-compose",
  185. submission=AttemptSubmission("closed", artifact_refs=[script_ref]),
  186. )
  187. compose_validation = ValidationReport(
  188. "validation-compose",
  189. "compose",
  190. "attempt-compose",
  191. 1,
  192. "snapshot-compose",
  193. "validator-compose",
  194. status=ValidationRunStatus.COMPLETED,
  195. verdict=ValidationVerdict.PASSED,
  196. )
  197. compose_decision = PlannerDecision(
  198. "decision-compose",
  199. "compose",
  200. DecisionAction.ACCEPT,
  201. "passed",
  202. TaskStatus.AWAITING_DECISION,
  203. TaskStatus.COMPLETED,
  204. "attempt-compose",
  205. "validation-compose",
  206. )
  207. compose_task.attempt_ids.append("attempt-compose")
  208. compose_task.validation_ids.append("validation-compose")
  209. compose_task.decision_ids.append("decision-compose")
  210. portfolio_attempt = TaskAttempt(
  211. "attempt-portfolio",
  212. "portfolio",
  213. 1,
  214. "worker-portfolio",
  215. "script_candidate_portfolio_worker",
  216. "new",
  217. ("decision-compose",),
  218. )
  219. ledger = TaskLedger(
  220. "root",
  221. "mission",
  222. "root-task",
  223. tasks={item.task_id: item for item in (root, portfolio_task, compose_task)},
  224. attempts={
  225. compose_attempt.attempt_id: compose_attempt,
  226. portfolio_attempt.attempt_id: portfolio_attempt,
  227. },
  228. validations={compose_validation.validation_id: compose_validation},
  229. decisions={compose_decision.decision_id: compose_decision},
  230. )
  231. compose_normalized = _normalized("closed", script_ref)
  232. snapshots = {
  233. "snapshot-compose": ArtifactSnapshot(
  234. "snapshot-compose",
  235. "attempt-compose",
  236. compose_normalized,
  237. _snapshot_sha(compose_normalized),
  238. [script_ref],
  239. [],
  240. )
  241. }
  242. paragraph = ScriptParagraphV1(1, 1, 1, None, "opening", {}, description="done")
  243. structured = StructuredScriptArtifactV1(
  244. "script-build://artifact-versions/9",
  245. INPUT_DIGEST,
  246. (paragraph,),
  247. (),
  248. (),
  249. ("script-build://artifact-versions/9",),
  250. (),
  251. (),
  252. SCRIPT_DIGEST,
  253. )
  254. artifacts = _Artifacts(
  255. {
  256. 1: _version(
  257. 1,
  258. "compose",
  259. "attempt-compose",
  260. ArtifactKind.STRUCTURED_SCRIPT,
  261. SCRIPT_DIGEST,
  262. structured,
  263. )
  264. }
  265. )
  266. contracts = _Contracts(
  267. {"compose": _compose_contract(), "portfolio": _portfolio_contract(script_ref)}
  268. )
  269. accepted = AcceptedInputResolver(
  270. task_store=_Store(ledger),
  271. artifact_store=_Store(snapshots),
  272. artifacts=artifacts,
  273. contracts=contracts,
  274. )
  275. bundle = await accepted.resolve(
  276. root_trace_id="root",
  277. script_build_id=7,
  278. task_id="portfolio",
  279. attempt_id="attempt-portfolio",
  280. contract=contracts.values["portfolio"],
  281. input_snapshot_id=SNAPSHOT_ID,
  282. )
  283. portfolio = CandidatePortfolioArtifactV1(
  284. script_ref.uri,
  285. (script_ref.uri,),
  286. ("decision-compose",),
  287. (),
  288. (),
  289. bundle.input_closure_digest,
  290. (),
  291. ("decision-compose",),
  292. PORTFOLIO_DIGEST,
  293. )
  294. portfolio_ref = ArtifactRef(
  295. "script-build://artifact-versions/2",
  296. ArtifactKind.CANDIDATE_PORTFOLIO.value,
  297. "2",
  298. PORTFOLIO_DIGEST,
  299. )
  300. portfolio_attempt.status = AttemptStatus.SUBMITTED
  301. portfolio_attempt.snapshot_id = "snapshot-portfolio"
  302. portfolio_attempt.submission = AttemptSubmission("closed", artifact_refs=[portfolio_ref])
  303. portfolio_validation = ValidationReport(
  304. "validation-portfolio",
  305. "portfolio",
  306. "attempt-portfolio",
  307. 1,
  308. "snapshot-portfolio",
  309. "validator-portfolio",
  310. status=ValidationRunStatus.COMPLETED,
  311. verdict=ValidationVerdict.PASSED,
  312. )
  313. portfolio_decision = PlannerDecision(
  314. "decision-portfolio",
  315. "portfolio",
  316. DecisionAction.ACCEPT,
  317. "passed",
  318. TaskStatus.AWAITING_DECISION,
  319. TaskStatus.COMPLETED,
  320. "attempt-portfolio",
  321. "validation-portfolio",
  322. )
  323. portfolio_task.status = TaskStatus.COMPLETED
  324. portfolio_task.attempt_ids.append("attempt-portfolio")
  325. portfolio_task.validation_ids.append("validation-portfolio")
  326. portfolio_task.decision_ids.append("decision-portfolio")
  327. ledger.validations[portfolio_validation.validation_id] = portfolio_validation
  328. ledger.decisions[portfolio_decision.decision_id] = portfolio_decision
  329. portfolio_normalized = _normalized("closed", portfolio_ref)
  330. snapshots["snapshot-portfolio"] = ArtifactSnapshot(
  331. "snapshot-portfolio",
  332. "attempt-portfolio",
  333. portfolio_normalized,
  334. _snapshot_sha(portfolio_normalized),
  335. [portfolio_ref],
  336. [],
  337. )
  338. artifacts.versions[2] = _version(
  339. 2,
  340. "portfolio",
  341. "attempt-portfolio",
  342. ArtifactKind.CANDIDATE_PORTFOLIO,
  343. PORTFOLIO_DIGEST,
  344. portfolio,
  345. )
  346. verifier = ScriptPhaseTwoBoundaryVerifier(
  347. task_store=_Store(ledger),
  348. artifact_store=_Store(snapshots),
  349. artifacts=artifacts,
  350. bindings=_Bindings(),
  351. accepted_inputs=accepted,
  352. )
  353. return verifier, ledger
  354. @pytest.mark.asyncio
  355. async def test_phase_two_boundary_requires_one_closed_adopted_portfolio() -> None:
  356. verifier, _ = await _fixture()
  357. await verifier.verify_checkpoint(
  358. script_build_id=7, root_trace_id="root", input_snapshot_id=SNAPSHOT_ID
  359. )
  360. @pytest.mark.asyncio
  361. async def test_phase_two_boundary_rejects_nonterminal_subtree_and_early_advance() -> None:
  362. verifier, ledger = await _fixture()
  363. ledger.tasks["compose"].status = TaskStatus.RUNNING
  364. with pytest.raises(PhaseTwoBoundaryNotReady, match="subtree is not terminal"):
  365. await verifier.verify_checkpoint(
  366. script_build_id=7, root_trace_id="root", input_snapshot_id=SNAPSHOT_ID
  367. )
  368. with pytest.raises(PhaseTwoBoundaryNotReady, match="portfolio already exists"):
  369. await verifier.verify_before_advance(
  370. script_build_id=7, root_trace_id="root", input_snapshot_id=SNAPSHOT_ID
  371. )
  372. @pytest.mark.asyncio
  373. async def test_phase_two_boundary_rejects_closure_digest_drift() -> None:
  374. verifier, _ = await _fixture()
  375. version = verifier._artifacts.versions[2]
  376. artifact = replace(version.artifact, input_closure_digest="sha256:" + "f" * 64)
  377. verifier._artifacts.versions[2] = replace(version, artifact=artifact)
  378. with pytest.raises(PhaseTwoBoundaryNotReady, match="closure digest"):
  379. await verifier.verify_checkpoint(
  380. script_build_id=7, root_trace_id="root", input_snapshot_id=SNAPSHOT_ID
  381. )
  382. def _normalized(summary: str, ref: ArtifactRef) -> dict[str, object]:
  383. return {
  384. "summary": summary,
  385. "artifact_refs": [
  386. {
  387. "uri": ref.uri,
  388. "kind": ref.kind,
  389. "version": ref.version,
  390. "digest": ref.digest,
  391. "summary": ref.summary,
  392. "metadata": ref.metadata,
  393. }
  394. ],
  395. "evidence_refs": [],
  396. }
  397. def _snapshot_sha(value: dict[str, object]) -> str:
  398. canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
  399. return hashlib.sha256(canonical.encode()).hexdigest()