test_phase_two_sql_flow.py 39 KB

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