|
|
@@ -81,14 +81,20 @@ class FakeExecutor:
|
|
|
return ValidatorRunResult(context["validator_trace_id"], "completed")
|
|
|
|
|
|
|
|
|
-async def make_coordinator(tmp_path, executor):
|
|
|
- trace_store = FileSystemTraceStore(str(tmp_path))
|
|
|
+async def make_coordinator(
|
|
|
+ tmp_path,
|
|
|
+ executor,
|
|
|
+ artifact_store=None,
|
|
|
+ trace_store=None,
|
|
|
+ task_store=None,
|
|
|
+):
|
|
|
+ trace_store = trace_store or FileSystemTraceStore(str(tmp_path))
|
|
|
await trace_store.create_trace(Trace(trace_id="root", mode="agent", task="mission", agent_role="planner"))
|
|
|
await trace_store.update_goal_tree("root", GoalTree(mission="mission"))
|
|
|
- task_store = FileSystemTaskStore(str(tmp_path))
|
|
|
+ task_store = task_store or FileSystemTaskStore(str(tmp_path))
|
|
|
coordinator = TaskCoordinator(
|
|
|
task_store,
|
|
|
- FileSystemArtifactStore(str(tmp_path)),
|
|
|
+ artifact_store or FileSystemArtifactStore(str(tmp_path)),
|
|
|
trace_store,
|
|
|
OrchestrationConfig(max_parallel_tasks=4),
|
|
|
TraceEventSink(str(tmp_path)),
|
|
|
@@ -399,3 +405,165 @@ async def test_real_runner_local_executor_creates_independent_terminal_traces(tm
|
|
|
assert worker.agent_role == "worker" and worker.result_summary
|
|
|
assert validator.agent_role == "validator" and validator.result_summary
|
|
|
assert worker.trace_id != validator.trace_id
|
|
|
+
|
|
|
+
|
|
|
+class OneShotGetFailureArtifactStore(FileSystemArtifactStore):
|
|
|
+ def __init__(self, base_path):
|
|
|
+ super().__init__(base_path)
|
|
|
+ self.get_calls = 0
|
|
|
+
|
|
|
+ def for_root(self, root_trace_id):
|
|
|
+ self.root_trace_id = root_trace_id
|
|
|
+ return self
|
|
|
+
|
|
|
+ async def get(self, snapshot_id):
|
|
|
+ self.get_calls += 1
|
|
|
+ if self.get_calls == 2:
|
|
|
+ raise RuntimeError("injected artifact read failure")
|
|
|
+ return await super().get(snapshot_id)
|
|
|
+
|
|
|
+
|
|
|
+class OneShotGoalProjectionFailureStore(FileSystemTraceStore):
|
|
|
+ def __init__(self, base_path):
|
|
|
+ super().__init__(base_path)
|
|
|
+ self.fail_next_goal_update = True
|
|
|
+
|
|
|
+ async def update_goal(self, trace_id, goal_id, cascade_completion=True, **updates):
|
|
|
+ if self.fail_next_goal_update:
|
|
|
+ self.fail_next_goal_update = False
|
|
|
+ raise RuntimeError("injected goal projection failure")
|
|
|
+ return await super().update_goal(
|
|
|
+ trace_id,
|
|
|
+ goal_id,
|
|
|
+ cascade_completion=cascade_completion,
|
|
|
+ **updates,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class OneShotLoadFailureTaskStore(FileSystemTaskStore):
|
|
|
+ def __init__(self, base_path):
|
|
|
+ super().__init__(base_path)
|
|
|
+ self.fail_next_load = False
|
|
|
+
|
|
|
+ async def load(self, root_trace_id):
|
|
|
+ if self.fail_next_load:
|
|
|
+ self.fail_next_load = False
|
|
|
+ raise RuntimeError("injected reservation load failure")
|
|
|
+ return await super().load(root_trace_id)
|
|
|
+
|
|
|
+
|
|
|
+class FailingEventSink:
|
|
|
+ async def emit(self, root_trace_id, event_type, payload):
|
|
|
+ raise RuntimeError("injected event sink failure")
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_parallel_unexpected_branch_error_does_not_cancel_siblings(tmp_path):
|
|
|
+ executor = FakeExecutor([ValidationVerdict.PASSED] * 4, delay=0.01)
|
|
|
+ artifact_store = OneShotGetFailureArtifactStore(str(tmp_path))
|
|
|
+ coordinator, store, _ = await make_coordinator(
|
|
|
+ tmp_path, executor, artifact_store=artifact_store
|
|
|
+ )
|
|
|
+ task_ids = [await create_task(coordinator, f"isolated-{index}") for index in range(4)]
|
|
|
+ results = await coordinator.dispatch_tasks("root", task_ids)
|
|
|
+
|
|
|
+ assert [result.task_id for result in results] == task_ids
|
|
|
+ failures = [result for result in results if result.error]
|
|
|
+ assert len(failures) == 1
|
|
|
+ assert "artifact read failure" in failures[0].error
|
|
|
+ assert failures[0].task_status == TaskStatus.NEEDS_REPLAN
|
|
|
+ successes = [result for result in results if not result.error]
|
|
|
+ assert len(successes) == 3
|
|
|
+ assert all(result.task_status == TaskStatus.AWAITING_DECISION for result in successes)
|
|
|
+ ledger = await store.load("root")
|
|
|
+ assert all(
|
|
|
+ ledger.tasks[task_id].status not in {TaskStatus.RUNNING, TaskStatus.VALIDATING}
|
|
|
+ for task_id in task_ids
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_batch_reservation_conflict_does_not_strand_other_tasks(tmp_path):
|
|
|
+ executor = FakeExecutor([ValidationVerdict.PASSED, ValidationVerdict.PASSED])
|
|
|
+ coordinator, store, _ = await make_coordinator(tmp_path, executor)
|
|
|
+ task_ids = [await create_task(coordinator, f"reserve-{index}") for index in range(3)]
|
|
|
+ existing = await coordinator._create_attempt(
|
|
|
+ "root", task_ids[1], "worker", "existing-reservation"
|
|
|
+ )
|
|
|
+
|
|
|
+ results = await coordinator.dispatch_tasks("root", task_ids)
|
|
|
+ assert [result.task_id for result in results] == task_ids
|
|
|
+ assert results[0].task_status == TaskStatus.AWAITING_DECISION
|
|
|
+ assert results[1].attempt_id is None
|
|
|
+ assert "Dispatch conflict" in results[1].error
|
|
|
+ assert results[2].task_status == TaskStatus.AWAITING_DECISION
|
|
|
+
|
|
|
+ ledger = await store.load("root")
|
|
|
+ assert ledger.tasks[task_ids[0]].status == TaskStatus.AWAITING_DECISION
|
|
|
+ assert ledger.tasks[task_ids[2]].status == TaskStatus.AWAITING_DECISION
|
|
|
+ assert ledger.attempts[existing["attempt_id"]].status.value == "running"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_goal_projection_failure_does_not_abort_committed_attempt(tmp_path):
|
|
|
+ executor = FakeExecutor([ValidationVerdict.PASSED])
|
|
|
+ trace_store = OneShotGoalProjectionFailureStore(str(tmp_path))
|
|
|
+ coordinator, store, _ = await make_coordinator(
|
|
|
+ tmp_path,
|
|
|
+ executor,
|
|
|
+ trace_store=trace_store,
|
|
|
+ )
|
|
|
+ task_id = await create_task(coordinator, "reservation-projection-failure")
|
|
|
+
|
|
|
+ result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
|
|
|
+
|
|
|
+ assert result.task_id == task_id
|
|
|
+ assert result.task_status == TaskStatus.AWAITING_DECISION
|
|
|
+ assert result.attempt_id is not None
|
|
|
+ assert result.error is None
|
|
|
+ ledger = await store.load("root")
|
|
|
+ assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
|
|
|
+ assert ledger.attempts[result.attempt_id].status.value == "submitted"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_reservation_load_failure_does_not_corrupt_existing_attempt(tmp_path):
|
|
|
+ executor = FakeExecutor([ValidationVerdict.PASSED])
|
|
|
+ task_store = OneShotLoadFailureTaskStore(str(tmp_path))
|
|
|
+ coordinator, store, _ = await make_coordinator(
|
|
|
+ tmp_path,
|
|
|
+ executor,
|
|
|
+ task_store=task_store,
|
|
|
+ )
|
|
|
+ running_task = await create_task(coordinator, "already-running")
|
|
|
+ existing = await coordinator._create_attempt(
|
|
|
+ "root", running_task, "worker", "existing-running-attempt"
|
|
|
+ )
|
|
|
+ sibling_task = await create_task(coordinator, "unrelated-sibling")
|
|
|
+
|
|
|
+ task_store.fail_next_load = True
|
|
|
+ results = await coordinator.dispatch_tasks(
|
|
|
+ "root",
|
|
|
+ [running_task, sibling_task],
|
|
|
+ )
|
|
|
+
|
|
|
+ assert [result.task_id for result in results] == [running_task, sibling_task]
|
|
|
+ assert results[0].attempt_id is None
|
|
|
+ assert results[0].task_status == TaskStatus.RUNNING
|
|
|
+ assert "reservation load failure" in results[0].error
|
|
|
+ assert results[1].task_status == TaskStatus.AWAITING_DECISION
|
|
|
+ ledger = await store.load("root")
|
|
|
+ assert ledger.tasks[running_task].status == TaskStatus.RUNNING
|
|
|
+ assert ledger.attempts[existing["attempt_id"]].status.value == "running"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_event_sink_failure_does_not_rollback_committed_ledger(tmp_path):
|
|
|
+ executor = FakeExecutor([])
|
|
|
+ coordinator, store, _ = await make_coordinator(tmp_path, executor)
|
|
|
+ coordinator.event_sink = FailingEventSink()
|
|
|
+
|
|
|
+ task_id = await create_task(coordinator, "event-outage")
|
|
|
+
|
|
|
+ ledger = await store.load("root")
|
|
|
+ assert ledger.tasks[task_id].status == TaskStatus.PENDING
|