Просмотр исходного кода

编排:隔离批量任务的预留与执行异常

dispatch_tasks 按输入索引收集每个 Task 的独立结果,TaskConflict、预留异常和运行期异常不再中断同批任务。Ledger 提交后的事件与 Goal 投影改为非破坏性处理,并修复预留失败误伤旧 running Attempt 的竞态。增加 sibling 隔离、顺序保持、投影失败和事件失败回归测试。
SamLee 4 дней назад
Родитель
Сommit
aa85ab61f8
2 измененных файлов с 328 добавлено и 13 удалено
  1. 156 9
      agent/agent/orchestration/coordinator.py
  2. 172 4
      agent/tests/test_coordinator_integration.py

+ 156 - 9
agent/agent/orchestration/coordinator.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
+import logging
 from dataclasses import asdict
 from dataclasses import asdict
 from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
 from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
 from uuid import uuid4
 from uuid import uuid4
@@ -43,6 +44,9 @@ from .state_machine import transition
 from .store import TaskStoreNotFound
 from .store import TaskStoreNotFound
 
 
 
 
+logger = logging.getLogger(__name__)
+
+
 TERMINAL_TASK_STATUSES = {
 TERMINAL_TASK_STATUSES = {
     TaskStatus.COMPLETED,
     TaskStatus.COMPLETED,
     TaskStatus.CANCELLED,
     TaskStatus.CANCELLED,
@@ -132,7 +136,19 @@ class TaskCoordinator:
 
 
     async def _emit(self, root_trace_id: str, event_type: str, payload: Dict[str, Any]) -> None:
     async def _emit(self, root_trace_id: str, event_type: str, payload: Dict[str, Any]) -> None:
         if self.event_sink:
         if self.event_sink:
-            await self.event_sink.emit(root_trace_id, event_type, payload)
+            try:
+                await self.event_sink.emit(root_trace_id, event_type, payload)
+            except Exception as exc:
+                # Events are an observational projection.  Once the Ledger
+                # commit succeeds, an EventSink outage must not make callers
+                # believe the state mutation failed or retry it blindly.
+                logger.warning(
+                    "Orchestration event emission failed after Ledger commit "
+                    "(%s, %s): %s",
+                    root_trace_id,
+                    event_type,
+                    exc,
+                )
 
 
     async def create_tasks(
     async def create_tasks(
         self,
         self,
@@ -259,25 +275,146 @@ class TaskCoordinator:
 
 
         # Reserve every task before starting any worker so concurrent duplicate
         # Reserve every task before starting any worker so concurrent duplicate
         # dispatches fail deterministically.
         # dispatches fail deterministically.
-        reservations: List[Tuple[str, str]] = []
+        reservations: List[Tuple[int, str, str]] = []
+        early_results: Dict[int, TaskCycleResult] = {}
         for index, (task_id, preset) in enumerate(zip(task_ids, presets)):
         for index, (task_id, preset) in enumerate(zip(task_ids, presets)):
             key = f"{idempotency_key}:{index}" if idempotency_key else None
             key = f"{idempotency_key}:{index}" if idempotency_key else None
-            attempt = await self._create_attempt(root_trace_id, task_id, preset, key)
-            reservations.append((task_id, attempt["attempt_id"]))
+            try:
+                attempt = await self._create_attempt(root_trace_id, task_id, preset, key)
+                reservations.append((index, task_id, attempt["attempt_id"]))
+            except TaskConflict as exc:
+                # A conflict belongs to this item only; previously reserved
+                # tasks must still run and later items must still be attempted.
+                early_results[index] = await self._cycle_error_result(
+                    root_trace_id, task_id, None, f"Dispatch conflict: {exc}"
+                )
+            except Exception as exc:
+                # _create_attempt contains all post-commit adapter failures.
+                # Therefore an exception here occurred before a new Attempt
+                # was safely returned.  Never guess by "latest Attempt": that
+                # could corrupt a pre-existing run owned by another dispatch.
+                attempt_id = None
+                early_results[index] = await self._cycle_error_result(
+                    root_trace_id, task_id, attempt_id,
+                    f"Attempt reservation failed: {type(exc).__name__}: {exc}",
+                )
 
 
         semaphore = asyncio.Semaphore(self.config.max_parallel_tasks)
         semaphore = asyncio.Semaphore(self.config.max_parallel_tasks)
 
 
-        async def run_one(task_id: str, attempt_id: str) -> TaskCycleResult:
+        async def run_one(index: int, task_id: str, attempt_id: str) -> Tuple[int, TaskCycleResult]:
             async with semaphore:
             async with semaphore:
-                return await self._run_task_cycle(root_trace_id, task_id, attempt_id)
-
-        results = list(await asyncio.gather(*(run_one(t, a) for t, a in reservations)))
+                try:
+                    result = await self._run_task_cycle(root_trace_id, task_id, attempt_id)
+                except Exception as exc:
+                    error = f"Task cycle failed: {type(exc).__name__}: {exc}"
+                    await self._recover_cycle_failure(
+                        root_trace_id, task_id, attempt_id, error
+                    )
+                    result = await self._cycle_error_result(
+                        root_trace_id, task_id, attempt_id, error
+                    )
+                return index, result
+
+        completed = await asyncio.gather(
+            *(run_one(index, task_id, attempt_id) for index, task_id, attempt_id in reservations)
+        )
+        indexed_results = dict(early_results)
+        indexed_results.update(completed)
+        results = [indexed_results[index] for index in range(len(task_ids))]
         if idempotency_key:
         if idempotency_key:
             def remember(ledger: TaskLedger) -> Dict[str, Any]:
             def remember(ledger: TaskLedger) -> Dict[str, Any]:
                 return {"results": [x.to_dict() for x in results]}
                 return {"results": [x.to_dict() for x in results]}
             await self._mutate(root_trace_id, "dispatch_completed", remember, idempotency_key)
             await self._mutate(root_trace_id, "dispatch_completed", remember, idempotency_key)
         return results
         return results
 
 
+    async def _recover_cycle_failure(
+        self,
+        root_trace_id: str,
+        task_id: str,
+        attempt_id: str,
+        error: str,
+    ) -> None:
+        """Best-effort normalization for an unexpected per-task failure."""
+
+        def mutate(ledger: TaskLedger) -> Dict[str, Any]:
+            task = _task(ledger, task_id)
+            attempt = ledger.attempts.get(attempt_id)
+            if attempt and attempt.status == AttemptStatus.RUNNING:
+                attempt.status = AttemptStatus.FAILED
+                attempt.error = error
+                attempt.updated_at = utc_now()
+
+            validation_id = None
+            for candidate_id in reversed(task.validation_ids):
+                candidate = ledger.validations[candidate_id]
+                if candidate.attempt_id == attempt_id:
+                    validation_id = candidate_id
+                    if candidate.status in {
+                        ValidationRunStatus.PENDING,
+                        ValidationRunStatus.RUNNING,
+                    }:
+                        candidate.status = ValidationRunStatus.ERROR
+                        candidate.error = error
+                        candidate.updated_at = utc_now()
+                    break
+
+            if task.status in {
+                TaskStatus.RUNNING,
+                TaskStatus.AWAITING_VALIDATION,
+                TaskStatus.VALIDATING,
+            }:
+                transition(task.status, TaskStatus.NEEDS_REPLAN)
+                task.status = TaskStatus.NEEDS_REPLAN
+                task.updated_at = utc_now()
+            return {
+                "task_id": task_id,
+                "attempt_id": attempt_id,
+                "validation_id": validation_id,
+                "error": error,
+            }
+
+        try:
+            await self._mutate(root_trace_id, "task_cycle_failed", mutate)
+            await self.project_goal_state(root_trace_id, task_id)
+        except Exception:
+            # The original adapter failure is the useful branch result.  A
+            # persistent Store outage cannot be repaired in-process (V2), but
+            # must not cancel sibling Tasks in this batch.
+            return
+
+    async def _cycle_error_result(
+        self,
+        root_trace_id: str,
+        task_id: str,
+        attempt_id: Optional[str],
+        error: str,
+    ) -> TaskCycleResult:
+        try:
+            ledger = await self.task_store.load(root_trace_id)
+            task = ledger.tasks.get(task_id)
+            status = task.status if task else TaskStatus.NEEDS_REPLAN
+            validation_id = None
+            validation = None
+            if task and attempt_id:
+                for candidate_id in reversed(task.validation_ids):
+                    candidate = ledger.validations[candidate_id]
+                    if candidate.attempt_id == attempt_id:
+                        validation_id = candidate_id
+                        validation = candidate
+                        break
+        except Exception:
+            status = TaskStatus.NEEDS_REPLAN
+            validation_id = None
+            validation = None
+        return TaskCycleResult(
+            task_id=task_id,
+            task_status=status,
+            attempt_id=attempt_id,
+            validation_id=validation_id,
+            validation=validation,
+            error=error,
+        )
+
     async def _create_attempt(
     async def _create_attempt(
         self,
         self,
         root_trace_id: str,
         root_trace_id: str,
@@ -328,7 +465,17 @@ class TaskCoordinator:
             }
             }
 
 
         result = await self._mutate(root_trace_id, "attempt_created", mutate, idempotency_key)
         result = await self._mutate(root_trace_id, "attempt_created", mutate, idempotency_key)
-        await self.project_goal_state(root_trace_id, task_id)
+        try:
+            await self.project_goal_state(root_trace_id, task_id)
+        except Exception as exc:
+            # GoalTree is a compatibility projection.  The committed Attempt
+            # remains authoritative and can execute; reconcile_goal_tree can
+            # repair the projection later.
+            logger.warning(
+                "Goal projection failed after Attempt %s was committed: %s",
+                result["attempt_id"],
+                exc,
+            )
         return result
         return result
 
 
     async def _run_task_cycle(
     async def _run_task_cycle(

+ 172 - 4
agent/tests/test_coordinator_integration.py

@@ -81,14 +81,20 @@ class FakeExecutor:
         return ValidatorRunResult(context["validator_trace_id"], "completed")
         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.create_trace(Trace(trace_id="root", mode="agent", task="mission", agent_role="planner"))
     await trace_store.update_goal_tree("root", GoalTree(mission="mission"))
     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(
     coordinator = TaskCoordinator(
         task_store,
         task_store,
-        FileSystemArtifactStore(str(tmp_path)),
+        artifact_store or FileSystemArtifactStore(str(tmp_path)),
         trace_store,
         trace_store,
         OrchestrationConfig(max_parallel_tasks=4),
         OrchestrationConfig(max_parallel_tasks=4),
         TraceEventSink(str(tmp_path)),
         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 worker.agent_role == "worker" and worker.result_summary
     assert validator.agent_role == "validator" and validator.result_summary
     assert validator.agent_role == "validator" and validator.result_summary
     assert worker.trace_id != validator.trace_id
     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