test_orchestration_store.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  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 == 2
  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_cursor_reads_mixed_v1_v2_journal(tmp_path):
  174. store = FileSystemTaskStore(str(tmp_path))
  175. ledger = TaskLedger(root_trace_id="mixed", mission="mixed")
  176. await store.commit(
  177. ledger,
  178. expected_revision=-1,
  179. event=EventDraft("legacy_event", {"legacy": True}),
  180. )
  181. path = store.ledger_path("mixed")
  182. raw = json.loads(path.read_text(encoding="utf-8"))
  183. raw["_events"][0]["schema_version"] = 1
  184. path.write_text(json.dumps(raw), encoding="utf-8")
  185. current = await store.load("mixed")
  186. await store.commit(
  187. current,
  188. expected_revision=current.revision,
  189. event=EventDraft("current_event", {"task_id": "task"}),
  190. )
  191. first = await store.list_events("mixed", limit=1)
  192. second = await store.list_events("mixed", cursor=first.next_cursor, limit=1)
  193. assert [first.events[0].schema_version, second.events[0].schema_version] == [1, 2]
  194. assert [first.events[0].sequence, second.events[0].sequence] == [1, 2]
  195. @pytest.mark.asyncio
  196. async def test_event_command_replay_does_not_duplicate_event(tmp_path):
  197. store = FileSystemTaskStore(str(tmp_path))
  198. ledger = TaskLedger(root_trace_id="root", mission="mission")
  199. await store.commit(ledger, -1, event=EventDraft("created", {}, command_id="command-1"))
  200. ledger.mission = "same command replay"
  201. await store.commit(ledger, 0, event=EventDraft("created", {}, command_id="command-1"))
  202. page = await store.list_events("root")
  203. assert len(page.events) == 1
  204. @pytest.mark.asyncio
  205. async def test_two_store_instances_enforce_compare_and_swap(tmp_path):
  206. first_store = FileSystemTaskStore(str(tmp_path))
  207. second_store = FileSystemTaskStore(str(tmp_path))
  208. ledger = TaskLedger(root_trace_id="root", mission="mission")
  209. await first_store.commit(ledger, -1)
  210. first_copy = await first_store.load("root")
  211. stale_copy = deepcopy(first_copy)
  212. first_copy.mission = "winner"
  213. stale_copy.mission = "stale"
  214. await first_store.commit(first_copy, 0)
  215. with pytest.raises(RevisionConflict):
  216. await second_store.commit(stale_copy, 0)
  217. assert (await second_store.load("root")).mission == "winner"
  218. def test_cross_process_lock_allows_only_one_cas_winner(tmp_path):
  219. asyncio.run(
  220. FileSystemTaskStore(str(tmp_path)).commit(
  221. TaskLedger(root_trace_id="root", mission="initial"),
  222. -1,
  223. )
  224. )
  225. context = multiprocessing.get_context("spawn")
  226. barrier = context.Barrier(2)
  227. results = context.Queue()
  228. processes = [
  229. context.Process(
  230. target=_commit_from_process,
  231. args=(str(tmp_path), mission, barrier, results),
  232. )
  233. for mission in ("first", "second")
  234. ]
  235. for process in processes:
  236. process.start()
  237. for process in processes:
  238. process.join(timeout=10)
  239. assert process.exitcode == 0
  240. assert sorted([results.get(timeout=1), results.get(timeout=1)]) == ["committed", "conflict"]
  241. assert asyncio.run(FileSystemTaskStore(str(tmp_path)).load("root")).revision == 1
  242. def test_cross_process_same_attempt_freeze_writes_one_snapshot(tmp_path):
  243. context = multiprocessing.get_context("spawn")
  244. barrier = context.Barrier(2)
  245. results = context.Queue()
  246. processes = [
  247. context.Process(
  248. target=_freeze_from_process,
  249. args=(str(tmp_path), barrier, results),
  250. )
  251. for _index in range(2)
  252. ]
  253. for process in processes:
  254. process.start()
  255. for process in processes:
  256. process.join(timeout=10)
  257. assert process.exitcode == 0
  258. assert results.get(timeout=1) == results.get(timeout=1)
  259. artifact_files = list((tmp_path / "root" / "orchestration" / "artifacts").glob("*.json"))
  260. assert len(artifact_files) == 1
  261. @pytest.mark.asyncio
  262. async def test_store_rejects_paths_outside_base(tmp_path):
  263. task_store = FileSystemTaskStore(str(tmp_path))
  264. artifact_store = FileSystemArtifactStore(str(tmp_path))
  265. with pytest.raises(ValueError, match="escapes"):
  266. await task_store.load("../escape")
  267. with pytest.raises(ValueError, match="escapes"):
  268. await artifact_store.get("root", "../../escape")
  269. @pytest.mark.asyncio
  270. async def test_store_reports_corrupt_ledger_and_event_journal(tmp_path):
  271. store = FileSystemTaskStore(str(tmp_path))
  272. path = tmp_path / "root" / "orchestration" / "ledger.json"
  273. path.parent.mkdir(parents=True)
  274. path.write_text("{truncated", encoding="utf-8")
  275. with pytest.raises(TaskStoreError, match="Cannot load task ledger"):
  276. await store.load("root")
  277. path.write_text(
  278. json.dumps({"root_trace_id": "root", "mission": "mission", "_events": {}}),
  279. encoding="utf-8",
  280. )
  281. with pytest.raises(TaskStoreError, match="event journal is invalid"):
  282. await store.list_events("root")
  283. with pytest.raises(ValueError, match="between 1 and 1000"):
  284. await store.list_events("root", limit=0)
  285. @pytest.mark.asyncio
  286. async def test_store_wraps_nested_model_and_event_parse_errors(tmp_path):
  287. store = FileSystemTaskStore(str(tmp_path))
  288. path = tmp_path / "root" / "orchestration" / "ledger.json"
  289. path.parent.mkdir(parents=True)
  290. path.write_text(
  291. json.dumps(
  292. {
  293. "root_trace_id": "root",
  294. "mission": "mission",
  295. "tasks": {"broken": {"task_id": "broken", "status": "not-a-status"}},
  296. }
  297. ),
  298. encoding="utf-8",
  299. )
  300. with pytest.raises(TaskStoreError, match="Cannot parse task ledger"):
  301. await store.load("root")
  302. path.write_text(
  303. json.dumps({"root_trace_id": "root", "mission": "mission", "tasks": []}),
  304. encoding="utf-8",
  305. )
  306. with pytest.raises(TaskStoreError, match="Cannot parse task ledger"):
  307. await store.load("root")
  308. path.write_text(
  309. json.dumps(
  310. {
  311. "root_trace_id": "root",
  312. "mission": "mission",
  313. "_events": [{"sequence": 1}],
  314. }
  315. ),
  316. encoding="utf-8",
  317. )
  318. with pytest.raises(TaskStoreError, match="contains an invalid event"):
  319. await store.list_events("root")
  320. @pytest.mark.asyncio
  321. async def test_failed_atomic_write_does_not_advance_caller_ledger(tmp_path, monkeypatch):
  322. store = FileSystemTaskStore(str(tmp_path))
  323. ledger = TaskLedger(root_trace_id="root", mission="mission")
  324. await store.commit(ledger, expected_revision=-1, event=EventDraft("created"))
  325. original_updated_at = ledger.updated_at
  326. ledger.mission = "uncommitted"
  327. def fail_write(_path, _data):
  328. raise OSError("injected replace failure")
  329. monkeypatch.setattr(FileSystemTaskStore, "_atomic_json_write", staticmethod(fail_write))
  330. with pytest.raises(OSError, match="injected replace failure"):
  331. await store.commit(ledger, expected_revision=0, event=EventDraft("updated"))
  332. assert ledger.revision == 0
  333. assert ledger.updated_at == original_updated_at
  334. loaded = await store.load("root")
  335. assert loaded.mission == "mission"
  336. assert [event.event_type for event in (await store.list_events("root")).events] == ["created"]
  337. @pytest.mark.asyncio
  338. async def test_orphan_cleanup_never_trusts_snapshot_id_as_path(tmp_path):
  339. store = FileSystemArtifactStore(str(tmp_path))
  340. orchestration_dir = tmp_path / "root" / "orchestration"
  341. artifacts_dir = orchestration_dir / "artifacts"
  342. artifacts_dir.mkdir(parents=True)
  343. ledger_path = orchestration_dir / "ledger.json"
  344. ledger_path.write_text("sentinel", encoding="utf-8")
  345. (artifacts_dir / "malicious.json").write_text(
  346. json.dumps(
  347. {
  348. "snapshot_id": "../ledger",
  349. "attempt_id": "orphan",
  350. "normalized_content": {},
  351. "sha256": "digest",
  352. "artifact_refs": [],
  353. "evidence_refs": [],
  354. }
  355. ),
  356. encoding="utf-8",
  357. )
  358. assert await store.cleanup_orphans("root", []) == ["../ledger"]
  359. assert ledger_path.read_text(encoding="utf-8") == "sentinel"
  360. assert not (artifacts_dir / "malicious.json").exists()
  361. def test_state_machine_rejects_shortcut_to_completed():
  362. assert transition(TaskStatus.PENDING, TaskStatus.RUNNING) == TaskStatus.RUNNING
  363. with pytest.raises(InvalidTaskTransition):
  364. transition(TaskStatus.RUNNING, TaskStatus.COMPLETED)