test_phase_two_lifecycle.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932
  1. from __future__ import annotations
  2. import asyncio
  3. from dataclasses import replace
  4. from datetime import UTC, datetime
  5. from types import SimpleNamespace
  6. from unittest.mock import AsyncMock
  7. import pytest
  8. from agent.orchestration import DecisionAction, OperationStatus, TaskStatus
  9. from agent.trace.models import Message, Trace
  10. from agent.trace.store import FileSystemTraceStore
  11. from script_build_host.application.mission_factory import ScriptMissionFactory
  12. from script_build_host.application.mission_service import (
  13. PHASE_ONE_CAPABILITY_BOUNDARY,
  14. PHASE_TWO_CANDIDATE_PORTFOLIO_READY,
  15. ScriptMissionService,
  16. )
  17. from script_build_host.application.observation_views import mission_snapshot_view
  18. from script_build_host.domain.errors import (
  19. MissionRecoveryRequired,
  20. PhaseTwoBoundaryNotReady,
  21. PlannerPolicyMigrationRequired,
  22. )
  23. from script_build_host.domain.input_snapshot import ScriptBuildInputSnapshotV1
  24. from script_build_host.domain.records import (
  25. BuildStatus,
  26. MissionBinding,
  27. Principal,
  28. PublicationState,
  29. )
  30. from script_build_host.infrastructure.canonical_json import canonical_sha256
  31. class _TraceStore:
  32. def __init__(self) -> None:
  33. self.events: list[dict[str, object]] = []
  34. self.messages: list[SimpleNamespace] = []
  35. async def get_events(self, _root: str, _since: int):
  36. return list(self.events)
  37. async def append_event(self, _root: str, event: str, payload: dict[str, object]):
  38. self.events.append({"event": event, **payload})
  39. return len(self.events)
  40. async def get_trace_messages(self, _root: str):
  41. return list(self.messages)
  42. class _Runner:
  43. def __init__(self, root: SimpleNamespace) -> None:
  44. self.root = root
  45. self.trace_store = _TraceStore()
  46. self.tools = SimpleNamespace(
  47. get_tool_names=lambda **_kwargs: ["plan_script_tasks", "decide_script_task"]
  48. )
  49. self.observed_config = None
  50. async def run(self, *, messages, config):
  51. self.observed_config = config
  52. assert [item["role"] for item in messages] == ["system", "user"]
  53. yield SimpleNamespace(trace_id="root")
  54. yield SimpleNamespace(role="system", sequence=11)
  55. yield SimpleNamespace(role="user", sequence=12)
  56. assert self.root.status is TaskStatus.NEEDS_REPLAN
  57. self.root.status = TaskStatus.BLOCKED
  58. self.root.blocked_reason = PHASE_TWO_CANDIDATE_PORTFOLIO_READY
  59. class _Coordinator:
  60. def __init__(self, root: SimpleNamespace) -> None:
  61. self.root = root
  62. self.unblock_calls = 0
  63. self.task_store = SimpleNamespace(
  64. load=lambda _root: _async(
  65. SimpleNamespace(root_task_id="root-task", tasks={"root-task": root})
  66. )
  67. )
  68. async def decide_task(
  69. self,
  70. _root_trace_id,
  71. task_id,
  72. _validation_id,
  73. action,
  74. _payload,
  75. idempotency_key,
  76. ):
  77. assert task_id == "root-task"
  78. assert action is DecisionAction.UNBLOCK
  79. assert idempotency_key == "phase-two-unblock:7"
  80. self.unblock_calls += 1
  81. self.root.status = TaskStatus.NEEDS_REPLAN
  82. self.root.blocked_reason = None
  83. async def root_completion(self, _root: str):
  84. return {
  85. "status": self.root.status.value,
  86. "blocked_reason": self.root.blocked_reason,
  87. }
  88. class _State:
  89. def __init__(self) -> None:
  90. self.status = BuildStatus.RUNNING
  91. self.checkpoint = None
  92. async def get_status(self, _build: int):
  93. return self.status
  94. async def set_status(self, _build: int, status: BuildStatus, **_kwargs):
  95. self.status = status
  96. async def set_checkpoint(self, _build: int, **values):
  97. self.status = BuildStatus.PARTIAL
  98. self.checkpoint = values
  99. def _snapshot() -> ScriptBuildInputSnapshotV1:
  100. return ScriptBuildInputSnapshotV1(
  101. snapshot_id="11",
  102. script_build_id=7,
  103. execution_id=1,
  104. topic_build_id=2,
  105. topic_id=3,
  106. topic={"topic": {"id": 3, "result": "topic"}},
  107. account={"account_name": "acct"},
  108. persona_points=(),
  109. section_patterns=(),
  110. strategies=(),
  111. prompt_manifest=(),
  112. datasource_manifest={},
  113. model_manifest={
  114. "presets": {
  115. "script_planner": {
  116. "model": "fake",
  117. "temperature": 0,
  118. "max_iterations": 10,
  119. }
  120. }
  121. },
  122. canonical_sha256="sha256:" + "1" * 64,
  123. created_at=datetime.now(UTC),
  124. )
  125. def _binding() -> MissionBinding:
  126. now = datetime.now(UTC)
  127. return MissionBinding(
  128. binding_id=1,
  129. script_build_id=7,
  130. root_trace_id="root",
  131. input_snapshot_id=11,
  132. active_direction_artifact_version_id=3,
  133. accepted_root_artifact_version_id=None,
  134. engine_version="test",
  135. schema_version="v1",
  136. created_at=now,
  137. updated_at=now,
  138. )
  139. @pytest.mark.asyncio
  140. async def test_phase_two_policy_is_persisted_before_root_unblock_and_same_trace_continues() -> None:
  141. root = SimpleNamespace(
  142. task_id="root-task",
  143. status=TaskStatus.BLOCKED,
  144. blocked_reason=PHASE_ONE_CAPABILITY_BOUNDARY,
  145. )
  146. runner = _Runner(root)
  147. coordinator = _Coordinator(root)
  148. state = _State()
  149. service = ScriptMissionService(
  150. runner=runner,
  151. coordinator=coordinator,
  152. factory=ScriptMissionFactory(),
  153. input_snapshots=SimpleNamespace(),
  154. bindings=SimpleNamespace(),
  155. legacy_state=state,
  156. authorizer=SimpleNamespace(),
  157. direction_reconciler=SimpleNamespace(),
  158. )
  159. await service.run_phase_two(
  160. 7,
  161. binding=_binding(),
  162. snapshot=_snapshot(),
  163. direction_artifact_version_id=3,
  164. )
  165. assert runner.observed_config.trace_id == "root"
  166. assert runner.observed_config.new_trace_id is None
  167. assert runner.trace_store.events[0]["event"] == "planner_policy_migrated"
  168. assert state.status is BuildStatus.PARTIAL
  169. assert state.checkpoint["checkpoint_code"] == PHASE_TWO_CANDIDATE_PORTFOLIO_READY
  170. async def _async(value):
  171. return value
  172. class _ReentryRunner(_Runner):
  173. def __init__(self, root: SimpleNamespace) -> None:
  174. super().__init__(root)
  175. self.run_result_calls = 0
  176. async def run_result(self, *, messages, config):
  177. self.run_result_calls += 1
  178. self.observed_config = config
  179. assert messages == []
  180. assert self.root.status is TaskStatus.NEEDS_REPLAN
  181. self.root.status = TaskStatus.BLOCKED
  182. self.root.blocked_reason = PHASE_TWO_CANDIDATE_PORTFOLIO_READY
  183. return SimpleNamespace()
  184. def _migration_event(runner: _Runner, snapshot: ScriptBuildInputSnapshotV1) -> dict[str, object]:
  185. factory = ScriptMissionFactory()
  186. policy = factory.build_phase_two_policy(snapshot)
  187. continuation = factory.build_phase_two_message(
  188. _binding(), snapshot, direction_artifact_version_id=3
  189. )
  190. runner.trace_store.messages = [
  191. SimpleNamespace(sequence=11, role="system", content=policy, parent_sequence=10),
  192. SimpleNamespace(sequence=12, role="user", content=continuation, parent_sequence=11),
  193. ]
  194. return {
  195. "event": "planner_policy_migrated",
  196. "policy_version": "script-build-phase-two/v1",
  197. "policy_digest": canonical_sha256(policy).wire,
  198. "toolset_digest": canonical_sha256(
  199. sorted(runner.tools.get_tool_names(groups=["script_build"]))
  200. ).wire,
  201. "input_snapshot_id": snapshot.snapshot_id,
  202. "system_sequence": 11,
  203. "user_sequence": 12,
  204. }
  205. @pytest.mark.asyncio
  206. @pytest.mark.parametrize("root_status", [TaskStatus.BLOCKED, TaskStatus.NEEDS_REPLAN])
  207. async def test_phase_two_policy_reentry_resumes_before_or_after_unblock_without_rewriting_policy(
  208. root_status: TaskStatus,
  209. ) -> None:
  210. root = SimpleNamespace(
  211. task_id="root-task",
  212. status=root_status,
  213. blocked_reason=(
  214. PHASE_ONE_CAPABILITY_BOUNDARY if root_status is TaskStatus.BLOCKED else None
  215. ),
  216. )
  217. runner = _ReentryRunner(root)
  218. runner.trace_store.events = [_migration_event(runner, _snapshot())]
  219. coordinator = _Coordinator(root)
  220. state = _State()
  221. state.status = BuildStatus.PARTIAL
  222. service = ScriptMissionService(
  223. runner=runner,
  224. coordinator=coordinator,
  225. factory=ScriptMissionFactory(),
  226. input_snapshots=SimpleNamespace(),
  227. bindings=SimpleNamespace(),
  228. legacy_state=state,
  229. authorizer=SimpleNamespace(),
  230. direction_reconciler=SimpleNamespace(),
  231. )
  232. await service.run_phase_two(
  233. 7,
  234. binding=_binding(),
  235. snapshot=_snapshot(),
  236. direction_artifact_version_id=3,
  237. )
  238. assert runner.run_result_calls == 1
  239. assert len(runner.trace_store.events) == 1
  240. assert coordinator.unblock_calls == (1 if root_status is TaskStatus.BLOCKED else 0)
  241. assert state.status is BuildStatus.PARTIAL
  242. assert state.checkpoint["checkpoint_code"] == PHASE_TWO_CANDIDATE_PORTFOLIO_READY
  243. @pytest.mark.asyncio
  244. async def test_phase_two_reentry_with_output_requires_recovery_without_terminal_failure() -> None:
  245. root = SimpleNamespace(
  246. task_id="root-task",
  247. status=TaskStatus.NEEDS_REPLAN,
  248. blocked_reason=None,
  249. )
  250. runner = _ReentryRunner(root)
  251. runner.trace_store.events = [_migration_event(runner, _snapshot())]
  252. runner.trace_store.messages.append(
  253. SimpleNamespace(sequence=13, role="assistant", content="started")
  254. )
  255. state = _State()
  256. state.status = BuildStatus.RUNNING
  257. service = ScriptMissionService(
  258. runner=runner,
  259. coordinator=_Coordinator(root),
  260. factory=ScriptMissionFactory(),
  261. input_snapshots=SimpleNamespace(),
  262. bindings=SimpleNamespace(),
  263. legacy_state=state,
  264. authorizer=SimpleNamespace(),
  265. direction_reconciler=SimpleNamespace(),
  266. )
  267. with pytest.raises(MissionRecoveryRequired):
  268. await service.run_phase_two(
  269. 7,
  270. binding=_binding(),
  271. snapshot=_snapshot(),
  272. direction_artifact_version_id=3,
  273. )
  274. assert state.status is BuildStatus.RUNNING
  275. assert runner.run_result_calls == 0
  276. @pytest.mark.asyncio
  277. @pytest.mark.parametrize(
  278. ("root_status", "has_model_output"),
  279. [
  280. (TaskStatus.BLOCKED, False),
  281. (TaskStatus.NEEDS_REPLAN, False),
  282. (TaskStatus.NEEDS_REPLAN, True),
  283. ],
  284. )
  285. async def test_historical_snapshot_v2_policy_reentry_survives_filestore_reload(
  286. tmp_path,
  287. root_status: TaskStatus,
  288. has_model_output: bool,
  289. ) -> None:
  290. parent = _snapshot()
  291. business_digest = canonical_sha256(parent.to_input().business_payload()).wire
  292. current = replace(
  293. parent,
  294. snapshot_id="12",
  295. parent_snapshot_id=parent.snapshot_id,
  296. parent_snapshot_sha256=parent.canonical_sha256,
  297. business_input_sha256=business_digest,
  298. )
  299. binding = replace(_binding(), input_snapshot_id=12)
  300. policy = ScriptMissionFactory().build_phase_two_policy(current)
  301. continuation = ScriptMissionFactory().build_phase_two_message(
  302. binding,
  303. current,
  304. direction_artifact_version_id=3,
  305. )
  306. store_path = tmp_path / "phase-two-reentry"
  307. writer = FileSystemTraceStore(str(store_path))
  308. await writer.create_trace(
  309. Trace(
  310. trace_id="root",
  311. mode="agent",
  312. agent_type="script_planner",
  313. agent_role="planner",
  314. )
  315. )
  316. await writer.add_message(
  317. Message.create(
  318. trace_id="root",
  319. role="system",
  320. sequence=11,
  321. parent_sequence=10,
  322. content=policy,
  323. )
  324. )
  325. await writer.add_message(
  326. Message.create(
  327. trace_id="root",
  328. role="user",
  329. sequence=12,
  330. parent_sequence=11,
  331. content=continuation,
  332. )
  333. )
  334. toolset_digest = canonical_sha256(["decide_script_task", "plan_script_tasks"]).wire
  335. await writer.append_event(
  336. "root",
  337. "planner_policy_migrated",
  338. {
  339. "policy_version": "script-build-phase-two/v1",
  340. "policy_digest": canonical_sha256(policy).wire,
  341. "toolset_digest": toolset_digest,
  342. "input_snapshot_id": current.snapshot_id,
  343. "system_sequence": 11,
  344. "user_sequence": 12,
  345. },
  346. )
  347. if has_model_output:
  348. await writer.add_message(
  349. Message.create(
  350. trace_id="root",
  351. role="assistant",
  352. sequence=13,
  353. parent_sequence=12,
  354. content={"text": "phase two already started"},
  355. )
  356. )
  357. root = SimpleNamespace(
  358. task_id="root-task",
  359. status=root_status,
  360. blocked_reason=(
  361. PHASE_ONE_CAPABILITY_BOUNDARY if root_status is TaskStatus.BLOCKED else None
  362. ),
  363. )
  364. runner = _ReentryRunner(root)
  365. runner.trace_store = FileSystemTraceStore(str(store_path))
  366. coordinator = _Coordinator(root)
  367. state = _State()
  368. state.status = BuildStatus.PARTIAL if root_status is TaskStatus.BLOCKED else BuildStatus.RUNNING
  369. service = ScriptMissionService(
  370. runner=runner,
  371. coordinator=coordinator,
  372. factory=ScriptMissionFactory(),
  373. input_snapshots=SimpleNamespace(),
  374. bindings=SimpleNamespace(),
  375. legacy_state=state,
  376. authorizer=SimpleNamespace(),
  377. direction_reconciler=SimpleNamespace(),
  378. )
  379. if has_model_output:
  380. with pytest.raises(MissionRecoveryRequired):
  381. await service.run_phase_two(
  382. 7,
  383. binding=binding,
  384. snapshot=current,
  385. direction_artifact_version_id=3,
  386. )
  387. assert runner.run_result_calls == 0
  388. assert state.status is BuildStatus.RUNNING
  389. return
  390. await service.run_phase_two(
  391. 7,
  392. binding=binding,
  393. snapshot=current,
  394. direction_artifact_version_id=3,
  395. )
  396. reloaded = FileSystemTraceStore(str(store_path))
  397. messages = await reloaded.get_trace_messages("root")
  398. events = await reloaded.get_events("root", 0)
  399. assert [(item.sequence, item.role) for item in messages if item.sequence >= 11] == [
  400. (11, "system"),
  401. (12, "user"),
  402. ]
  403. assert len([item for item in events if item.get("event") == "planner_policy_migrated"]) == 1
  404. assert coordinator.unblock_calls == (1 if root_status is TaskStatus.BLOCKED else 0)
  405. assert state.status is BuildStatus.PARTIAL
  406. @pytest.mark.asyncio
  407. async def test_phase_two_reentry_rejects_unbacked_policy_event() -> None:
  408. root = SimpleNamespace(
  409. task_id="root-task",
  410. status=TaskStatus.BLOCKED,
  411. blocked_reason=PHASE_ONE_CAPABILITY_BOUNDARY,
  412. )
  413. runner = _ReentryRunner(root)
  414. runner.trace_store.events = [_migration_event(runner, _snapshot())]
  415. runner.trace_store.messages[0].content = "tampered policy"
  416. state = _State()
  417. state.status = BuildStatus.PARTIAL
  418. service = ScriptMissionService(
  419. runner=runner,
  420. coordinator=_Coordinator(root),
  421. factory=ScriptMissionFactory(),
  422. input_snapshots=SimpleNamespace(),
  423. bindings=SimpleNamespace(),
  424. legacy_state=state,
  425. authorizer=SimpleNamespace(),
  426. direction_reconciler=SimpleNamespace(),
  427. )
  428. with pytest.raises(PlannerPolicyMigrationRequired):
  429. await service.run_phase_two(
  430. 7,
  431. binding=_binding(),
  432. snapshot=_snapshot(),
  433. direction_artifact_version_id=3,
  434. )
  435. assert runner.run_result_calls == 0
  436. assert state.status is BuildStatus.FAILED
  437. @pytest.mark.asyncio
  438. async def test_phase_two_reentry_recovers_messages_written_before_migration_event() -> None:
  439. root = SimpleNamespace(
  440. task_id="root-task",
  441. status=TaskStatus.BLOCKED,
  442. blocked_reason=PHASE_ONE_CAPABILITY_BOUNDARY,
  443. )
  444. runner = _ReentryRunner(root)
  445. event = _migration_event(runner, _snapshot())
  446. runner.trace_store.events = []
  447. runner.trace_store.messages[0].parent_sequence = 10
  448. runner.trace_store.messages[1].parent_sequence = 11
  449. state = _State()
  450. state.status = BuildStatus.PARTIAL
  451. service = ScriptMissionService(
  452. runner=runner,
  453. coordinator=_Coordinator(root),
  454. factory=ScriptMissionFactory(),
  455. input_snapshots=SimpleNamespace(),
  456. bindings=SimpleNamespace(),
  457. legacy_state=state,
  458. authorizer=SimpleNamespace(),
  459. direction_reconciler=SimpleNamespace(),
  460. )
  461. await service.run_phase_two(
  462. 7,
  463. binding=_binding(),
  464. snapshot=_snapshot(),
  465. direction_artifact_version_id=3,
  466. )
  467. assert runner.run_result_calls == 1
  468. assert len(runner.trace_store.events) == 1
  469. assert runner.trace_store.events[0]["policy_digest"] == event["policy_digest"]
  470. assert state.status is BuildStatus.PARTIAL
  471. @pytest.mark.asyncio
  472. @pytest.mark.parametrize("failure", ["duplicate", "toolset"])
  473. async def test_phase_two_reentry_rejects_ambiguous_policy_identity(failure: str) -> None:
  474. root = SimpleNamespace(
  475. task_id="root-task",
  476. status=TaskStatus.BLOCKED,
  477. blocked_reason=PHASE_ONE_CAPABILITY_BOUNDARY,
  478. )
  479. runner = _ReentryRunner(root)
  480. event = _migration_event(runner, _snapshot())
  481. if failure == "duplicate":
  482. runner.trace_store.events = [event, dict(event)]
  483. else:
  484. runner.trace_store.events = [{**event, "toolset_digest": "sha256:" + "0" * 64}]
  485. state = _State()
  486. state.status = BuildStatus.PARTIAL
  487. service = ScriptMissionService(
  488. runner=runner,
  489. coordinator=_Coordinator(root),
  490. factory=ScriptMissionFactory(),
  491. input_snapshots=SimpleNamespace(),
  492. bindings=SimpleNamespace(),
  493. legacy_state=state,
  494. authorizer=SimpleNamespace(),
  495. direction_reconciler=SimpleNamespace(),
  496. )
  497. with pytest.raises(PlannerPolicyMigrationRequired):
  498. await service.run_phase_two(
  499. 7,
  500. binding=_binding(),
  501. snapshot=_snapshot(),
  502. direction_artifact_version_id=3,
  503. )
  504. assert runner.run_result_calls == 0
  505. assert state.status is BuildStatus.FAILED
  506. @pytest.mark.asyncio
  507. async def test_direction_accept_may_cross_only_one_verified_prompt_snapshot_lineage() -> None:
  508. parent = _snapshot()
  509. business_digest = canonical_sha256(parent.to_input().business_payload()).wire
  510. current = replace(
  511. parent,
  512. snapshot_id="12",
  513. parent_snapshot_id=parent.snapshot_id,
  514. parent_snapshot_sha256=parent.canonical_sha256,
  515. business_input_sha256=business_digest,
  516. )
  517. spec = SimpleNamespace(
  518. version=1,
  519. context_refs=(
  520. "script-build://task-kinds/direction",
  521. f"script-build://inputs/{parent.snapshot_id}",
  522. ),
  523. )
  524. direction = SimpleNamespace(
  525. status=TaskStatus.COMPLETED,
  526. current_spec=spec,
  527. specs=[spec],
  528. decision_ids=["direction-accept"],
  529. )
  530. ledger = SimpleNamespace(
  531. tasks={"direction": direction},
  532. decisions={
  533. "direction-accept": SimpleNamespace(
  534. action=DecisionAction.ACCEPT,
  535. attempt_id="direction-attempt",
  536. )
  537. },
  538. attempts={"direction-attempt": SimpleNamespace(spec_version=1)},
  539. )
  540. class Snapshots:
  541. async def get(self, snapshot_id: str, *, script_build_id: int):
  542. assert (snapshot_id, script_build_id) == ("11", 7)
  543. return parent
  544. service = ScriptMissionService(
  545. runner=SimpleNamespace(),
  546. coordinator=SimpleNamespace(),
  547. factory=ScriptMissionFactory(),
  548. input_snapshots=Snapshots(),
  549. bindings=SimpleNamespace(),
  550. legacy_state=SimpleNamespace(),
  551. authorizer=SimpleNamespace(),
  552. direction_reconciler=SimpleNamespace(),
  553. )
  554. await service._verify_direction_snapshot_lineage(
  555. script_build_id=7,
  556. ledger=ledger,
  557. current_snapshot=current,
  558. )
  559. tampered = replace(current, business_input_sha256="sha256:" + "9" * 64)
  560. with pytest.raises(PhaseTwoBoundaryNotReady, match="immutable lineage"):
  561. await service._verify_direction_snapshot_lineage(
  562. script_build_id=7,
  563. ledger=ledger,
  564. current_snapshot=tampered,
  565. )
  566. class _AdvanceSnapshots:
  567. def __init__(self) -> None:
  568. self.extend_calls = 0
  569. async def get(self, _snapshot_id: str, *, script_build_id: int):
  570. assert script_build_id == 7
  571. return _snapshot()
  572. async def extend_prompt_lineage(self, *_args, **_kwargs):
  573. self.extend_calls += 1
  574. return _snapshot()
  575. class _AdvanceState(_State):
  576. def __init__(self, status: BuildStatus) -> None:
  577. super().__init__()
  578. self.status = status
  579. def _advance_service(
  580. *,
  581. status: BuildStatus = BuildStatus.PARTIAL,
  582. blocked_reason: str = PHASE_ONE_CAPABILITY_BOUNDARY,
  583. operation_status: OperationStatus | None = None,
  584. publication_closed: bool = True,
  585. ) -> tuple[ScriptMissionService, _AdvanceSnapshots]:
  586. root = SimpleNamespace(
  587. task_id="root-task",
  588. status=TaskStatus.BLOCKED,
  589. blocked_reason=blocked_reason,
  590. decision_ids=["root-decision"],
  591. current_spec=SimpleNamespace(context_refs=()),
  592. )
  593. operation = (
  594. {"operation": SimpleNamespace(status=operation_status)}
  595. if operation_status is not None
  596. else {}
  597. )
  598. ledger = SimpleNamespace(
  599. root_task_id="root-task",
  600. tasks={"root-task": root},
  601. decisions={
  602. "root-decision": SimpleNamespace(
  603. action=DecisionAction.BLOCK,
  604. reason=blocked_reason,
  605. )
  606. },
  607. operations=operation,
  608. )
  609. async def root_completion(_root: str):
  610. return {"status": root.status.value, "blocked_reason": root.blocked_reason}
  611. coordinator = SimpleNamespace(
  612. root_completion=root_completion,
  613. task_store=SimpleNamespace(load=lambda _root: _async(ledger)),
  614. )
  615. snapshots = _AdvanceSnapshots()
  616. publication = (
  617. SimpleNamespace(
  618. state=PublicationState.PUBLISHED,
  619. artifact_version_id=3,
  620. )
  621. if publication_closed
  622. else None
  623. )
  624. reconciler = SimpleNamespace(
  625. reconcile=lambda _build, _root: _async(3),
  626. publications=SimpleNamespace(get_by_build=lambda *_args, **_kwargs: _async(publication)),
  627. )
  628. service = ScriptMissionService(
  629. runner=SimpleNamespace(
  630. trace_store=SimpleNamespace(
  631. get_trace=lambda _root: _async(SimpleNamespace(agent_type="script_planner"))
  632. )
  633. ),
  634. coordinator=coordinator,
  635. factory=ScriptMissionFactory(),
  636. input_snapshots=snapshots,
  637. bindings=SimpleNamespace(get_by_build=lambda _build: _async(_binding())),
  638. legacy_state=_AdvanceState(status),
  639. authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async(None)),
  640. direction_reconciler=reconciler,
  641. phase_two_prompt_requests=(SimpleNamespace(),),
  642. phase_two_required_presets=("missing-phase-two-preset",),
  643. )
  644. service.run_phase_two = AsyncMock() # type: ignore[method-assign]
  645. return service, snapshots
  646. @pytest.mark.asyncio
  647. @pytest.mark.parametrize(
  648. ("status", "blocked_reason", "operation_status", "publication_closed"),
  649. [
  650. (BuildStatus.STOPPED, PHASE_ONE_CAPABILITY_BOUNDARY, None, True),
  651. (BuildStatus.STOPPING, PHASE_ONE_CAPABILITY_BOUNDARY, None, True),
  652. (BuildStatus.PARTIAL, "WRONG_BOUNDARY", None, True),
  653. (BuildStatus.PARTIAL, PHASE_ONE_CAPABILITY_BOUNDARY, OperationStatus.RUNNING, True),
  654. (BuildStatus.PARTIAL, PHASE_ONE_CAPABILITY_BOUNDARY, None, False),
  655. ],
  656. )
  657. async def test_phase_two_advance_rejects_invalid_entry_without_extending_snapshot(
  658. status: BuildStatus,
  659. blocked_reason: str,
  660. operation_status: OperationStatus | None,
  661. publication_closed: bool,
  662. ) -> None:
  663. service, snapshots = _advance_service(
  664. status=status,
  665. blocked_reason=blocked_reason,
  666. operation_status=operation_status,
  667. publication_closed=publication_closed,
  668. )
  669. with pytest.raises(PhaseTwoBoundaryNotReady):
  670. await service.advance_to_phase_two(7, Principal("owner"))
  671. assert snapshots.extend_calls == 0
  672. @pytest.mark.asyncio
  673. async def test_concurrent_phase_two_advance_creates_only_one_active_run() -> None:
  674. state = _AdvanceState(BuildStatus.PARTIAL)
  675. release_prepare = asyncio.Event()
  676. release_run = asyncio.Event()
  677. prepare_calls = 0
  678. async def prepare(_build: int):
  679. nonlocal prepare_calls
  680. prepare_calls += 1
  681. await release_prepare.wait()
  682. return _binding(), _snapshot(), 3
  683. async def run_phase_two(*_args, **_kwargs):
  684. await release_run.wait()
  685. service = ScriptMissionService(
  686. runner=SimpleNamespace(),
  687. coordinator=SimpleNamespace(
  688. root_completion=lambda _root: _async(
  689. {
  690. "status": TaskStatus.BLOCKED.value,
  691. "blocked_reason": PHASE_ONE_CAPABILITY_BOUNDARY,
  692. }
  693. )
  694. ),
  695. factory=ScriptMissionFactory(),
  696. input_snapshots=SimpleNamespace(),
  697. bindings=SimpleNamespace(get_by_build=lambda _build: _async(_binding())),
  698. legacy_state=state,
  699. authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async(None)),
  700. direction_reconciler=SimpleNamespace(),
  701. )
  702. service._prepare_phase_two_transition = prepare # type: ignore[method-assign]
  703. service.run_phase_two = run_phase_two # type: ignore[method-assign]
  704. first = asyncio.create_task(service.advance_to_phase_two(7, Principal("owner")))
  705. second = asyncio.create_task(service.advance_to_phase_two(7, Principal("owner")))
  706. await asyncio.sleep(0)
  707. release_prepare.set()
  708. first_result, second_result = await asyncio.gather(first, second)
  709. assert prepare_calls == 1
  710. assert first_result.status is BuildStatus.RUNNING
  711. assert second_result.status is BuildStatus.RUNNING
  712. assert first_result.root_trace_id == second_result.root_trace_id == "root"
  713. release_run.set()
  714. await asyncio.sleep(0)
  715. @pytest.mark.asyncio
  716. async def test_http_advance_uses_safe_reentry_after_policy_was_unblocked() -> None:
  717. state = _AdvanceState(BuildStatus.PARTIAL)
  718. release_run = asyncio.Event()
  719. coordinator = SimpleNamespace(
  720. root_completion=lambda _root: _async(
  721. {"status": TaskStatus.NEEDS_REPLAN.value, "blocked_reason": None}
  722. )
  723. )
  724. service = ScriptMissionService(
  725. runner=SimpleNamespace(),
  726. coordinator=coordinator,
  727. factory=ScriptMissionFactory(),
  728. input_snapshots=SimpleNamespace(),
  729. bindings=SimpleNamespace(get_by_build=lambda _build: _async(_binding())),
  730. legacy_state=state,
  731. authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async(None)),
  732. direction_reconciler=SimpleNamespace(),
  733. )
  734. service._prepare_phase_two_transition = AsyncMock( # type: ignore[method-assign]
  735. side_effect=AssertionError("normal Phase1 boundary path must not be used")
  736. )
  737. service._prepare_phase_two_reentry = AsyncMock( # type: ignore[method-assign]
  738. return_value=(_binding(), _snapshot(), 3)
  739. )
  740. async def run_phase_two(*_args, **_kwargs):
  741. await release_run.wait()
  742. service.run_phase_two = run_phase_two # type: ignore[method-assign]
  743. result = await service.advance_to_phase_two(7, Principal("owner"))
  744. assert result.status is BuildStatus.RUNNING
  745. service._prepare_phase_two_reentry.assert_awaited_once_with(7) # type: ignore[attr-defined]
  746. service._prepare_phase_two_transition.assert_not_awaited() # type: ignore[attr-defined]
  747. release_run.set()
  748. await asyncio.sleep(0)
  749. def test_mission_observation_dto_bounds_text_and_omits_internal_payloads() -> None:
  750. secret = "must-not-leak"
  751. artifact_ref = SimpleNamespace(
  752. uri="script-build://artifact-versions/1",
  753. kind="paragraph",
  754. version="1",
  755. digest="sha256:" + "a" * 64,
  756. )
  757. task = SimpleNamespace(
  758. task_id="task-1",
  759. parent_task_id="root",
  760. display_path="root/task-1",
  761. status=TaskStatus.COMPLETED,
  762. current_spec=SimpleNamespace(
  763. version=1,
  764. objective="candidate body " * 500,
  765. acceptance_criteria=[],
  766. context_refs=("protected://" + secret,),
  767. ),
  768. child_task_ids=[],
  769. attempt_ids=["attempt-1"],
  770. validation_ids=[],
  771. decision_ids=["decision-1"],
  772. blocked_reason=None,
  773. superseded_by=None,
  774. created_at="now",
  775. updated_at="now",
  776. )
  777. attempt = SimpleNamespace(
  778. attempt_id="attempt-1",
  779. task_id="task-1",
  780. spec_version=1,
  781. worker_trace_id="worker",
  782. worker_preset="script_paragraph_worker",
  783. status="submitted",
  784. operation_id="operation-1",
  785. snapshot_id="snapshot-1",
  786. accepted_child_decision_ids=[],
  787. submission=SimpleNamespace(
  788. summary="candidate body " * 500,
  789. artifact_refs=[artifact_ref],
  790. evidence_refs=[],
  791. ),
  792. error=None,
  793. created_at="now",
  794. updated_at="now",
  795. protected_context={"token": secret},
  796. )
  797. decision = SimpleNamespace(
  798. decision_id="decision-1",
  799. task_id="task-1",
  800. action=DecisionAction.ACCEPT,
  801. reason="accepted",
  802. from_status="awaiting_decision",
  803. to_status="completed",
  804. attempt_id="attempt-1",
  805. validation_id=None,
  806. created_at="now",
  807. payload={"tool_arguments": secret},
  808. )
  809. operation = SimpleNamespace(
  810. operation_id="operation-1",
  811. kind="dispatch",
  812. status=OperationStatus.COMPLETED,
  813. task_ids=["task-1"],
  814. attempt_ids=["attempt-1"],
  815. validation_ids=[],
  816. deadline_at=None,
  817. error=None,
  818. created_at="now",
  819. updated_at="now",
  820. request={"arguments": secret},
  821. )
  822. ledger = SimpleNamespace(
  823. revision=1,
  824. root_trace_id="root",
  825. root_task_id="root",
  826. root_objective="root",
  827. focused_task_id=None,
  828. tasks={"task-1": task},
  829. attempts={"attempt-1": attempt},
  830. validations={},
  831. decisions={"decision-1": decision},
  832. operations={"operation-1": operation},
  833. protected_context={"token": secret},
  834. )
  835. view = mission_snapshot_view(ledger)
  836. rendered = str(view)
  837. assert secret not in rendered
  838. assert view["tasks"][0]["current_spec"]["context_refs"] == ["[internal-ref]"]
  839. assert len(view["tasks"][0]["current_spec"]["objective"]) == 2_000
  840. assert len(view["attempts"][0]["submission"]["summary"]) == 500