|
@@ -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(
|