test_phase_two_boundary.py 14 KB

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