test_phase_two_sql_flow.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063
  1. from __future__ import annotations
  2. import asyncio
  3. import re
  4. from collections.abc import Mapping, Sequence
  5. from datetime import UTC, datetime
  6. from pathlib import Path
  7. from typing import Any, cast
  8. import pytest
  9. from agent import FileSystemArtifactStore, FileSystemTaskStore, FileSystemTraceStore
  10. from agent.orchestration import (
  11. AgentRole,
  12. ArtifactRef,
  13. AttemptSubmission,
  14. CriterionResult,
  15. DecisionAction,
  16. TaskCoordinator,
  17. TaskStatus,
  18. ValidationVerdict,
  19. )
  20. from agent.orchestration.protocols import ValidatorRunResult, WorkerRunResult
  21. from sqlalchemy import event, func, select
  22. from script_build_host.application.phase_two_boundary import ScriptPhaseTwoBoundaryVerifier
  23. from script_build_host.application.phase_two_candidates import PhaseTwoCandidateService
  24. from script_build_host.application.phase_two_inputs import (
  25. AcceptedInputResolver,
  26. ActiveFrontierResolver,
  27. StoredTaskContractReader,
  28. )
  29. from script_build_host.application.phase_two_planning import (
  30. PHASE_TWO_CANDIDATE_PORTFOLIO_READY,
  31. PhaseTwoPlanningService,
  32. )
  33. from script_build_host.domain.artifacts import (
  34. ArtifactKind,
  35. DirectionGoal,
  36. EvidenceRecordV1,
  37. ScriptDirectionArtifactV1,
  38. )
  39. from script_build_host.domain.errors import PhaseTwoBoundaryNotReady
  40. from script_build_host.domain.phase_two_artifacts import (
  41. CandidatePortfolioArtifactV1,
  42. StructuredScriptArtifactV1,
  43. )
  44. from script_build_host.domain.records import BuildStatus, MissionBinding
  45. from script_build_host.domain.task_contracts import ScriptTaskKind
  46. from script_build_host.infrastructure.legacy_tables import (
  47. script_build_element,
  48. script_build_paragraph,
  49. script_build_paragraph_element,
  50. script_build_record,
  51. )
  52. from script_build_host.infrastructure.tables import (
  53. artifact_version_table,
  54. publication_table,
  55. )
  56. from script_build_host.infrastructure.task_contract_store import FileScriptTaskContractStore
  57. from script_build_host.repositories.legacy_state import SqlAlchemyLegacyBuildStateRepository
  58. from script_build_host.repositories.sqlalchemy import (
  59. SqlAlchemyMissionBindingRepository,
  60. SqlAlchemyScriptBusinessArtifactRepository,
  61. )
  62. from script_build_host.repositories.workspace import SqlAlchemyCandidateWorkspaceRepository
  63. ROOT = "phase-two-sql-root"
  64. SNAPSHOT_ID = 11
  65. FULL_SCOPE = "script-build://scopes/full"
  66. FULL_WRITE = "script-build://writes/full"
  67. def _contract(
  68. kind: ScriptTaskKind,
  69. *,
  70. scope: str = FULL_SCOPE,
  71. write_scope: Sequence[str] = (FULL_WRITE,),
  72. input_refs: Sequence[dict[str, Any]] = (),
  73. base_ref: ArtifactRef | None = None,
  74. supersedes: Sequence[str] = (),
  75. closure: Sequence[dict[str, Any]] = (),
  76. adopted: Sequence[str] = (),
  77. held: Sequence[str] = (),
  78. order: Sequence[str] = (),
  79. comparison_refs: Sequence[dict[str, Any]] = (),
  80. execution_ready: bool = True,
  81. ) -> dict[str, Any]:
  82. output_schema = {
  83. ScriptTaskKind.DIRECTION: "script-direction/v1",
  84. ScriptTaskKind.DECODE_RETRIEVAL: "evidence-record/v1",
  85. ScriptTaskKind.STRUCTURE: "structure-artifact/v1",
  86. ScriptTaskKind.PARAGRAPH: "paragraph-patch/v1" if base_ref else "paragraph-artifact/v1",
  87. ScriptTaskKind.ELEMENT_SET: (
  88. "element-set-patch/v1" if base_ref else "element-set-artifact/v1"
  89. ),
  90. ScriptTaskKind.COMPARE: "comparison-artifact/v1",
  91. ScriptTaskKind.COMPOSE: "structured-script/v1",
  92. ScriptTaskKind.CANDIDATE_PORTFOLIO: "candidate-portfolio/v1",
  93. }[kind]
  94. if kind in {ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO}:
  95. intent = "compose" if kind is ScriptTaskKind.COMPOSE else "portfolio"
  96. elif supersedes:
  97. intent = "replace"
  98. else:
  99. intent = "explore"
  100. payload: dict[str, Any] = {
  101. "schema_version": "script-task-contract/v1",
  102. "task_kind": kind.value,
  103. "scope_ref": scope,
  104. "intent_class": intent,
  105. "objective": f"produce independently verifiable {kind.value} content",
  106. "input_decision_refs": list(input_refs),
  107. "base_artifact_ref": _ref_payload(base_ref) if base_ref else None,
  108. "write_scope": list(write_scope),
  109. "gap_ref": None,
  110. "output_schema": output_schema,
  111. "criteria": [
  112. {
  113. "criterion_id": "closed",
  114. "description": "the frozen increment is concrete and independently verifiable",
  115. "hard": True,
  116. }
  117. ],
  118. "budget": {
  119. "max_attempts": 4,
  120. "max_tokens": 32_000,
  121. "max_seconds": 900,
  122. "max_external_queries": 40,
  123. "max_no_improvement": 3,
  124. },
  125. "supersedes_decision_ids": list(supersedes),
  126. "candidate_closure_decision_refs": list(closure),
  127. "adopted_decision_ids": list(adopted),
  128. "held_or_rejected_decision_ids": list(held),
  129. "compose_order": list(order),
  130. "comparison_decision_refs": list(comparison_refs),
  131. }
  132. if not execution_ready and kind in {
  133. ScriptTaskKind.COMPOSE,
  134. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  135. }:
  136. payload.update(
  137. candidate_closure_decision_refs=[],
  138. adopted_decision_ids=[],
  139. held_or_rejected_decision_ids=[],
  140. compose_order=[],
  141. )
  142. return payload
  143. def _decision_ref(
  144. decision_id: str,
  145. ref: ArtifactRef,
  146. *,
  147. scope: str,
  148. kind: ScriptTaskKind,
  149. ) -> dict[str, Any]:
  150. return {
  151. "decision_id": decision_id,
  152. "artifact_ref": _ref_payload(ref),
  153. "scope_ref": scope,
  154. "expected_task_kind": kind.value,
  155. }
  156. def _ref_payload(ref: ArtifactRef) -> dict[str, Any]:
  157. return {
  158. "uri": ref.uri,
  159. "kind": ref.kind,
  160. "version": ref.version,
  161. "digest": ref.digest,
  162. "summary": ref.summary,
  163. "metadata": ref.metadata,
  164. }
  165. def _task_kind(context: Mapping[str, Any]) -> ScriptTaskKind:
  166. refs = cast(dict[str, Any], context["task_spec"])["context_refs"]
  167. values = [value.rsplit("/", 1)[-1] for value in refs if "/task-kinds/" in value]
  168. assert len(values) == 1
  169. return ScriptTaskKind(values[0])
  170. class _ScriptedExecutor:
  171. """Drive real Coordinator stages through the same protected Host services."""
  172. def __init__(
  173. self,
  174. *,
  175. coordinator: TaskCoordinator,
  176. candidates: PhaseTwoCandidateService,
  177. artifacts: SqlAlchemyScriptBusinessArtifactRepository,
  178. script_build_id: int,
  179. ) -> None:
  180. self.coordinator = coordinator
  181. self.candidates = candidates
  182. self.artifacts = artifacts
  183. self.script_build_id = script_build_id
  184. async def run_worker(self, context: dict[str, Any]) -> WorkerRunResult:
  185. kind = _task_kind(context)
  186. candidate_context = {
  187. "root_trace_id": context["root_trace_id"],
  188. "task_id": context["task_id"],
  189. "attempt_id": context["attempt_id"],
  190. "spec_version": context["spec_version"],
  191. }
  192. if kind is ScriptTaskKind.DECODE_RETRIEVAL:
  193. await self.artifacts.freeze(
  194. script_build_id=self.script_build_id,
  195. task_id=context["task_id"],
  196. attempt_id=context["attempt_id"],
  197. spec_version=context["spec_version"],
  198. artifact=EvidenceRecordV1(
  199. evidence_id=f"evidence-{context['attempt_id']}",
  200. source_type="decode",
  201. tool_name="retrieve_decode",
  202. query={"query": "opening tension", "top_k": 3},
  203. source_refs=("decode-index://fixture/1",),
  204. raw_artifact_ref=None,
  205. summary="A concrete opening needs a visible tension and an observable detail.",
  206. supports=("direction",),
  207. confidence="high",
  208. limitations=(),
  209. content_sha256="",
  210. created_at=datetime.now(UTC),
  211. ),
  212. )
  213. elif kind is ScriptTaskKind.DIRECTION:
  214. child_results = cast(list[dict[str, Any]], context["accepted_child_results"])
  215. assert len(child_results) == 1
  216. evidence_refs = child_results[0]["submission"]["evidence_refs"]
  217. await self.artifacts.freeze(
  218. script_build_id=self.script_build_id,
  219. task_id=context["task_id"],
  220. attempt_id=context["attempt_id"],
  221. spec_version=context["spec_version"],
  222. artifact=ScriptDirectionArtifactV1(
  223. goals=(DirectionGoal("goal-1", "Use a concrete reversal to reveal the topic"),),
  224. evidence_refs=(str(evidence_refs[0]["uri"]),),
  225. legacy_markdown=(
  226. "# Direction\n\nReveal the topic through one concrete reversal."
  227. ),
  228. ),
  229. )
  230. elif kind in {ScriptTaskKind.STRUCTURE, ScriptTaskKind.PARAGRAPH}:
  231. workspace = await self.candidates.read_attempt_workspace(context=candidate_context)
  232. if workspace["paragraphs"]:
  233. paragraph_id = workspace["paragraphs"][0]["paragraph_id"]
  234. else:
  235. created = await self.candidates.create_script_paragraph(
  236. payload={
  237. "paragraph_index": 1,
  238. "name": f"{kind.value} opening",
  239. "content_range": {"scope": "opening"},
  240. },
  241. context=candidate_context,
  242. )
  243. paragraph_id = created["paragraph_id"]
  244. await self.candidates.batch_update_script_paragraphs(
  245. updates=(
  246. {
  247. "paragraph_id": paragraph_id,
  248. "theme": "a specific reversal",
  249. "form": "contrast",
  250. "function": "hook",
  251. "feeling": "curiosity",
  252. "description": "The observable detail changes the initial interpretation.",
  253. "full_description": (
  254. "The opening states a familiar assumption, then overturns it with "
  255. "one visible and source-grounded detail."
  256. ),
  257. },
  258. ),
  259. context=candidate_context,
  260. )
  261. elif kind is ScriptTaskKind.ELEMENT_SET:
  262. created = await self.candidates.create_script_element(
  263. payload={
  264. "name": "observable reversal detail",
  265. "dimension_primary": "实质",
  266. "dimension_secondary": "narrative evidence",
  267. "topic_support": {"scope": "opening"},
  268. "weight_score": {"score": 0.9},
  269. },
  270. context=candidate_context,
  271. )
  272. workspace = await self.candidates.read_attempt_workspace(context=candidate_context)
  273. if workspace["paragraphs"]:
  274. await self.candidates.batch_link_paragraph_elements(
  275. links=(
  276. {
  277. "paragraph_id": workspace["paragraphs"][0]["paragraph_id"],
  278. "element_ids": [created["element_id"]],
  279. },
  280. ),
  281. context=candidate_context,
  282. )
  283. elif kind is ScriptTaskKind.COMPARE:
  284. pinned = await self.candidates.read_pinned_candidates(context=candidate_context)
  285. candidate_refs = [str(item["artifact_ref"]["uri"]) for item in pinned]
  286. await self.candidates.save_comparison_candidate(
  287. payload={
  288. "criterion_results": [
  289. {
  290. "criterion_id": "closed",
  291. "candidate_results": [
  292. {"artifact_ref": ref, "reason": "immutable candidate reviewed"}
  293. for ref in candidate_refs
  294. ],
  295. }
  296. ],
  297. "conflicts": ["the replacement has the more concrete opening"],
  298. "recommendation": candidate_refs[-1],
  299. },
  300. context=candidate_context,
  301. )
  302. elif kind is ScriptTaskKind.COMPOSE:
  303. await self.candidates.save_structured_script_candidate(
  304. acceptance_notes=("all adopted increments are pinned by ACCEPT decisions",),
  305. context=candidate_context,
  306. )
  307. elif kind is ScriptTaskKind.CANDIDATE_PORTFOLIO:
  308. await self.candidates.save_candidate_portfolio(
  309. payload={"unresolved_defects": []}, context=candidate_context
  310. )
  311. else: # pragma: no cover - this executor is intentionally phase-two bounded
  312. raise AssertionError(f"unexpected worker kind: {kind.value}")
  313. manifest = await self.candidates.resolve_attempt_manifest(context=candidate_context)
  314. safe_ref = ArtifactRef(
  315. manifest.artifact_ref.uri,
  316. manifest.artifact_ref.kind,
  317. manifest.artifact_ref.version,
  318. manifest.artifact_ref.digest,
  319. )
  320. await self.coordinator.submit_attempt(
  321. {
  322. "role": AgentRole.WORKER.value,
  323. "root_trace_id": context["root_trace_id"],
  324. "task_id": context["task_id"],
  325. "attempt_id": context["attempt_id"],
  326. "spec_version": context["spec_version"],
  327. "trace_id": context["worker_trace_id"],
  328. "tool_call_id": f"submit:{context['attempt_id']}",
  329. "operation_id": context["operation_id"],
  330. "execution_epoch": context["execution_epoch"],
  331. },
  332. AttemptSubmission(
  333. summary=f"kind={safe_ref.kind};status=frozen",
  334. artifact_refs=[] if safe_ref.kind == ArtifactKind.EVIDENCE.value else [safe_ref],
  335. evidence_refs=[safe_ref] if safe_ref.kind == ArtifactKind.EVIDENCE.value else [],
  336. ),
  337. )
  338. return WorkerRunResult(context["worker_trace_id"], "completed")
  339. async def run_validator(self, context: dict[str, Any]) -> ValidatorRunResult:
  340. criteria = cast(dict[str, Any], context["task_spec"])["acceptance_criteria"]
  341. results = [
  342. CriterionResult(
  343. criterion_id=item["criterion_id"],
  344. verdict=ValidationVerdict.PASSED,
  345. reason="the immutable SQL artifact is concrete and owner/digest closed",
  346. )
  347. for item in criteria
  348. ]
  349. await self.coordinator.submit_validation(
  350. {
  351. "role": AgentRole.VALIDATOR.value,
  352. "root_trace_id": context["root_trace_id"],
  353. "task_id": context["task_id"],
  354. "attempt_id": context["attempt_id"],
  355. "validation_id": context["validation_id"],
  356. "snapshot_id": context["snapshot_id"],
  357. "trace_id": context["validator_trace_id"],
  358. "tool_call_id": f"validate:{context['validation_id']}",
  359. "operation_id": context["operation_id"],
  360. "execution_epoch": context["execution_epoch"],
  361. },
  362. ValidationVerdict.PASSED,
  363. results,
  364. "all hard criteria passed against the immutable snapshot",
  365. (),
  366. (),
  367. (),
  368. "accept",
  369. )
  370. return ValidatorRunResult(context["validator_trace_id"], "completed")
  371. async def stop(self, trace_id: str) -> bool:
  372. del trace_id
  373. return True
  374. async def _plan_one(
  375. planning: PhaseTwoPlanningService,
  376. payload: Mapping[str, Any],
  377. *,
  378. parent_task_id: str | None,
  379. call_id: str,
  380. ) -> str:
  381. result = await planning.plan_script_tasks(
  382. contract_payloads=(payload,),
  383. parent_task_id=parent_task_id,
  384. context={"root_trace_id": ROOT, "tool_call_id": call_id},
  385. )
  386. return cast(str, result["task_ids"][0])
  387. async def _dispatch_accept(
  388. planning: PhaseTwoPlanningService, coordinator: TaskCoordinator, task_id: str
  389. ) -> tuple[str, ArtifactRef]:
  390. cycles = await planning.dispatch_script_tasks(
  391. task_ids=(task_id,),
  392. context={"root_trace_id": ROOT, "tool_call_id": f"dispatch:{task_id}"},
  393. )
  394. assert cycles[0]["error"] is None
  395. validation_id = cast(str, cycles[0]["validation_id"])
  396. accepted = await planning.decide_script_task(
  397. task_id=task_id,
  398. action=DecisionAction.ACCEPT.value,
  399. reason="independent validation passed",
  400. validation_id=validation_id,
  401. replacement_contract=None,
  402. child_contracts=(),
  403. context={"root_trace_id": ROOT, "tool_call_id": f"accept:{task_id}"},
  404. )
  405. decision_id = cast(str, accepted["decision_id"])
  406. ledger = await coordinator.task_store.load(ROOT)
  407. decision = ledger.decisions[decision_id]
  408. attempt = ledger.attempts[cast(str, decision.attempt_id)]
  409. assert attempt.submission is not None
  410. refs = [*attempt.submission.artifact_refs, *attempt.submission.evidence_refs]
  411. assert len(refs) == 1
  412. return decision_id, refs[0]
  413. @pytest.mark.asyncio
  414. async def test_sql_phase_two_dynamic_replacement_compose_portfolio_and_boundary(
  415. database: Any, tmp_path: Path
  416. ) -> None:
  417. engine, sessions = database
  418. mutation_statements: list[str] = []
  419. def observe_sql(
  420. _connection: Any,
  421. _cursor: Any,
  422. statement: str,
  423. _parameters: Any,
  424. _context: Any,
  425. _executemany: bool,
  426. ) -> None:
  427. if statement.lstrip().upper().startswith(("INSERT ", "UPDATE ", "DELETE ")):
  428. mutation_statements.append(statement)
  429. event.listen(engine.sync_engine, "before_cursor_execute", observe_sql)
  430. build_states = SqlAlchemyLegacyBuildStateRepository(sessions)
  431. script_build_id = await build_states.create(
  432. execution_id=101,
  433. topic_build_id=202,
  434. topic_id=303,
  435. agent_type="script-planner",
  436. agent_config={},
  437. data_source_url=None,
  438. strategies_config={},
  439. )
  440. bindings = SqlAlchemyMissionBindingRepository(sessions)
  441. await bindings.create(
  442. script_build_id=script_build_id,
  443. root_trace_id=ROOT,
  444. input_snapshot_id=SNAPSHOT_ID,
  445. engine_version="phase-two-test",
  446. schema_version="v1",
  447. )
  448. task_path = tmp_path / "task-ledger"
  449. artifact_path = tmp_path / "framework-artifacts"
  450. contract_path = tmp_path / "contract-data"
  451. task_store = FileSystemTaskStore(str(task_path))
  452. framework_artifacts = FileSystemArtifactStore(str(artifact_path))
  453. coordinator = TaskCoordinator(
  454. task_store,
  455. framework_artifacts,
  456. FileSystemTraceStore(str(tmp_path / "traces")),
  457. )
  458. await coordinator.ensure_ledger(
  459. ROOT,
  460. {
  461. "objective": "produce a governed phase-two candidate portfolio",
  462. "acceptance_criteria": [
  463. {"criterion_id": "portfolio", "description": "portfolio closed", "hard": True}
  464. ],
  465. "context_refs": [f"script-build://inputs/{SNAPSHOT_ID}"],
  466. },
  467. )
  468. contract_store = FileScriptTaskContractStore(contract_path)
  469. contract_reader = StoredTaskContractReader(contract_store)
  470. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  471. accepted_inputs = AcceptedInputResolver(
  472. task_store=task_store,
  473. artifact_store=framework_artifacts,
  474. artifacts=artifacts,
  475. contracts=contract_reader,
  476. )
  477. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  478. candidates = PhaseTwoCandidateService(
  479. bindings=bindings,
  480. task_store=task_store,
  481. framework_artifact_store=framework_artifacts,
  482. artifacts=artifacts,
  483. accepted_inputs=accepted_inputs,
  484. active_frontier=ActiveFrontierResolver(),
  485. workspaces=workspaces,
  486. )
  487. boundary = ScriptPhaseTwoBoundaryVerifier(
  488. task_store=task_store,
  489. artifact_store=framework_artifacts,
  490. artifacts=artifacts,
  491. bindings=bindings,
  492. accepted_inputs=accepted_inputs,
  493. )
  494. planning = PhaseTwoPlanningService(
  495. coordinator=coordinator,
  496. bindings=bindings,
  497. contracts=contract_store,
  498. closure_gate=boundary,
  499. )
  500. coordinator.set_executor(
  501. _ScriptedExecutor(
  502. coordinator=coordinator,
  503. candidates=candidates,
  504. artifacts=artifacts,
  505. script_build_id=script_build_id,
  506. )
  507. )
  508. direction = await _plan_one(
  509. planning, _contract(ScriptTaskKind.DIRECTION), parent_task_id=None, call_id="direction"
  510. )
  511. retrieval = await _plan_one(
  512. planning,
  513. _contract(ScriptTaskKind.DECODE_RETRIEVAL, write_scope=()),
  514. parent_task_id=direction,
  515. call_id="retrieval",
  516. )
  517. await _dispatch_accept(planning, coordinator, retrieval)
  518. direction_decision, direction_ref = await _dispatch_accept(planning, coordinator, direction)
  519. portfolio = await _plan_one(
  520. planning,
  521. _contract(
  522. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  523. write_scope=("script-build://writes",),
  524. execution_ready=False,
  525. ),
  526. parent_task_id=None,
  527. call_id="portfolio",
  528. )
  529. compose = await _plan_one(
  530. planning,
  531. _contract(
  532. ScriptTaskKind.COMPOSE,
  533. write_scope=("script-build://writes",),
  534. execution_ready=False,
  535. ),
  536. parent_task_id=portfolio,
  537. call_id="compose",
  538. )
  539. # Exploration order is intentionally Element -> Paragraph -> Structure.
  540. old_element = await _plan_one(
  541. planning,
  542. _contract(
  543. ScriptTaskKind.ELEMENT_SET,
  544. scope="script-build://scopes/full/elements",
  545. write_scope=("script-build://writes/elements",),
  546. ),
  547. parent_task_id=compose,
  548. call_id="element-first",
  549. )
  550. old_element_decision, old_element_ref = await _dispatch_accept(
  551. planning, coordinator, old_element
  552. )
  553. old_paragraph = await _plan_one(
  554. planning,
  555. _contract(
  556. ScriptTaskKind.PARAGRAPH,
  557. scope="script-build://scopes/full/opening",
  558. write_scope=("script-build://writes/paragraphs/opening",),
  559. ),
  560. parent_task_id=compose,
  561. call_id="paragraph-second",
  562. )
  563. old_paragraph_decision, old_paragraph_ref = await _dispatch_accept(
  564. planning, coordinator, old_paragraph
  565. )
  566. structure = await _plan_one(
  567. planning,
  568. _contract(
  569. ScriptTaskKind.STRUCTURE,
  570. scope="script-build://scopes/full/opening",
  571. write_scope=("script-build://writes/paragraphs/opening",),
  572. input_refs=(
  573. _decision_ref(
  574. old_paragraph_decision,
  575. old_paragraph_ref,
  576. scope="script-build://scopes/full/opening",
  577. kind=ScriptTaskKind.PARAGRAPH,
  578. ),
  579. ),
  580. base_ref=old_paragraph_ref,
  581. ),
  582. parent_task_id=compose,
  583. call_id="structure-third",
  584. )
  585. structure_decision, structure_ref = await _dispatch_accept(planning, coordinator, structure)
  586. paragraph_replacement = await _plan_one(
  587. planning,
  588. _contract(
  589. ScriptTaskKind.PARAGRAPH,
  590. scope="script-build://scopes/full/opening",
  591. write_scope=("script-build://writes/paragraphs/opening",),
  592. input_refs=(
  593. _decision_ref(
  594. structure_decision,
  595. structure_ref,
  596. scope="script-build://scopes/full/opening",
  597. kind=ScriptTaskKind.STRUCTURE,
  598. ),
  599. _decision_ref(
  600. old_paragraph_decision,
  601. old_paragraph_ref,
  602. scope="script-build://scopes/full/opening",
  603. kind=ScriptTaskKind.PARAGRAPH,
  604. ),
  605. ),
  606. base_ref=structure_ref,
  607. supersedes=(old_paragraph_decision,),
  608. ),
  609. parent_task_id=compose,
  610. call_id="replace-paragraph",
  611. )
  612. paragraph_decision, paragraph_ref = await _dispatch_accept(
  613. planning, coordinator, paragraph_replacement
  614. )
  615. element_replacement = await _plan_one(
  616. planning,
  617. _contract(
  618. ScriptTaskKind.ELEMENT_SET,
  619. scope="script-build://scopes/full/elements",
  620. write_scope=("script-build://writes/elements",),
  621. input_refs=(
  622. _decision_ref(
  623. old_element_decision,
  624. old_element_ref,
  625. scope="script-build://scopes/full/elements",
  626. kind=ScriptTaskKind.ELEMENT_SET,
  627. ),
  628. _decision_ref(
  629. paragraph_decision,
  630. paragraph_ref,
  631. scope="script-build://scopes/full/opening",
  632. kind=ScriptTaskKind.PARAGRAPH,
  633. ),
  634. ),
  635. base_ref=paragraph_ref,
  636. supersedes=(old_element_decision,),
  637. ),
  638. parent_task_id=compose,
  639. call_id="replace-element",
  640. )
  641. element_decision, element_ref = await _dispatch_accept(
  642. planning, coordinator, element_replacement
  643. )
  644. comparison = await _plan_one(
  645. planning,
  646. _contract(
  647. ScriptTaskKind.COMPARE,
  648. scope="script-build://scopes/full/opening",
  649. write_scope=(),
  650. comparison_refs=(
  651. _decision_ref(
  652. old_paragraph_decision,
  653. old_paragraph_ref,
  654. scope="script-build://scopes/full/opening",
  655. kind=ScriptTaskKind.PARAGRAPH,
  656. ),
  657. _decision_ref(
  658. paragraph_decision,
  659. paragraph_ref,
  660. scope="script-build://scopes/full/opening",
  661. kind=ScriptTaskKind.PARAGRAPH,
  662. ),
  663. ),
  664. ),
  665. parent_task_id=compose,
  666. call_id="compare-opening-candidates",
  667. )
  668. comparison_decision, comparison_ref = await _dispatch_accept(planning, coordinator, comparison)
  669. with pytest.raises(PhaseTwoBoundaryNotReady, match="CandidatePortfolio"):
  670. await planning.decide_script_task(
  671. task_id=(await task_store.load(ROOT)).root_task_id,
  672. action=DecisionAction.BLOCK.value,
  673. reason=PHASE_TWO_CANDIDATE_PORTFOLIO_READY,
  674. validation_id=None,
  675. replacement_contract=None,
  676. child_contracts=(),
  677. context={"root_trace_id": ROOT, "tool_call_id": "early-boundary"},
  678. )
  679. local_refs = (
  680. _decision_ref(
  681. old_element_decision,
  682. old_element_ref,
  683. scope="script-build://scopes/full/elements",
  684. kind=ScriptTaskKind.ELEMENT_SET,
  685. ),
  686. _decision_ref(
  687. old_paragraph_decision,
  688. old_paragraph_ref,
  689. scope="script-build://scopes/full/opening",
  690. kind=ScriptTaskKind.PARAGRAPH,
  691. ),
  692. _decision_ref(
  693. structure_decision,
  694. structure_ref,
  695. scope="script-build://scopes/full/opening",
  696. kind=ScriptTaskKind.STRUCTURE,
  697. ),
  698. _decision_ref(
  699. paragraph_decision,
  700. paragraph_ref,
  701. scope="script-build://scopes/full/opening",
  702. kind=ScriptTaskKind.PARAGRAPH,
  703. ),
  704. _decision_ref(
  705. element_decision,
  706. element_ref,
  707. scope="script-build://scopes/full/elements",
  708. kind=ScriptTaskKind.ELEMENT_SET,
  709. ),
  710. )
  711. adopted = (structure_decision, paragraph_decision, element_decision)
  712. held = (old_element_decision, old_paragraph_decision)
  713. compose_contract = _contract(
  714. ScriptTaskKind.COMPOSE,
  715. write_scope=("script-build://writes",),
  716. input_refs=(
  717. _decision_ref(
  718. direction_decision,
  719. direction_ref,
  720. scope=FULL_SCOPE,
  721. kind=ScriptTaskKind.DIRECTION,
  722. ),
  723. ),
  724. closure=local_refs,
  725. adopted=adopted,
  726. held=held,
  727. order=adopted,
  728. comparison_refs=(
  729. _decision_ref(
  730. comparison_decision,
  731. comparison_ref,
  732. scope="script-build://scopes/full/opening",
  733. kind=ScriptTaskKind.COMPARE,
  734. ),
  735. ),
  736. )
  737. await planning.decide_script_task(
  738. task_id=compose,
  739. action=DecisionAction.REVISE.value,
  740. reason="freeze the selected active frontier and formal compose order",
  741. validation_id=None,
  742. replacement_contract=compose_contract,
  743. child_contracts=(),
  744. context={"root_trace_id": ROOT, "tool_call_id": "close-compose"},
  745. )
  746. compose_decision, compose_ref = await _dispatch_accept(planning, coordinator, compose)
  747. portfolio_contract = _contract(
  748. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  749. write_scope=("script-build://writes",),
  750. closure=(
  751. _decision_ref(
  752. compose_decision,
  753. compose_ref,
  754. scope=FULL_SCOPE,
  755. kind=ScriptTaskKind.COMPOSE,
  756. ),
  757. ),
  758. adopted=(compose_decision,),
  759. order=(compose_decision,),
  760. )
  761. await planning.decide_script_task(
  762. task_id=portfolio,
  763. action=DecisionAction.REVISE.value,
  764. reason="freeze the uniquely adopted StructuredScript",
  765. validation_id=None,
  766. replacement_contract=portfolio_contract,
  767. child_contracts=(),
  768. context={"root_trace_id": ROOT, "tool_call_id": "close-portfolio"},
  769. )
  770. portfolio_decision, _ = await _dispatch_accept(planning, coordinator, portfolio)
  771. ledger = await task_store.load(ROOT)
  772. compose_attempt = ledger.attempts[cast(str, ledger.decisions[compose_decision].attempt_id)]
  773. frontier = await candidates.read_active_frontier(
  774. context={
  775. "root_trace_id": ROOT,
  776. "task_id": compose,
  777. "attempt_id": compose_attempt.attempt_id,
  778. "spec_version": compose_attempt.spec_version,
  779. }
  780. )
  781. assert [item["decision_id"] for item in frontier["inputs"]] == list(adopted)
  782. assert old_element_decision not in {item["decision_id"] for item in frontier["inputs"]}
  783. assert old_paragraph_decision not in {item["decision_id"] for item in frontier["inputs"]}
  784. await planning.decide_script_task(
  785. task_id=ledger.root_task_id,
  786. action=DecisionAction.BLOCK.value,
  787. reason=PHASE_TWO_CANDIDATE_PORTFOLIO_READY,
  788. validation_id=None,
  789. replacement_contract=None,
  790. child_contracts=(),
  791. context={"root_trace_id": ROOT, "tool_call_id": "phase-two-boundary"},
  792. )
  793. await build_states.set_checkpoint(
  794. script_build_id,
  795. checkpoint_code=PHASE_TWO_CANDIDATE_PORTFOLIO_READY,
  796. summary="one validated CandidatePortfolio is ready for phase three",
  797. root_trace_id=ROOT,
  798. )
  799. reloaded = await FileSystemTaskStore(str(task_path)).load(ROOT)
  800. assert reloaded.tasks[reloaded.root_task_id].status is TaskStatus.BLOCKED
  801. assert reloaded.tasks[reloaded.root_task_id].blocked_reason == (
  802. PHASE_TWO_CANDIDATE_PORTFOLIO_READY
  803. )
  804. compose_children = [reloaded.tasks[item] for item in reloaded.tasks[compose].child_task_ids]
  805. stored_reader = StoredTaskContractReader(FileScriptTaskContractStore(contract_path))
  806. assert [
  807. (
  808. await stored_reader.read_for_task(
  809. root_trace_id=ROOT,
  810. task=item,
  811. spec_version=item.current_spec_version,
  812. )
  813. ).task_kind
  814. for item in compose_children[:3]
  815. ] == [
  816. ScriptTaskKind.ELEMENT_SET,
  817. ScriptTaskKind.PARAGRAPH,
  818. ScriptTaskKind.STRUCTURE,
  819. ]
  820. compose_version = await artifacts.get_by_attempt(
  821. script_build_id=script_build_id,
  822. task_id=compose,
  823. attempt_id=compose_attempt.attempt_id,
  824. )
  825. assert isinstance(compose_version.artifact, StructuredScriptArtifactV1)
  826. assert len(compose_version.artifact.paragraphs) == 1
  827. assert compose_version.artifact.elements
  828. assert compose_version.artifact.paragraph_element_links
  829. portfolio_attempt = reloaded.attempts[
  830. cast(str, reloaded.decisions[portfolio_decision].attempt_id)
  831. ]
  832. portfolio_version = await artifacts.get_by_attempt(
  833. script_build_id=script_build_id,
  834. task_id=portfolio,
  835. attempt_id=portfolio_attempt.attempt_id,
  836. )
  837. assert isinstance(portfolio_version.artifact, CandidatePortfolioArtifactV1)
  838. assert portfolio_version.artifact.accepted_decision_ids == (compose_decision,)
  839. assert portfolio_version.artifact.adopted_structured_script_ref == compose_ref.uri
  840. async with sessions() as session:
  841. build = (
  842. (
  843. await session.execute(
  844. select(script_build_record).where(script_build_record.c.id == script_build_id)
  845. )
  846. )
  847. .mappings()
  848. .one()
  849. )
  850. branch_values = [
  851. *(
  852. await session.scalars(
  853. select(script_build_paragraph.c.branch_id).where(
  854. script_build_paragraph.c.script_build_id == script_build_id
  855. )
  856. )
  857. ),
  858. *(
  859. await session.scalars(
  860. select(script_build_element.c.branch_id).where(
  861. script_build_element.c.script_build_id == script_build_id
  862. )
  863. )
  864. ),
  865. *(
  866. await session.scalars(
  867. select(script_build_paragraph_element.c.branch_id).where(
  868. script_build_paragraph_element.c.script_build_id == script_build_id
  869. )
  870. )
  871. ),
  872. ]
  873. final_publications = await session.scalar(
  874. select(func.count())
  875. .select_from(publication_table)
  876. .where(
  877. publication_table.c.script_build_id == script_build_id,
  878. publication_table.c.publication_type == "final",
  879. )
  880. )
  881. phase_two_rows = (
  882. (
  883. await session.execute(
  884. select(artifact_version_table).where(
  885. artifact_version_table.c.script_build_id == script_build_id,
  886. artifact_version_table.c.artifact_type.in_(
  887. [
  888. ArtifactKind.STRUCTURE.value,
  889. ArtifactKind.PARAGRAPH.value,
  890. ArtifactKind.ELEMENT_SET.value,
  891. ArtifactKind.COMPARISON.value,
  892. ArtifactKind.STRUCTURED_SCRIPT.value,
  893. ArtifactKind.CANDIDATE_PORTFOLIO.value,
  894. ]
  895. ),
  896. )
  897. )
  898. )
  899. .mappings()
  900. .all()
  901. )
  902. mutation_targets = {
  903. match.group(1).strip('`"').lower()
  904. for statement in mutation_statements
  905. if (
  906. match := re.match(
  907. r"\s*(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([`\"\w]+)",
  908. statement,
  909. re.IGNORECASE,
  910. )
  911. )
  912. }
  913. assert mutation_targets <= {
  914. "script_build_record",
  915. "script_build_mission_binding",
  916. "script_build_artifact_version",
  917. "script_build_publication",
  918. "script_build_paragraph",
  919. "script_build_element",
  920. "script_build_paragraph_element",
  921. "script_build_task_plan_step",
  922. }
  923. assert not mutation_targets & {
  924. "script_build_branch",
  925. "script_build_round",
  926. "script_build_multipath",
  927. }
  928. assert BuildStatus(str(build["status"])) is BuildStatus.PARTIAL
  929. assert build["error_message"] == PHASE_TWO_CANDIDATE_PORTFOLIO_READY
  930. assert build["reson_trace_id"] == ROOT and build["end_time"] is not None
  931. assert branch_values and min(branch_values) > 0 and 0 not in branch_values
  932. assert final_publications == 0
  933. assert (
  934. sum(
  935. row["artifact_type"] == ArtifactKind.CANDIDATE_PORTFOLIO.value for row in phase_two_rows
  936. )
  937. == 1
  938. )
  939. for row in phase_two_rows:
  940. if row["artifact_type"] in {
  941. ArtifactKind.STRUCTURE.value,
  942. ArtifactKind.PARAGRAPH.value,
  943. ArtifactKind.ELEMENT_SET.value,
  944. ArtifactKind.STRUCTURED_SCRIPT.value,
  945. }:
  946. assert row["legacy_branch_id"] == row["id"] > 0
  947. else:
  948. assert row["legacy_branch_id"] is None
  949. class _SingleBinding:
  950. def __init__(self, root: str) -> None:
  951. now = datetime.now(UTC)
  952. self.value = MissionBinding(1, 1, root, 11, None, None, "test", "v1", now, now)
  953. async def get_by_root(self, root_trace_id: str) -> MissionBinding:
  954. assert root_trace_id == self.value.root_trace_id
  955. return self.value
  956. class _BlockingExecutor:
  957. def __init__(self) -> None:
  958. self.started = asyncio.Event()
  959. self.release = asyncio.Event()
  960. async def run_worker(self, context: dict[str, Any]) -> WorkerRunResult:
  961. self.started.set()
  962. await self.release.wait()
  963. return WorkerRunResult(context["worker_trace_id"], "failed", error="test release")
  964. async def run_validator(self, context: dict[str, Any]) -> ValidatorRunResult:
  965. raise AssertionError(f"unexpected validation: {context}")
  966. async def stop(self, trace_id: str) -> bool:
  967. del trace_id
  968. self.release.set()
  969. return True
  970. @pytest.mark.asyncio
  971. async def test_same_task_cannot_reserve_parallel_attempts(tmp_path: Path) -> None:
  972. root = "parallel-attempt-root"
  973. task_store = FileSystemTaskStore(str(tmp_path / "ledger"))
  974. executor = _BlockingExecutor()
  975. coordinator = TaskCoordinator(
  976. task_store,
  977. FileSystemArtifactStore(str(tmp_path / "artifacts")),
  978. FileSystemTraceStore(str(tmp_path / "traces")),
  979. executor=executor,
  980. )
  981. await coordinator.ensure_ledger(
  982. root,
  983. {
  984. "objective": "parallel attempt guard",
  985. "acceptance_criteria": [
  986. {"criterion_id": "guard", "description": "guard", "hard": True}
  987. ],
  988. "context_refs": ["script-build://inputs/11"],
  989. },
  990. )
  991. planning = PhaseTwoPlanningService(
  992. coordinator=coordinator,
  993. bindings=cast(Any, _SingleBinding(root)),
  994. contracts=FileScriptTaskContractStore(tmp_path / "contracts"),
  995. )
  996. task = await planning.plan_script_tasks(
  997. contract_payloads=(_contract(ScriptTaskKind.DIRECTION),),
  998. parent_task_id=None,
  999. context={"root_trace_id": root},
  1000. )
  1001. task_id = task["task_ids"][0]
  1002. first = asyncio.create_task(
  1003. planning.dispatch_script_tasks(task_ids=(task_id,), context={"root_trace_id": root})
  1004. )
  1005. await asyncio.wait_for(executor.started.wait(), timeout=2)
  1006. second = await planning.dispatch_script_tasks(
  1007. task_ids=(task_id,), context={"root_trace_id": root}
  1008. )
  1009. assert "Dispatch conflict" in cast(str, second[0]["error"])
  1010. executor.release.set()
  1011. await first
  1012. ledger = await task_store.load(root)
  1013. assert len(ledger.tasks[task_id].attempt_ids) == 1