test_orchestration_store.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import asyncio
  2. import json
  3. import multiprocessing
  4. from copy import deepcopy
  5. import pytest
  6. from agent.orchestration.models import (
  7. ArtifactRef,
  8. AttemptSubmission,
  9. BackgroundOperation,
  10. CommandRecord,
  11. EventDraft,
  12. OperationKind,
  13. OperationStatus,
  14. TaskLedger,
  15. TaskStatus,
  16. )
  17. from agent.orchestration.state_machine import InvalidTaskTransition, transition
  18. from agent.orchestration.store import (
  19. ArtifactConflict,
  20. FileSystemArtifactStore,
  21. FileSystemTaskStore,
  22. RevisionConflict,
  23. TaskStoreError,
  24. )
  25. def _commit_from_process(base_path, mission, barrier, results):
  26. async def run():
  27. store = FileSystemTaskStore(base_path)
  28. ledger = await store.load("root")
  29. ledger.mission = mission
  30. barrier.wait()
  31. try:
  32. await store.commit(ledger, expected_revision=0)
  33. except RevisionConflict:
  34. results.put("conflict")
  35. else:
  36. results.put("committed")
  37. asyncio.run(run())
  38. def _freeze_from_process(base_path, barrier, results):
  39. async def run():
  40. store = FileSystemArtifactStore(base_path)
  41. barrier.wait()
  42. snapshot = await store.freeze("root", "attempt", AttemptSubmission(summary="same"))
  43. results.put(snapshot.snapshot_id)
  44. asyncio.run(run())
  45. @pytest.mark.asyncio
  46. async def test_ledger_atomic_revision_and_roundtrip(tmp_path):
  47. store = FileSystemTaskStore(str(tmp_path))
  48. ledger = TaskLedger(root_trace_id="root", mission="mission")
  49. committed = await store.commit(ledger, expected_revision=-1)
  50. assert committed.revision == 0
  51. loaded = await store.load("root")
  52. assert loaded.mission == "mission"
  53. assert loaded.revision == 0
  54. stale = TaskLedger(root_trace_id="root", mission="stale", revision=-1)
  55. with pytest.raises(RevisionConflict):
  56. await store.commit(stale, expected_revision=-1)
  57. on_disk = json.loads((tmp_path / "root" / "orchestration" / "ledger.json").read_text())
  58. assert on_disk["revision"] == 0
  59. @pytest.mark.asyncio
  60. async def test_snapshot_is_immutable_and_hash_is_stable(tmp_path):
  61. store = FileSystemArtifactStore(str(tmp_path)).for_root("root")
  62. submission = AttemptSubmission(
  63. summary=" delivered ",
  64. artifact_refs=[ArtifactRef(uri="file:///result", version="v1")],
  65. evidence_refs=[ArtifactRef(uri="db://row/1", digest="abc")],
  66. )
  67. first = await store.freeze("attempt-1", submission)
  68. second = await store.freeze("attempt-2", submission)
  69. assert first.sha256 == second.sha256
  70. assert (await store.get(first.snapshot_id)).normalized_content["summary"] == "delivered"
  71. with pytest.raises(ValueError, match="version or digest"):
  72. ArtifactRef(uri="file:///mutable")
  73. @pytest.mark.asyncio
  74. async def test_same_attempt_snapshot_is_first_write_wins(tmp_path):
  75. store = FileSystemArtifactStore(str(tmp_path))
  76. submission = AttemptSubmission(
  77. summary="delivered",
  78. artifact_refs=[ArtifactRef(uri="file:///result", digest="abc")],
  79. )
  80. first = await store.freeze("root", "attempt-1", submission)
  81. replay = await store.freeze("root", "attempt-1", submission)
  82. assert replay == first
  83. assert await store.get_for_attempt("root", "attempt-1") == first
  84. with pytest.raises(ArtifactConflict, match="different artifact snapshot"):
  85. await store.freeze(
  86. "root",
  87. "attempt-1",
  88. AttemptSubmission(summary="changed", artifact_refs=submission.artifact_refs),
  89. )
  90. @pytest.mark.asyncio
  91. async def test_concurrent_same_attempt_freeze_creates_one_snapshot(tmp_path):
  92. first_store = FileSystemArtifactStore(str(tmp_path))
  93. second_store = FileSystemArtifactStore(str(tmp_path))
  94. submission = AttemptSubmission(summary="same")
  95. first, second = await asyncio.gather(
  96. first_store.freeze("root", "attempt-1", submission),
  97. second_store.freeze("root", "attempt-1", submission),
  98. )
  99. assert first == second
  100. artifact_files = list((tmp_path / "root" / "orchestration" / "artifacts").glob("*.json"))
  101. assert len(artifact_files) == 1
  102. @pytest.mark.asyncio
  103. async def test_artifact_orphans_can_be_listed_and_explicitly_cleaned(tmp_path):
  104. store = FileSystemArtifactStore(str(tmp_path))
  105. kept = await store.freeze("root", "attempt-kept", AttemptSubmission(summary="kept"))
  106. orphan = await store.freeze("root", "attempt-orphan", AttemptSubmission(summary="orphan"))
  107. assert await store.list_orphans("root", ["attempt-kept"]) == [orphan]
  108. assert await store.cleanup_orphans("root", ["attempt-kept"]) == [orphan.snapshot_id]
  109. assert await store.get("root", kept.snapshot_id) == kept
  110. with pytest.raises(FileNotFoundError):
  111. await store.get("root", orphan.snapshot_id)
  112. @pytest.mark.asyncio
  113. async def test_operations_commands_and_old_ledgers_roundtrip(tmp_path):
  114. store = FileSystemTaskStore(str(tmp_path))
  115. ledger = TaskLedger(root_trace_id="root", mission="mission")
  116. operation = BackgroundOperation(
  117. operation_id="op-1",
  118. root_trace_id="root",
  119. kind=OperationKind.DISPATCH,
  120. request={"task_ids": ["task-1"]},
  121. request_fingerprint="fingerprint",
  122. status=OperationStatus.RUNNING,
  123. )
  124. ledger.operations[operation.operation_id] = operation
  125. ledger.command_records["start:key"] = CommandRecord(
  126. command_id="start:key",
  127. operation="start_dispatch",
  128. fingerprint="fingerprint",
  129. result_ref={"operation_id": "op-1"},
  130. committed_revision=0,
  131. operation_id="op-1",
  132. )
  133. await store.commit(ledger, -1)
  134. loaded = await store.load("root")
  135. assert loaded.operations["op-1"].kind is OperationKind.DISPATCH
  136. assert loaded.command_records["start:key"].result_ref == {"operation_id": "op-1"}
  137. assert [item.operation_id for item in await store.list_recoverable("root")] == ["op-1"]
  138. old_path = tmp_path / "old-root" / "orchestration" / "ledger.json"
  139. old_path.parent.mkdir(parents=True)
  140. old_path.write_text(json.dumps({"root_trace_id": "old-root", "mission": "old"}), encoding="utf-8")
  141. old = await store.load("old-root")
  142. assert old.operations == {}
  143. assert old.command_records == {}
  144. @pytest.mark.asyncio
  145. async def test_events_commit_atomically_and_page_with_opaque_cursor(tmp_path):
  146. store = FileSystemTaskStore(str(tmp_path))
  147. ledger = TaskLedger(root_trace_id="root", mission="mission")
  148. await store.commit(
  149. ledger,
  150. -1,
  151. event=EventDraft("ledger_created", {"mission": "mission"}, command_id="command-1"),
  152. )
  153. ledger.mission = "updated"
  154. await store.commit(
  155. ledger,
  156. 0,
  157. event=EventDraft("mission_updated", {"mission": "updated"}, command_id="command-2"),
  158. )
  159. first_page = await store.list_events("root", limit=1)
  160. assert [event.sequence for event in first_page.events] == [1]
  161. assert first_page.events[0].schema_version == 1
  162. assert first_page.has_more is True
  163. assert first_page.next_cursor and "command" not in first_page.next_cursor
  164. second_page = await store.list_events("root", cursor=first_page.next_cursor)
  165. assert [event.event_type for event in second_page.events] == ["mission_updated"]
  166. assert second_page.events[0].ledger_revision == 1
  167. raw = json.loads((tmp_path / "root" / "orchestration" / "ledger.json").read_text())
  168. assert len(raw["_events"]) == 2
  169. assert "_events" not in (await store.load("root")).to_dict()
  170. with pytest.raises(ValueError, match="Invalid event cursor"):
  171. await store.list_events("other-root", cursor=first_page.next_cursor)
  172. @pytest.mark.asyncio
  173. async def test_event_command_replay_does_not_duplicate_event(tmp_path):
  174. store = FileSystemTaskStore(str(tmp_path))
  175. ledger = TaskLedger(root_trace_id="root", mission="mission")
  176. await store.commit(ledger, -1, event=EventDraft("created", {}, command_id="command-1"))
  177. ledger.mission = "same command replay"
  178. await store.commit(ledger, 0, event=EventDraft("created", {}, command_id="command-1"))
  179. page = await store.list_events("root")
  180. assert len(page.events) == 1
  181. @pytest.mark.asyncio
  182. async def test_two_store_instances_enforce_compare_and_swap(tmp_path):
  183. first_store = FileSystemTaskStore(str(tmp_path))
  184. second_store = FileSystemTaskStore(str(tmp_path))
  185. ledger = TaskLedger(root_trace_id="root", mission="mission")
  186. await first_store.commit(ledger, -1)
  187. first_copy = await first_store.load("root")
  188. stale_copy = deepcopy(first_copy)
  189. first_copy.mission = "winner"
  190. stale_copy.mission = "stale"
  191. await first_store.commit(first_copy, 0)
  192. with pytest.raises(RevisionConflict):
  193. await second_store.commit(stale_copy, 0)
  194. assert (await second_store.load("root")).mission == "winner"
  195. def test_cross_process_lock_allows_only_one_cas_winner(tmp_path):
  196. asyncio.run(
  197. FileSystemTaskStore(str(tmp_path)).commit(
  198. TaskLedger(root_trace_id="root", mission="initial"),
  199. -1,
  200. )
  201. )
  202. context = multiprocessing.get_context("spawn")
  203. barrier = context.Barrier(2)
  204. results = context.Queue()
  205. processes = [
  206. context.Process(
  207. target=_commit_from_process,
  208. args=(str(tmp_path), mission, barrier, results),
  209. )
  210. for mission in ("first", "second")
  211. ]
  212. for process in processes:
  213. process.start()
  214. for process in processes:
  215. process.join(timeout=10)
  216. assert process.exitcode == 0
  217. assert sorted([results.get(timeout=1), results.get(timeout=1)]) == ["committed", "conflict"]
  218. assert asyncio.run(FileSystemTaskStore(str(tmp_path)).load("root")).revision == 1
  219. def test_cross_process_same_attempt_freeze_writes_one_snapshot(tmp_path):
  220. context = multiprocessing.get_context("spawn")
  221. barrier = context.Barrier(2)
  222. results = context.Queue()
  223. processes = [
  224. context.Process(
  225. target=_freeze_from_process,
  226. args=(str(tmp_path), barrier, results),
  227. )
  228. for _index in range(2)
  229. ]
  230. for process in processes:
  231. process.start()
  232. for process in processes:
  233. process.join(timeout=10)
  234. assert process.exitcode == 0
  235. assert results.get(timeout=1) == results.get(timeout=1)
  236. artifact_files = list((tmp_path / "root" / "orchestration" / "artifacts").glob("*.json"))
  237. assert len(artifact_files) == 1
  238. @pytest.mark.asyncio
  239. async def test_store_rejects_paths_outside_base(tmp_path):
  240. task_store = FileSystemTaskStore(str(tmp_path))
  241. artifact_store = FileSystemArtifactStore(str(tmp_path))
  242. with pytest.raises(ValueError, match="escapes"):
  243. await task_store.load("../escape")
  244. with pytest.raises(ValueError, match="escapes"):
  245. await artifact_store.get("root", "../../escape")
  246. @pytest.mark.asyncio
  247. async def test_store_reports_corrupt_ledger_and_event_journal(tmp_path):
  248. store = FileSystemTaskStore(str(tmp_path))
  249. path = tmp_path / "root" / "orchestration" / "ledger.json"
  250. path.parent.mkdir(parents=True)
  251. path.write_text("{truncated", encoding="utf-8")
  252. with pytest.raises(TaskStoreError, match="Cannot load task ledger"):
  253. await store.load("root")
  254. path.write_text(
  255. json.dumps({"root_trace_id": "root", "mission": "mission", "_events": {}}),
  256. encoding="utf-8",
  257. )
  258. with pytest.raises(TaskStoreError, match="event journal is invalid"):
  259. await store.list_events("root")
  260. with pytest.raises(ValueError, match="between 1 and 1000"):
  261. await store.list_events("root", limit=0)
  262. @pytest.mark.asyncio
  263. async def test_store_wraps_nested_model_and_event_parse_errors(tmp_path):
  264. store = FileSystemTaskStore(str(tmp_path))
  265. path = tmp_path / "root" / "orchestration" / "ledger.json"
  266. path.parent.mkdir(parents=True)
  267. path.write_text(
  268. json.dumps(
  269. {
  270. "root_trace_id": "root",
  271. "mission": "mission",
  272. "tasks": {"broken": {"task_id": "broken", "status": "not-a-status"}},
  273. }
  274. ),
  275. encoding="utf-8",
  276. )
  277. with pytest.raises(TaskStoreError, match="Cannot parse task ledger"):
  278. await store.load("root")
  279. path.write_text(
  280. json.dumps({"root_trace_id": "root", "mission": "mission", "tasks": []}),
  281. encoding="utf-8",
  282. )
  283. with pytest.raises(TaskStoreError, match="Cannot parse task ledger"):
  284. await store.load("root")
  285. path.write_text(
  286. json.dumps(
  287. {
  288. "root_trace_id": "root",
  289. "mission": "mission",
  290. "_events": [{"sequence": 1}],
  291. }
  292. ),
  293. encoding="utf-8",
  294. )
  295. with pytest.raises(TaskStoreError, match="contains an invalid event"):
  296. await store.list_events("root")
  297. @pytest.mark.asyncio
  298. async def test_failed_atomic_write_does_not_advance_caller_ledger(tmp_path, monkeypatch):
  299. store = FileSystemTaskStore(str(tmp_path))
  300. ledger = TaskLedger(root_trace_id="root", mission="mission")
  301. await store.commit(ledger, expected_revision=-1, event=EventDraft("created"))
  302. original_updated_at = ledger.updated_at
  303. ledger.mission = "uncommitted"
  304. def fail_write(_path, _data):
  305. raise OSError("injected replace failure")
  306. monkeypatch.setattr(FileSystemTaskStore, "_atomic_json_write", staticmethod(fail_write))
  307. with pytest.raises(OSError, match="injected replace failure"):
  308. await store.commit(ledger, expected_revision=0, event=EventDraft("updated"))
  309. assert ledger.revision == 0
  310. assert ledger.updated_at == original_updated_at
  311. loaded = await store.load("root")
  312. assert loaded.mission == "mission"
  313. assert [event.event_type for event in (await store.list_events("root")).events] == ["created"]
  314. @pytest.mark.asyncio
  315. async def test_orphan_cleanup_never_trusts_snapshot_id_as_path(tmp_path):
  316. store = FileSystemArtifactStore(str(tmp_path))
  317. orchestration_dir = tmp_path / "root" / "orchestration"
  318. artifacts_dir = orchestration_dir / "artifacts"
  319. artifacts_dir.mkdir(parents=True)
  320. ledger_path = orchestration_dir / "ledger.json"
  321. ledger_path.write_text("sentinel", encoding="utf-8")
  322. (artifacts_dir / "malicious.json").write_text(
  323. json.dumps(
  324. {
  325. "snapshot_id": "../ledger",
  326. "attempt_id": "orphan",
  327. "normalized_content": {},
  328. "sha256": "digest",
  329. "artifact_refs": [],
  330. "evidence_refs": [],
  331. }
  332. ),
  333. encoding="utf-8",
  334. )
  335. assert await store.cleanup_orphans("root", []) == ["../ledger"]
  336. assert ledger_path.read_text(encoding="utf-8") == "sentinel"
  337. assert not (artifacts_dir / "malicious.json").exists()
  338. def test_state_machine_rejects_shortcut_to_completed():
  339. assert transition(TaskStatus.PENDING, TaskStatus.RUNNING) == TaskStatus.RUNNING
  340. with pytest.raises(InvalidTaskTransition):
  341. transition(TaskStatus.RUNNING, TaskStatus.COMPLETED)