Selaa lähdekoodia

feat(orchestration): add durable operation control

SamLee 1 päivä sitten
vanhempi
commit
0f57f262c7

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 711 - 154
agent/agent/orchestration/coordinator.py


+ 12 - 0
agent/agent/orchestration/errors.py

@@ -0,0 +1,12 @@
+"""Shared orchestration domain errors."""
+
+
+class OrchestrationError(RuntimeError):
+    pass
+
+
+class TaskConflict(OrchestrationError):
+    pass
+
+
+__all__ = ["OrchestrationError", "TaskConflict"]

+ 184 - 64
agent/agent/orchestration/executor.py

@@ -3,12 +3,13 @@
 from __future__ import annotations
 
 import json
-from typing import Any, Dict
+from math import isfinite
+from typing import Any, Dict, Iterable, Optional, Tuple
 
 from agent.core.runner import RunConfig
 from agent.tools.builtin.knowledge import KnowledgeConfig
 
-from .models import CompletionPolicy
+from .models import CompletionPolicy, ExecutionStats, FailureCode
 from .protocols import ValidatorRunResult, WorkerRunResult
 
 
@@ -18,6 +19,10 @@ class LocalAgentExecutor:
     def __init__(self, runner: Any) -> None:
         self.runner = runner
 
+    async def stop(self, trace_id: str) -> bool:
+        """Request cooperative cancellation of a local sub-trace."""
+        return bool(await self.runner.stop(trace_id))
+
     async def run_worker(self, context: Dict[str, Any]) -> WorkerRunResult:
         preset = context["worker_preset"]
         if str(preset).startswith("remote_"):
@@ -25,61 +30,42 @@ class LocalAgentExecutor:
                 trace_id=context["worker_trace_id"],
                 status="failed",
                 error="V1 explicit_validation supports local Sub-Traces only",
+                execution_stats=ExecutionStats(failure_code=FailureCode.UNSUPPORTED),
             )
-        protected = {
-            "root_trace_id": context["root_trace_id"],
-            "task_id": context["task_id"],
-            "spec_version": context["spec_version"],
-            "attempt_id": context["attempt_id"],
-            "completion_policy": CompletionPolicy.EXPLICIT_VALIDATION.value,
-        }
+        protected = self._protected_context(
+            context,
+            ("root_trace_id", "task_id", "spec_version", "attempt_id"),
+        )
         continue_trace = context.get("continue_trace_id")
+        usage_baseline: Tuple[int, float] = (0, 0.0)
         if continue_trace:
             await self.runner.task_coordinator.validate_continue_from(
                 context["root_trace_id"], context["task_id"],
                 self._prior_attempt_id(context),
             )
             trace = await self.runner.trace_store.get_trace(continue_trace)
+            usage_baseline = _trace_usage(trace)
             trace_context = dict(trace.context or {})
             trace_context.update(protected)
             await self.runner.trace_store.update_trace(continue_trace, context=trace_context)
 
-        config = RunConfig(
-            agent_type=preset,
-            completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
-            trace_id=continue_trace,
-            new_trace_id=None if continue_trace else context["worker_trace_id"],
-            parent_trace_id=context["root_trace_id"],
-            tools=None,
-            tool_groups=None,
-            parallel_tool_execution=False,
-            enable_memory=False,
-            enable_research_flow=False,
-            knowledge=_disabled_knowledge(),
-            context=protected,
-            name=f"Worker {context['task_id']}",
-        )
         prompt = {
             "task_spec": context["task_spec"],
             "attempt_id": context["attempt_id"],
             "repair_feedback": context.get("repair_feedback"),
             "instruction": "Execute this TaskSpec and finish with submit_attempt.",
         }
-        try:
-            result = await self.runner.run_result(
-                messages=[{"role": "user", "content": json.dumps(prompt, ensure_ascii=False, indent=2)}],
-                config=config,
-            )
-            return WorkerRunResult(
-                trace_id=result.get("trace_id") or context["worker_trace_id"],
-                status=result.get("status", "failed"),
-                summary=result.get("summary", ""),
-                error=result.get("error"),
-            )
-        except Exception as exc:
-            return WorkerRunResult(
-                trace_id=context["worker_trace_id"], status="failed", error=str(exc)
-            )
+        result = await self._run_role(
+            context,
+            trace_key="worker_trace_id",
+            preset=preset,
+            protected=protected,
+            prompt=prompt,
+            name=f"Worker {context['task_id']}",
+            continue_trace_id=continue_trace,
+            usage_baseline=usage_baseline,
+        )
+        return WorkerRunResult(**result)
 
     async def run_validator(self, context: Dict[str, Any]) -> ValidatorRunResult:
         preset = context["validator_preset"]
@@ -88,20 +74,54 @@ class LocalAgentExecutor:
                 trace_id=context["validator_trace_id"],
                 status="failed",
                 error="V1 explicit_validation supports local Validator Sub-Traces only",
+                execution_stats=ExecutionStats(failure_code=FailureCode.UNSUPPORTED),
             )
-        protected = {
-            "root_trace_id": context["root_trace_id"],
-            "task_id": context["task_id"],
-            "spec_version": context["spec_version"],
+        protected = self._protected_context(
+            context,
+            (
+                "root_trace_id",
+                "task_id",
+                "spec_version",
+                "attempt_id",
+                "snapshot_id",
+                "validation_id",
+            ),
+        )
+        prompt = {
+            "task_spec": context["task_spec"],
             "attempt_id": context["attempt_id"],
-            "snapshot_id": context["snapshot_id"],
+            "artifact_snapshot": context["artifact_snapshot"],
             "validation_id": context["validation_id"],
-            "completion_policy": CompletionPolicy.EXPLICIT_VALIDATION.value,
+            "instruction": "Independently verify every criterion and finish with submit_validation.",
         }
+        result = await self._run_role(
+            context,
+            trace_key="validator_trace_id",
+            preset=preset,
+            protected=protected,
+            prompt=prompt,
+            name=f"Validator {context['task_id']}",
+        )
+        return ValidatorRunResult(**result)
+
+    async def _run_role(
+        self,
+        context: Dict[str, Any],
+        *,
+        trace_key: str,
+        preset: str,
+        protected: Dict[str, Any],
+        prompt: Dict[str, Any],
+        name: str,
+        continue_trace_id: Optional[str] = None,
+        usage_baseline: Tuple[int, float] = (0, 0.0),
+    ) -> Dict[str, Any]:
+        trace_id = context[trace_key]
         config = RunConfig(
             agent_type=preset,
             completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
-            new_trace_id=context["validator_trace_id"],
+            trace_id=continue_trace_id,
+            new_trace_id=None if continue_trace_id else trace_id,
             parent_trace_id=context["root_trace_id"],
             tools=None,
             tool_groups=None,
@@ -110,30 +130,65 @@ class LocalAgentExecutor:
             enable_research_flow=False,
             knowledge=_disabled_knowledge(),
             context=protected,
-            name=f"Validator {context['task_id']}",
+            name=name,
         )
-        prompt = {
-            "task_spec": context["task_spec"],
-            "attempt_id": context["attempt_id"],
-            "artifact_snapshot": context["artifact_snapshot"],
-            "validation_id": context["validation_id"],
-            "instruction": "Independently verify every criterion and finish with submit_validation.",
-        }
         try:
             result = await self.runner.run_result(
-                messages=[{"role": "user", "content": json.dumps(prompt, ensure_ascii=False, indent=2)}],
+                messages=[{
+                    "role": "user",
+                    "content": json.dumps(prompt, ensure_ascii=False, indent=2),
+                }],
                 config=config,
             )
-            return ValidatorRunResult(
-                trace_id=result.get("trace_id") or context["validator_trace_id"],
-                status=result.get("status", "failed"),
-                summary=result.get("summary", ""),
-                error=result.get("error"),
-            )
+            result_trace_id = result.get("trace_id") or trace_id
+            trace = await self._get_trace(result_trace_id)
+            return {
+                "trace_id": result_trace_id,
+                "status": result.get("status", "failed"),
+                "summary": result.get("summary", ""),
+                "error": result.get("error"),
+                "execution_stats": _execution_stats(
+                    result,
+                    trace,
+                    usage_baseline,
+                    config.model,
+                    result.get("status", "failed"),
+                ),
+            }
         except Exception as exc:
-            return ValidatorRunResult(
-                trace_id=context["validator_trace_id"], status="failed", error=str(exc)
-            )
+            trace = await self._get_trace(trace_id)
+            return {
+                "trace_id": trace_id,
+                "status": "failed",
+                "error": str(exc),
+                "execution_stats": _execution_stats(
+                    {},
+                    trace,
+                    usage_baseline,
+                    config.model,
+                    "executor_error",
+                ),
+            }
+
+    async def _get_trace(self, trace_id: str) -> Any:
+        trace_store = getattr(self.runner, "trace_store", None)
+        if trace_store is None:
+            return None
+        try:
+            return await trace_store.get_trace(trace_id)
+        except Exception:
+            return None
+
+    @staticmethod
+    def _protected_context(
+        context: Dict[str, Any], required_keys: Iterable[str]
+    ) -> Dict[str, Any]:
+        protected = {key: context[key] for key in required_keys}
+        protected["completion_policy"] = CompletionPolicy.EXPLICIT_VALIDATION.value
+        for key in ("operation_id", "execution_epoch", "deadline"):
+            if key in context:
+                protected[key] = context[key]
+        return protected
 
     def _prior_attempt_id(self, context: Dict[str, Any]) -> str:
         """Derive the previous attempt; callers cannot provide arbitrary trace IDs."""
@@ -153,4 +208,69 @@ def _disabled_knowledge() -> KnowledgeConfig:
     )
 
 
+def _trace_usage(trace: Any) -> Tuple[int, float]:
+    if trace is None:
+        return 0, 0.0
+    tokens = _nonnegative_int(getattr(trace, "total_tokens", 0))
+    cost = _nonnegative_float(getattr(trace, "total_cost", 0.0))
+    return tokens or 0, cost or 0.0
+
+
+def _execution_stats(
+    result: Dict[str, Any],
+    trace: Any,
+    baseline: Tuple[int, float],
+    fallback_model: str,
+    run_status: str,
+) -> ExecutionStats:
+    raw = result.get("stats")
+    raw = raw if isinstance(raw, dict) else {}
+    trace_tokens, trace_cost = _trace_usage(trace)
+    cumulative_tokens = raw.get("total_tokens", trace_tokens if trace is not None else None)
+    cumulative_cost = raw.get("total_cost", trace_cost if trace is not None else None)
+    parsed_tokens = _nonnegative_int(cumulative_tokens)
+    parsed_cost = _nonnegative_float(cumulative_cost)
+    total_tokens = max(0, parsed_tokens - baseline[0]) if parsed_tokens is not None else None
+    total_cost = max(0.0, parsed_cost - baseline[1]) if parsed_cost is not None else None
+    primary_model = (
+        raw.get("primary_model")
+        or raw.get("model")
+        or getattr(trace, "model", None)
+        or fallback_model
+    )
+    failure_code = {
+        "executor_error": FailureCode.EXECUTOR_ERROR,
+        "failed": FailureCode.AGENT_FAILED,
+        "error": FailureCode.AGENT_FAILED,
+        "expired": FailureCode.TIMEOUT,
+        "stopped": FailureCode.STOPPED,
+    }.get(str(run_status))
+    return ExecutionStats(
+        primary_model=str(primary_model) if primary_model is not None else None,
+        total_tokens=total_tokens,
+        total_cost=total_cost,
+        failure_code=failure_code,
+    )
+
+
+def _nonnegative_int(value: Any) -> Optional[int]:
+    if value is None or isinstance(value, bool):
+        return None
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError, OverflowError):
+        return None
+    return parsed if parsed >= 0 else None
+
+
+def _nonnegative_float(value: Any) -> Optional[float]:
+    if value is None or isinstance(value, bool):
+        return None
+    try:
+        parsed = float(value)
+    except (TypeError, ValueError, OverflowError):
+        return None
+    return parsed if isfinite(parsed) and parsed >= 0 else None
+
+
 __all__ = ["LocalAgentExecutor"]

+ 796 - 0
agent/agent/orchestration/operations.py

@@ -0,0 +1,796 @@
+"""Durable background-operation lifecycle control.
+
+The controller owns process-local scheduling and operation-level state only.
+Task/Attempt/Validation execution remains in :mod:`coordinator` and is reached
+through the injected ``execute_claimed`` callback.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import replace
+from datetime import datetime, timezone
+from functools import partial
+from typing import Any, Awaitable, Callable, Dict, List, Optional, Protocol, Sequence
+
+from .config import OrchestrationConfig
+from .errors import TaskConflict
+from .models import (
+    AttemptStatus,
+    BackgroundOperation,
+    ExecutionStats,
+    FailureCode,
+    OperationKind,
+    OperationStatus,
+    TaskCycleResult,
+    TaskLedger,
+    TaskStatus,
+    TaskAttempt,
+    ValidationReport,
+    ValidationRunStatus,
+    new_id,
+    utc_now,
+)
+from .protocols import AgentExecutor, TaskStore
+from .runtime import OperationRuntime
+
+
+class Mutate(Protocol):
+    async def __call__(
+        self,
+        root_trace_id: str,
+        event_type: str,
+        mutator: Callable[[TaskLedger], Dict[str, Any]],
+        idempotency_key: Optional[str] = None,
+        *,
+        idempotency_payload: Optional[Any] = None,
+        operation_id: Optional[str] = None,
+    ) -> Dict[str, Any]: ...
+
+
+ExecuteClaimed = Callable[
+    [BackgroundOperation],
+    Awaitable[Sequence[TaskCycleResult]],
+]
+Fingerprint = Callable[[str, Any], str]
+
+
+class OperationController:
+    """Own BackgroundOperation persistence, recovery, stop and scheduling."""
+
+    def __init__(
+        self,
+        task_store: TaskStore,
+        mutate: Mutate,
+        execute_claimed: ExecuteClaimed,
+        get_config: Callable[[], OrchestrationConfig],
+        get_executor: Callable[[], Optional[AgentExecutor]],
+        fingerprint: Fingerprint,
+    ) -> None:
+        self.task_store = task_store
+        self._mutate = mutate
+        self._execute_claimed = execute_claimed
+        self._get_config = get_config
+        self._get_executor = get_executor
+        self._fingerprint = fingerprint
+        self.runtime = OperationRuntime()
+
+    async def start(
+        self,
+        root_trace_id: str,
+        kind: OperationKind | str,
+        *,
+        task_ids: Optional[Sequence[str]] = None,
+        worker_presets: Optional[Sequence[str]] = None,
+        task_id: Optional[str] = None,
+        attempt_id: Optional[str] = None,
+        idempotency_key: Optional[str] = None,
+        deadline_at: Optional[str] = None,
+    ) -> BackgroundOperation:
+        if not self._get_executor():
+            raise RuntimeError("TaskCoordinator has no AgentExecutor")
+        operation_kind, requested_task_ids, presets, request = _normalize_start_request(
+            kind,
+            task_ids,
+            task_id,
+            worker_presets,
+            attempt_id,
+            deadline_at,
+            self._get_config().default_worker_preset,
+        )
+        operation_id = new_id()
+        fingerprint = self._fingerprint("start_operation", request)
+        command_key = (
+            f"operation.{operation_kind.value}:{idempotency_key}"
+            if idempotency_key
+            else None
+        )
+        try:
+            result = await self._mutate(
+                root_trace_id,
+                "operation_started",
+                partial(
+                    _create_operation,
+                    operation_id=operation_id,
+                    root_trace_id=root_trace_id,
+                    operation_kind=operation_kind,
+                    request=request,
+                    fingerprint=fingerprint,
+                    task_ids=requested_task_ids,
+                ),
+                command_key,
+                idempotency_payload=request,
+                operation_id=operation_id,
+            )
+        except TaskConflict:
+            if not command_key:
+                raise
+            await self._check_start_conflict(
+                root_trace_id,
+                command_key,
+                requested_task_ids,
+                presets,
+            )
+            raise
+
+        operation = await self.get(root_trace_id, result["operation_id"])
+        self.schedule(operation)
+        return operation
+
+    async def _check_start_conflict(
+        self,
+        root_trace_id: str,
+        command_key: str,
+        task_ids: Sequence[str],
+        presets: Sequence[str],
+    ) -> None:
+        ledger = await self.task_store.load(root_trace_id)
+        command = ledger.command_records.get(f"{root_trace_id}:{command_key}")
+        prior = ledger.operations.get(command.operation_id or "") if command else None
+        if not prior and command:
+            prior_id = command.result_ref.get("operation_id")
+            prior = ledger.operations.get(str(prior_id)) if prior_id else None
+        if prior and prior.task_ids != list(task_ids):
+            raise TaskConflict(
+                "Idempotency key is bound to different task_ids"
+            ) from None
+        if prior and list(prior.request.get("worker_presets", [])) != list(presets):
+            raise TaskConflict(
+                "Idempotency key is bound to different worker_presets"
+            ) from None
+
+    async def get(
+        self,
+        root_trace_id: str,
+        operation_id: str,
+    ) -> BackgroundOperation:
+        ledger = await self.task_store.load(root_trace_id)
+        try:
+            return ledger.operations[operation_id]
+        except KeyError as exc:
+            raise ValueError(f"Operation not found: {operation_id}") from exc
+
+    async def wait(
+        self,
+        root_trace_id: str,
+        operation_id: str,
+    ) -> BackgroundOperation:
+        operation = await self.get(root_trace_id, operation_id)
+        if operation.status == OperationStatus.PENDING:
+            self.schedule(operation)
+        await self.runtime.wait(root_trace_id, operation_id)
+        while True:
+            operation = await self.get(root_trace_id, operation_id)
+            if operation.status in {
+                OperationStatus.COMPLETED,
+                OperationStatus.FAILED,
+                OperationStatus.STOPPED,
+            }:
+                return operation
+            await asyncio.sleep(0.05)
+
+    async def stop(
+        self,
+        root_trace_id: str,
+        operation_id: str,
+        idempotency_key: Optional[str] = None,
+    ) -> BackgroundOperation:
+        """Persist stop intent before signalling any process-local Agent task."""
+
+        current = await self.get(root_trace_id, operation_id)
+        if current.status in {
+            OperationStatus.COMPLETED,
+            OperationStatus.FAILED,
+            OperationStatus.STOPPED,
+        }:
+            return current
+
+        def request_stop(ledger: TaskLedger) -> Dict[str, Any]:
+            operation = ledger.operations[operation_id]
+            if operation.status in {
+                OperationStatus.COMPLETED,
+                OperationStatus.FAILED,
+                OperationStatus.STOPPED,
+            }:
+                return {"operation_id": operation_id, "trace_ids": []}
+            operation.status = OperationStatus.STOP_REQUESTED
+            operation.execution_epoch += 1
+            operation.updated_at = utc_now()
+            trace_ids = [
+                ledger.attempts[item].worker_trace_id
+                for item in operation.attempt_ids
+                if item in ledger.attempts
+                and ledger.attempts[item].status == AttemptStatus.RUNNING
+            ]
+            trace_ids.extend(
+                ledger.validations[item].validator_trace_id
+                for item in operation.validation_ids
+                if item in ledger.validations
+                and ledger.validations[item].status == ValidationRunStatus.RUNNING
+            )
+            return {"operation_id": operation_id, "trace_ids": trace_ids}
+
+        result = await self._mutate(
+            root_trace_id,
+            "operation_stop_requested",
+            request_stop,
+            f"operation.stop:{idempotency_key}" if idempotency_key else None,
+            idempotency_payload={"operation_id": operation_id},
+            operation_id=operation_id,
+        )
+        executor = self._get_executor()
+        stop = getattr(executor, "stop", None)
+        signal_task: Optional[asyncio.Future[Any]] = None
+        if stop:
+            signal_task = asyncio.ensure_future(
+                asyncio.gather(
+                    *(stop(trace_id) for trace_id in result["trace_ids"]),
+                    return_exceptions=True,
+                )
+            )
+        grace = self._get_config().stop_grace_seconds
+        cancel_task = asyncio.create_task(
+            self.runtime.cancel(root_trace_id, operation_id, grace)
+        )
+        if signal_task:
+            await asyncio.sleep(0)
+            try:
+                await asyncio.wait_for(asyncio.shield(signal_task), timeout=grace)
+            except asyncio.TimeoutError:
+                signal_task.cancel()
+                await asyncio.gather(signal_task, return_exceptions=True)
+        await cancel_task
+        return await self._normalize_stopped(root_trace_id, operation_id)
+
+    async def _normalize_stopped(
+        self,
+        root_trace_id: str,
+        operation_id: str,
+    ) -> BackgroundOperation:
+        current = await self.get(root_trace_id, operation_id)
+        if current.status in {
+            OperationStatus.COMPLETED,
+            OperationStatus.FAILED,
+            OperationStatus.STOPPED,
+        }:
+            return current
+
+        def normalize(ledger: TaskLedger) -> Dict[str, Any]:
+            operation = ledger.operations[operation_id]
+            if operation.status in {OperationStatus.COMPLETED, OperationStatus.FAILED}:
+                return {"operation_id": operation_id, "status": operation.status.value}
+            for attempt_id in operation.attempt_ids:
+                attempt = ledger.attempts.get(attempt_id)
+                if attempt and attempt.status == AttemptStatus.RUNNING and attempt.started_at:
+                    attempt.status = AttemptStatus.STOPPED
+                    attempt.completed_at = utc_now()
+                    attempt.error = attempt.error or "Operation stopped"
+                    attempt.execution_stats = _failure_stats(
+                        attempt.execution_stats, FailureCode.STOPPED
+                    )
+                    task = ledger.tasks[attempt.task_id]
+                    if task.status == TaskStatus.RUNNING:
+                        task.status = TaskStatus.NEEDS_REPLAN
+                        task.updated_at = utc_now()
+            for validation_id in operation.validation_ids:
+                validation = ledger.validations.get(validation_id)
+                if (
+                    validation
+                    and validation.status == ValidationRunStatus.RUNNING
+                    and validation.started_at
+                ):
+                    validation.status = ValidationRunStatus.STOPPED
+                    validation.completed_at = utc_now()
+                    validation.error = validation.error or "Operation stopped"
+                    validation.execution_stats = _failure_stats(
+                        validation.execution_stats, FailureCode.STOPPED
+                    )
+                    task = ledger.tasks[validation.task_id]
+                    if task.status == TaskStatus.VALIDATING:
+                        task.status = TaskStatus.NEEDS_REPLAN
+                        task.updated_at = utc_now()
+            operation.status = OperationStatus.STOPPED
+            operation.completed_at = utc_now()
+            operation.updated_at = operation.completed_at
+            return {"operation_id": operation_id, "status": operation.status.value}
+
+        await self._mutate(
+            root_trace_id,
+            "operation_stopped",
+            normalize,
+            operation_id=operation_id,
+        )
+        return await self.get(root_trace_id, operation_id)
+
+    async def resume(
+        self,
+        root_trace_id: str,
+        operation_id: str,
+        idempotency_key: Optional[str] = None,
+    ) -> BackgroundOperation:
+        """Resume only stages that never started, or validation after submit."""
+
+        def resume_state(ledger: TaskLedger) -> Dict[str, Any]:
+            operation = ledger.operations[operation_id]
+            if operation.status == OperationStatus.PENDING:
+                return {"operation_id": operation_id}
+            if operation.status != OperationStatus.STOPPED:
+                raise TaskConflict("Only a stopped operation can be resumed")
+            unsafe: List[str] = []
+            for attempt_id in operation.attempt_ids:
+                attempt = ledger.attempts[attempt_id]
+                if attempt.status in {
+                    AttemptStatus.FAILED,
+                    AttemptStatus.EXPIRED,
+                } or (attempt.status == AttemptStatus.STOPPED and attempt.started_at):
+                    unsafe.append(attempt_id)
+            for validation_id in operation.validation_ids:
+                validation = ledger.validations[validation_id]
+                if validation.status in {
+                    ValidationRunStatus.ERROR,
+                    ValidationRunStatus.EXPIRED,
+                } or (
+                    validation.status == ValidationRunStatus.STOPPED
+                    and validation.started_at
+                ):
+                    unsafe.append(validation_id)
+            if unsafe:
+                raise TaskConflict(f"resume_not_safe: stages already started: {unsafe}")
+            operation.status = OperationStatus.PENDING
+            operation.completed_at = None
+            operation.error = None
+            operation.updated_at = utc_now()
+            return {"operation_id": operation_id}
+
+        await self._mutate(
+            root_trace_id,
+            "operation_resumed",
+            resume_state,
+            f"operation.resume:{idempotency_key}" if idempotency_key else None,
+            idempotency_payload={"operation_id": operation_id},
+            operation_id=operation_id,
+        )
+        operation = await self.get(root_trace_id, operation_id)
+        self.schedule(operation)
+        return operation
+
+    async def recover(self, root_trace_id: str) -> List[BackgroundOperation]:
+        """Reconcile crash state without automatically invoking any model."""
+
+        recoverable = await self.task_store.list_recoverable(root_trace_id)
+        recovered: List[BackgroundOperation] = []
+        for candidate in recoverable:
+            await self._mutate(
+                root_trace_id,
+                "operation_recovered",
+                partial(_recover_operation_state, operation_id=candidate.operation_id),
+                operation_id=candidate.operation_id,
+            )
+            recovered.append(await self.get(root_trace_id, candidate.operation_id))
+        return recovered
+
+    def schedule(self, operation: BackgroundOperation) -> None:
+        self.runtime.schedule(operation, lambda: self._run(operation))
+
+    async def _run(self, operation: BackgroundOperation) -> None:
+        try:
+            await self.advance(operation.root_trace_id, operation.operation_id)
+        except asyncio.CancelledError:
+            raise
+        except Exception as exc:
+            await self.finish(
+                operation.root_trace_id,
+                operation.operation_id,
+                OperationStatus.FAILED,
+                error=f"Operation failed: {type(exc).__name__}: {exc}",
+                execution_epoch=operation.execution_epoch,
+            )
+
+    async def advance(
+        self,
+        root_trace_id: str,
+        operation_id: str,
+    ) -> BackgroundOperation:
+        """Claim and advance an operation through its durable stage state."""
+
+        def claim(ledger: TaskLedger) -> Dict[str, Any]:
+            operation = ledger.operations[operation_id]
+            if operation.status in {
+                OperationStatus.COMPLETED,
+                OperationStatus.FAILED,
+                OperationStatus.STOPPED,
+            }:
+                return {"operation_id": operation_id, "execute": False}
+            if operation.status == OperationStatus.RUNNING:
+                return {"operation_id": operation_id, "execute": False}
+            if operation.status == OperationStatus.STOP_REQUESTED:
+                return {
+                    "operation_id": operation_id,
+                    "execute": False,
+                    "normalize_stop": True,
+                }
+            if deadline_expired(operation.deadline_at):
+                operation.status = OperationStatus.FAILED
+                operation.error = "Operation deadline exceeded before execution"
+                operation.completed_at = utc_now()
+                operation.updated_at = operation.completed_at
+                return {"operation_id": operation_id, "execute": False}
+            operation.status = OperationStatus.RUNNING
+            operation.execution_epoch += 1
+            operation.started_at = operation.started_at or utc_now()
+            operation.updated_at = utc_now()
+            return {
+                "operation_id": operation_id,
+                "execute": True,
+                "execution_epoch": operation.execution_epoch,
+            }
+
+        claimed = await self._mutate(
+            root_trace_id,
+            "operation_claimed",
+            claim,
+            operation_id=operation_id,
+        )
+        if not claimed["execute"]:
+            if claimed.get("normalize_stop"):
+                return await self._normalize_stopped(root_trace_id, operation_id)
+            return await self.get(root_trace_id, operation_id)
+        claimed_epoch = int(claimed["execution_epoch"])
+        try:
+            operation = await self.get(root_trace_id, operation_id)
+            results = list(await self._execute_claimed(operation))
+            return await self.finish(
+                root_trace_id,
+                operation_id,
+                OperationStatus.COMPLETED,
+                results=results,
+                execution_epoch=claimed_epoch,
+            )
+        except asyncio.CancelledError:
+            raise
+        except Exception as exc:
+            return await self.finish(
+                root_trace_id,
+                operation_id,
+                OperationStatus.FAILED,
+                error=f"Operation failed: {type(exc).__name__}: {exc}",
+                execution_epoch=claimed_epoch,
+            )
+
+    async def finish(
+        self,
+        root_trace_id: str,
+        operation_id: str,
+        status: OperationStatus,
+        *,
+        results: Optional[Sequence[TaskCycleResult]] = None,
+        error: Optional[str] = None,
+        execution_epoch: Optional[int] = None,
+    ) -> BackgroundOperation:
+        result_list = list(results or [])
+
+        def finish_state(ledger: TaskLedger) -> Dict[str, Any]:
+            operation = ledger.operations[operation_id]
+            if operation.status in {
+                OperationStatus.COMPLETED,
+                OperationStatus.FAILED,
+                OperationStatus.STOPPED,
+            }:
+                return {
+                    "operation_id": operation_id,
+                    "status": operation.status.value,
+                    "result_ref": operation.result_ref,
+                    "error": operation.error,
+                }
+            if (
+                execution_epoch is not None
+                and operation.execution_epoch != execution_epoch
+            ):
+                return {
+                    "operation_id": operation_id,
+                    "status": operation.status.value,
+                    "result_ref": operation.result_ref,
+                    "error": operation.error,
+                }
+            final_status = (
+                OperationStatus.STOPPED
+                if operation.status == OperationStatus.STOP_REQUESTED
+                else status
+            )
+            operation.status = final_status
+            operation.error = error
+            for item in result_list:
+                if item.attempt_id and item.attempt_id not in operation.attempt_ids:
+                    operation.attempt_ids.append(item.attempt_id)
+                if item.validation_id and item.validation_id not in operation.validation_ids:
+                    operation.validation_ids.append(item.validation_id)
+            operation.result_ref = {
+                "task_ids": [item.task_id for item in result_list],
+                "attempt_ids": [item.attempt_id for item in result_list],
+                "validation_ids": [item.validation_id for item in result_list],
+                "errors": [item.error for item in result_list],
+            }
+            operation.completed_at = utc_now()
+            operation.updated_at = operation.completed_at
+            return {
+                "operation_id": operation_id,
+                "status": final_status.value,
+                "result_ref": operation.result_ref,
+                "error": error,
+            }
+
+        await self._mutate(
+            root_trace_id,
+            "operation_finished",
+            finish_state,
+            operation_id=operation_id,
+        )
+        return await self.get(root_trace_id, operation_id)
+
+
+def _recover_operation_state(
+    ledger: TaskLedger,
+    *,
+    operation_id: str,
+) -> Dict[str, Any]:
+    operation = ledger.operations[operation_id]
+    if operation.status in {
+        OperationStatus.COMPLETED,
+        OperationStatus.FAILED,
+        OperationStatus.STOPPED,
+    }:
+        return {"operation_id": operation_id, "status": operation.status.value}
+    expired = deadline_expired(operation.deadline_at)
+    if operation.status == OperationStatus.PENDING and not expired:
+        return {"operation_id": operation_id, "status": operation.status.value}
+
+    operation.status = OperationStatus.FAILED if expired else OperationStatus.STOPPED
+    operation.error = (
+        "Operation deadline exceeded during recovery"
+        if expired
+        else "Runtime restart requires explicit resume or Planner replan"
+    )
+    operation.completed_at = utc_now()
+    operation.updated_at = operation.completed_at
+    _recover_attempts(ledger, operation_id, operation.error, expired)
+    _recover_validations(ledger, operation_id, operation.error, expired)
+    if expired:
+        _mark_expired_tasks(ledger, operation.task_ids)
+    return {"operation_id": operation_id, "status": operation.status.value}
+
+
+def _recover_attempts(
+    ledger: TaskLedger,
+    operation_id: str,
+    error: str,
+    expired: bool,
+) -> None:
+    for attempt in ledger.attempts.values():
+        if not _attempt_needs_recovery(attempt, operation_id, expired):
+            continue
+        attempt.status = AttemptStatus.EXPIRED if expired else AttemptStatus.STOPPED
+        attempt.completed_at = utc_now()
+        attempt.error = attempt.error or error
+        attempt.execution_stats = _failure_stats(
+            attempt.execution_stats,
+            FailureCode.TIMEOUT if expired else FailureCode.INTERRUPTED,
+        )
+        task = ledger.tasks[attempt.task_id]
+        if task.status == TaskStatus.RUNNING:
+            task.status = TaskStatus.NEEDS_REPLAN
+
+
+def _attempt_needs_recovery(
+    attempt: TaskAttempt,
+    operation_id: str,
+    expired: bool,
+) -> bool:
+    return (
+        attempt.operation_id == operation_id
+        and attempt.status == AttemptStatus.RUNNING
+        and (expired or attempt.started_at is not None)
+    )
+
+
+def _recover_validations(
+    ledger: TaskLedger,
+    operation_id: str,
+    error: str,
+    expired: bool,
+) -> None:
+    for validation in ledger.validations.values():
+        if not _validation_needs_recovery(validation, operation_id, expired):
+            continue
+        validation.status = (
+            ValidationRunStatus.EXPIRED if expired else ValidationRunStatus.STOPPED
+        )
+        validation.completed_at = utc_now()
+        validation.error = validation.error or error
+        validation.execution_stats = _failure_stats(
+            validation.execution_stats,
+            FailureCode.TIMEOUT if expired else FailureCode.INTERRUPTED,
+        )
+        task = ledger.tasks[validation.task_id]
+        if task.status == TaskStatus.VALIDATING:
+            task.status = TaskStatus.NEEDS_REPLAN
+
+
+def _validation_needs_recovery(
+    validation: ValidationReport,
+    operation_id: str,
+    expired: bool,
+) -> bool:
+    return (
+        validation.operation_id == operation_id
+        and validation.status == ValidationRunStatus.RUNNING
+        and (expired or validation.started_at is not None)
+    )
+
+
+def _mark_expired_tasks(ledger: TaskLedger, task_ids: Sequence[str]) -> None:
+    active = {
+        TaskStatus.RUNNING,
+        TaskStatus.AWAITING_VALIDATION,
+        TaskStatus.VALIDATING,
+    }
+    for task_id in task_ids:
+        task = ledger.tasks[task_id]
+        if task.status in active:
+            task.status = TaskStatus.NEEDS_REPLAN
+            task.updated_at = utc_now()
+
+
+def _normalize_start_request(
+    kind: OperationKind | str,
+    task_ids: Optional[Sequence[str]],
+    task_id: Optional[str],
+    worker_presets: Optional[Sequence[str]],
+    attempt_id: Optional[str],
+    deadline_at: Optional[str],
+    default_worker_preset: str,
+) -> tuple[OperationKind, List[str], List[str], Dict[str, Any]]:
+    operation_kind = OperationKind(kind)
+    requested_task_ids = list(task_ids or ([] if task_id is None else [task_id]))
+    presets = list(worker_presets or [])
+    _validate_start_request(operation_kind, requested_task_ids, presets, attempt_id)
+    presets = _normalize_worker_presets(
+        operation_kind,
+        requested_task_ids,
+        presets,
+        default_worker_preset,
+    )
+    normalized_deadline = parse_deadline(deadline_at)
+    request = {
+        "kind": operation_kind.value,
+        "task_ids": requested_task_ids,
+        "worker_presets": presets,
+        "attempt_id": attempt_id,
+        "deadline_at": normalized_deadline,
+    }
+    return operation_kind, requested_task_ids, presets, request
+
+
+def _validate_start_request(
+    operation_kind: OperationKind,
+    task_ids: Sequence[str],
+    presets: Sequence[str],
+    attempt_id: Optional[str],
+) -> None:
+    if operation_kind == OperationKind.DISPATCH and not task_ids:
+        raise ValueError("Dispatch requires at least one task")
+    if len(set(task_ids)) != len(task_ids):
+        raise TaskConflict("The same task cannot be dispatched twice in one call")
+    if operation_kind == OperationKind.REVALIDATE:
+        if len(task_ids) != 1 or not attempt_id:
+            raise ValueError("Revalidate requires exactly one task_id and attempt_id")
+        if presets:
+            raise ValueError("worker_presets are not valid for revalidation")
+    if presets and len(presets) not in (1, len(task_ids)):
+        raise ValueError("worker_presets must have length 1 or match task_ids")
+
+
+def _normalize_worker_presets(
+    operation_kind: OperationKind,
+    task_ids: Sequence[str],
+    presets: List[str],
+    default_worker_preset: str,
+) -> List[str]:
+    if len(presets) == 1:
+        presets *= len(task_ids)
+    if operation_kind == OperationKind.DISPATCH and not presets:
+        return [default_worker_preset] * len(task_ids)
+    return presets
+
+
+def _create_operation(
+    ledger: TaskLedger,
+    *,
+    operation_id: str,
+    root_trace_id: str,
+    operation_kind: OperationKind,
+    request: Dict[str, Any],
+    fingerprint: str,
+    task_ids: Sequence[str],
+) -> Dict[str, Any]:
+    for task_id in task_ids:
+        if task_id not in ledger.tasks:
+            raise ValueError(f"Task not found: {task_id}")
+    ledger.operations[operation_id] = BackgroundOperation(
+        operation_id=operation_id,
+        root_trace_id=root_trace_id,
+        kind=operation_kind,
+        request=request,
+        request_fingerprint=fingerprint,
+        task_ids=list(task_ids),
+        deadline_at=request["deadline_at"],
+    )
+    return {"operation_id": operation_id}
+
+
+def parse_deadline(value: Optional[str]) -> Optional[str]:
+    if value is None:
+        return None
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError("deadline_at must be an ISO-8601 timestamp")
+    try:
+        parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
+    except ValueError as exc:
+        raise ValueError("deadline_at must be an ISO-8601 timestamp") from exc
+    if parsed.tzinfo is None or parsed.utcoffset() is None:
+        raise ValueError("deadline_at must include a timezone")
+    return parsed.astimezone(timezone.utc).isoformat()
+
+
+def deadline_expired(deadline: Optional[str]) -> bool:
+    return deadline is not None and deadline_seconds(deadline) <= 0
+
+
+def deadline_seconds(deadline: str) -> float:
+    parsed = datetime.fromisoformat(deadline.replace("Z", "+00:00"))
+    return (parsed.astimezone(timezone.utc) - datetime.now(timezone.utc)).total_seconds()
+
+
+def stage_timeout(
+    deadline: Optional[str],
+    configured_timeout: Optional[float],
+) -> Optional[float]:
+    if deadline is None:
+        return configured_timeout
+    remaining = max(0.0, deadline_seconds(deadline))
+    if configured_timeout is None:
+        return remaining
+    return min(configured_timeout, remaining)
+
+
+def _failure_stats(
+    stats: Optional[ExecutionStats],
+    fallback: FailureCode,
+) -> ExecutionStats:
+    if stats is None:
+        return ExecutionStats(failure_code=fallback)
+    if stats.failure_code is not None:
+        return stats
+    return replace(stats, failure_code=fallback)
+
+
+__all__ = ["OperationController", "stage_timeout"]

+ 76 - 0
agent/agent/orchestration/runtime.py

@@ -0,0 +1,76 @@
+"""Process-local execution registry for durable background operations."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Awaitable, Callable, Dict, Optional, Tuple
+
+from .models import BackgroundOperation
+
+
+OperationKey = Tuple[str, str]
+
+
+class OperationRuntime:
+    """Own asyncio Tasks without treating them as durable operation state."""
+
+    def __init__(self) -> None:
+        self._tasks: Dict[OperationKey, asyncio.Task[None]] = {}
+
+    def schedule(
+        self,
+        operation: BackgroundOperation,
+        run: Callable[[], Awaitable[None]],
+    ) -> None:
+        key = (operation.root_trace_id, operation.operation_id)
+        existing = self._tasks.get(key)
+        if existing and not existing.done():
+            return
+        task = asyncio.create_task(run(), name=f"orchestration:{operation.operation_id}")
+        self._tasks[key] = task
+
+        def discard(completed: asyncio.Task[None]) -> None:
+            if self._tasks.get(key) is completed:
+                self._tasks.pop(key, None)
+
+        task.add_done_callback(discard)
+
+    async def wait(self, root_trace_id: str, operation_id: str) -> None:
+        task = self._tasks.get((root_trace_id, operation_id))
+        if task:
+            try:
+                await asyncio.shield(task)
+            except asyncio.CancelledError:
+                if not task.cancelled():
+                    raise
+
+    def task(self, root_trace_id: str, operation_id: str) -> Optional[asyncio.Task[None]]:
+        return self._tasks.get((root_trace_id, operation_id))
+
+    async def cancel(
+        self,
+        root_trace_id: str,
+        operation_id: str,
+        grace_seconds: float,
+    ) -> None:
+        task = self.task(root_trace_id, operation_id)
+        if not task or task.done():
+            return
+        try:
+            await asyncio.wait_for(asyncio.shield(task), timeout=grace_seconds)
+        except asyncio.TimeoutError:
+            task.cancel()
+            try:
+                await asyncio.wait_for(
+                    asyncio.shield(task),
+                    timeout=grace_seconds,
+                )
+            except asyncio.CancelledError:
+                pass
+            except asyncio.TimeoutError:
+                # Durable epoch ownership prevents a cancellation-resistant
+                # orphan from committing late results.
+                return
+
+
+__all__ = ["OperationRuntime"]

+ 89 - 12
agent/tests/test_coordinator_integration.py

@@ -11,6 +11,8 @@ from agent.orchestration.models import (
     AttemptSubmission,
     CriterionResult,
     DecisionAction,
+    OperationKind,
+    OperationStatus,
     TaskStatus,
     ValidationVerdict,
 )
@@ -454,11 +456,11 @@ class OneShotGetFailureArtifactStore(FileSystemArtifactStore):
         self.root_trace_id = root_trace_id
         return self
 
-    async def get(self, snapshot_id):
+    async def get(self, root_trace_id, snapshot_id=None):
         self.get_calls += 1
         if self.get_calls == 2:
             raise RuntimeError("injected artifact read failure")
-        return await super().get(snapshot_id)
+        return await super().get(root_trace_id, snapshot_id)
 
 
 class OneShotGoalProjectionFailureStore(FileSystemTraceStore):
@@ -495,7 +497,7 @@ class FailingDispatchCompletionTaskStore(FileSystemTaskStore):
         super().__init__(base_path)
         self.fail_next_dispatch_completion = True
 
-    async def commit(self, ledger, expected_revision, idempotency_key=None):
+    async def commit(self, ledger, expected_revision, idempotency_key=None, event=None):
         if (
             self.fail_next_dispatch_completion
             and idempotency_key == "root:batch"
@@ -506,6 +508,7 @@ class FailingDispatchCompletionTaskStore(FileSystemTaskStore):
             ledger,
             expected_revision,
             idempotency_key=idempotency_key,
+            event=event,
         )
 
 
@@ -513,12 +516,20 @@ class OneShotLoadFailureTaskStore(FileSystemTaskStore):
     def __init__(self, base_path):
         super().__init__(base_path)
         self.fail_next_load = False
+        self._active_operation_loads_before_failure = 1
 
     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)
+        ledger = await super().load(root_trace_id)
+        if self.fail_next_load and any(
+            operation.status.value == "running"
+            for operation in ledger.operations.values()
+        ):
+            if self._active_operation_loads_before_failure:
+                self._active_operation_loads_before_failure -= 1
+            else:
+                self.fail_next_load = False
+                raise RuntimeError("injected reservation load failure")
+        return ledger
 
 
 class FailingEventSink:
@@ -709,11 +720,8 @@ async def test_concurrent_dispatch_replay_does_not_duplicate_agent_execution(tmp
         result[0].task_status == TaskStatus.AWAITING_DECISION
         for result in (first, replay)
     )
-    assert any(
-        result[0].error
-        and "still in progress" in result[0].error
-        for result in (first, replay)
-    )
+    assert first[0].attempt_id == replay[0].attempt_id
+    assert all(result[0].task_status == TaskStatus.AWAITING_DECISION for result in (first, replay))
 
     final = await coordinator.dispatch_tasks(
         "root",
@@ -767,3 +775,72 @@ async def test_event_sink_failure_does_not_rollback_committed_ledger(tmp_path):
 
     ledger = await store.load("root")
     assert ledger.tasks[task_id].status == TaskStatus.PENDING
+
+
+@pytest.mark.asyncio
+async def test_background_operation_is_durable_and_idempotently_bound(tmp_path):
+    executor = FakeExecutor([ValidationVerdict.PASSED])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "durable-operation")
+
+    operation = await coordinator.start_operation(
+        "root",
+        OperationKind.DISPATCH,
+        task_ids=[task_id],
+        idempotency_key="operation-key",
+    )
+    completed = await coordinator.await_operation("root", operation.operation_id)
+    replay = await coordinator.start_operation(
+        "root",
+        OperationKind.DISPATCH,
+        task_ids=[task_id],
+        idempotency_key="operation-key",
+    )
+
+    ledger = await store.load("root")
+    assert completed.status == OperationStatus.COMPLETED
+    assert replay.operation_id == completed.operation_id
+    assert ledger.operations[completed.operation_id].attempt_ids
+    assert ledger.operations[completed.operation_id].validation_ids
+    assert executor.worker_calls == executor.validator_calls == 1
+
+    other = await create_task(coordinator, "different-operation")
+    with pytest.raises(TaskConflict, match="different task_ids"):
+        await coordinator.start_operation(
+            "root",
+            OperationKind.DISPATCH,
+            task_ids=[other],
+            idempotency_key="operation-key",
+        )
+
+
+@pytest.mark.asyncio
+async def test_submitted_attempt_advances_to_validation_without_worker_replay(tmp_path):
+    executor = FakeExecutor([ValidationVerdict.PASSED])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "resume-after-submit")
+    reserved = await coordinator._create_attempt(
+        "root", task_id, "worker", "resume-reservation"
+    )
+    await coordinator.submit_attempt(
+        {
+            "role": AgentRole.WORKER.value,
+            "root_trace_id": "root",
+            "task_id": task_id,
+            "attempt_id": reserved["attempt_id"],
+            "trace_id": reserved["worker_trace_id"],
+            "spec_version": 1,
+            "tool_call_id": "resume-submission",
+        },
+        AttemptSubmission(
+            summary="already executed",
+            artifact_refs=[ArtifactRef(uri="memory://submitted", version="1")],
+        ),
+    )
+
+    result = await coordinator.advance_cycle("root", task_id, reserved["attempt_id"])
+
+    assert result.task_status == TaskStatus.AWAITING_DECISION
+    assert executor.worker_calls == 0
+    assert executor.validator_calls == 1
+    assert len((await store.load("root")).tasks[task_id].attempt_ids) == 1

+ 705 - 0
agent/tests/test_orchestration_operations.py

@@ -0,0 +1,705 @@
+import asyncio
+
+import pytest
+
+from agent.orchestration.config import OrchestrationConfig
+from agent.orchestration.coordinator import TaskConflict, TaskCoordinator
+from agent.orchestration.models import (
+    AgentRole,
+    ArtifactRef,
+    AttemptStatus,
+    AttemptSubmission,
+    BackgroundOperation,
+    CriterionResult,
+    OperationKind,
+    OperationStatus,
+    TaskStatus,
+    ValidationVerdict,
+)
+from agent.orchestration.protocols import WorkerRunResult
+from agent.orchestration.store import FileSystemArtifactStore, TraceEventSink
+
+from test_coordinator_integration import FakeExecutor, create_task, make_coordinator
+
+
+class BlockingWorkerExecutor:
+    def __init__(self):
+        self.coordinator = None
+        self.started = asyncio.Event()
+        self.context = None
+        self.stop_calls = []
+
+    async def run_worker(self, context):
+        self.context = context
+        self.started.set()
+        await asyncio.Event().wait()
+
+    async def run_validator(self, context):
+        raise AssertionError("validator must not start")
+
+    async def stop(self, trace_id):
+        self.stop_calls.append(trace_id)
+        return True
+
+
+class SlowWorkerExecutor:
+    def __init__(self):
+        self.coordinator = None
+
+    async def run_worker(self, context):
+        await asyncio.sleep(1)
+        return WorkerRunResult(context["worker_trace_id"], "completed")
+
+    async def run_validator(self, context):
+        raise AssertionError("validator must not start")
+
+    async def stop(self, trace_id):
+        return True
+
+
+class HangingStopExecutor(BlockingWorkerExecutor):
+    async def stop(self, trace_id):
+        self.stop_calls.append(trace_id)
+        await asyncio.Event().wait()
+
+
+class CancellationResistantExecutor(BlockingWorkerExecutor):
+    def __init__(self):
+        super().__init__()
+        self.release = asyncio.Event()
+
+    async def run_worker(self, context):
+        self.context = context
+        self.started.set()
+        try:
+            await asyncio.Event().wait()
+        except asyncio.CancelledError:
+            await self.release.wait()
+        return WorkerRunResult(context["worker_trace_id"], "completed")
+
+
+class BlockingValidatorExecutor(FakeExecutor):
+    def __init__(self):
+        super().__init__([])
+        self.validator_started = asyncio.Event()
+        self.validator_context = None
+
+    async def run_validator(self, context):
+        self.validator_context = context
+        self.validator_started.set()
+        await asyncio.Event().wait()
+
+
+class SubmittedBlockingExecutor(FakeExecutor):
+    def __init__(self):
+        super().__init__([])
+        self.submitted = asyncio.Event()
+        self.release = asyncio.Event()
+
+    async def run_worker(self, context):
+        self.worker_calls += 1
+        await self.coordinator.submit_attempt(
+            {
+                **context,
+                "role": AgentRole.WORKER.value,
+                "trace_id": context["worker_trace_id"],
+                "tool_call_id": f"submit-{context['attempt_id']}",
+            },
+            AttemptSubmission(
+                summary="durable before worker return",
+                artifact_refs=[ArtifactRef(uri="memory://submitted", version="1")],
+            ),
+        )
+        self.submitted.set()
+        await self.release.wait()
+        return WorkerRunResult(context["worker_trace_id"], "completed")
+
+
+def second_coordinator(tmp_path, store, trace_store, executor):
+    coordinator = TaskCoordinator(
+        store,
+        FileSystemArtifactStore(str(tmp_path)),
+        trace_store,
+        OrchestrationConfig(stop_grace_seconds=0),
+        TraceEventSink(str(tmp_path)),
+        executor,
+    )
+    executor.coordinator = coordinator
+    return coordinator
+
+
+async def persist_operation(store, operation):
+    ledger = await store.load(operation.root_trace_id)
+    ledger.operations[operation.operation_id] = operation
+    await store.commit(ledger, ledger.revision)
+
+
+@pytest.mark.asyncio
+async def test_advance_normalizes_stop_requested_stages_atomically(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "normalize requested stop")
+    operation = BackgroundOperation(
+        operation_id="stop-requested-operation",
+        root_trace_id="root",
+        kind=OperationKind.DISPATCH,
+        status=OperationStatus.RUNNING,
+        task_ids=[task_id],
+        execution_epoch=1,
+    )
+    await persist_operation(store, operation)
+    reserved = await coordinator._create_attempt(
+        "root",
+        task_id,
+        "worker",
+        None,
+        operation_id=operation.operation_id,
+        execution_epoch=1,
+    )
+    await coordinator._mark_stage_started(
+        "root",
+        operation.operation_id,
+        1,
+        attempt_id=reserved["attempt_id"],
+    )
+    ledger = await store.load("root")
+    ledger.operations[operation.operation_id].status = OperationStatus.STOP_REQUESTED
+    ledger.operations[operation.operation_id].execution_epoch = 2
+    await store.commit(ledger, ledger.revision)
+
+    stopped = await coordinator.advance_operation("root", operation.operation_id)
+    ledger = await store.load("root")
+    assert stopped.status == OperationStatus.STOPPED
+    assert ledger.attempts[reserved["attempt_id"]].status == AttemptStatus.STOPPED
+    assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
+
+
+@pytest.mark.asyncio
+async def test_stop_marks_started_attempt_and_rejects_late_epoch_submission(tmp_path):
+    executor = BlockingWorkerExecutor()
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    coordinator.config = OrchestrationConfig(stop_grace_seconds=0)
+    task_id = await create_task(coordinator, "stop running worker")
+
+    operation = await coordinator.start_operation(
+        "root", OperationKind.DISPATCH, task_ids=[task_id]
+    )
+    await asyncio.wait_for(executor.started.wait(), timeout=1)
+    stopped = await coordinator.stop_operation(
+        "root", operation.operation_id, idempotency_key="stop-once"
+    )
+
+    ledger = await store.load("root")
+    attempt = ledger.attempts[ledger.tasks[task_id].attempt_ids[-1]]
+    assert stopped.status == OperationStatus.STOPPED
+    assert attempt.status == AttemptStatus.STOPPED
+    assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
+    assert executor.stop_calls == [attempt.worker_trace_id]
+
+    with pytest.raises(TaskConflict, match="epoch|stopping"):
+        await coordinator.submit_attempt(
+            {
+                **executor.context,
+                "role": AgentRole.WORKER.value,
+                "trace_id": attempt.worker_trace_id,
+                "tool_call_id": "late-submit",
+            },
+            AttemptSubmission(
+                summary="late",
+                artifact_refs=[ArtifactRef(uri="memory://late", version="1")],
+            ),
+        )
+
+    revision = ledger.revision
+    replay = await coordinator.stop_operation(
+        "root", operation.operation_id, idempotency_key="stop-once"
+    )
+    assert replay.status == OperationStatus.STOPPED
+    assert (await store.load("root")).revision == revision
+    with pytest.raises(TaskConflict, match="resume_not_safe"):
+        await coordinator.resume_operation("root", operation.operation_id)
+
+
+@pytest.mark.asyncio
+async def test_hanging_executor_stop_is_bounded_by_grace_period(tmp_path):
+    executor = HangingStopExecutor()
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    coordinator.config = OrchestrationConfig(stop_grace_seconds=0.01)
+    task_id = await create_task(coordinator, "bounded stop")
+    operation = await coordinator.start_operation(
+        "root", OperationKind.DISPATCH, task_ids=[task_id]
+    )
+    await asyncio.wait_for(executor.started.wait(), timeout=1)
+
+    stopped = await asyncio.wait_for(
+        coordinator.stop_operation("root", operation.operation_id), timeout=0.5
+    )
+    assert stopped.status == OperationStatus.STOPPED
+    assert executor.stop_calls
+    assert (await store.load("root")).operations[operation.operation_id].status == OperationStatus.STOPPED
+
+
+@pytest.mark.asyncio
+async def test_cancellation_resistant_runtime_cannot_block_stop_or_commit_late(tmp_path):
+    executor = CancellationResistantExecutor()
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    coordinator.config = OrchestrationConfig(stop_grace_seconds=0.01)
+    task_id = await create_task(coordinator, "resists cancellation")
+    operation = await coordinator.start_operation(
+        "root", OperationKind.DISPATCH, task_ids=[task_id]
+    )
+    await asyncio.wait_for(executor.started.wait(), timeout=1)
+
+    stopped = await asyncio.wait_for(
+        coordinator.stop_operation("root", operation.operation_id), timeout=0.5
+    )
+    assert stopped.status == OperationStatus.STOPPED
+
+    executor.release.set()
+    await asyncio.wait_for(
+        coordinator.runtime.wait("root", operation.operation_id), timeout=0.5
+    )
+    ledger = await store.load("root")
+    assert ledger.operations[operation.operation_id].status == OperationStatus.STOPPED
+    assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
+
+
+@pytest.mark.asyncio
+async def test_worker_timeout_expires_attempt_without_starting_validation(tmp_path):
+    executor = SlowWorkerExecutor()
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    coordinator.config = OrchestrationConfig(worker_timeout_seconds=0.001)
+    task_id = await create_task(coordinator, "timeout")
+
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+
+    ledger = await store.load("root")
+    assert result.task_status == TaskStatus.NEEDS_REPLAN
+    assert ledger.attempts[result.attempt_id].status == AttemptStatus.EXPIRED
+    assert ledger.tasks[task_id].validation_ids == []
+
+
+@pytest.mark.asyncio
+async def test_recover_then_resume_operation_before_any_attempt_is_safe(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "resume before reservation")
+    operation = BackgroundOperation(
+        operation_id="crashed-before-reservation",
+        root_trace_id="root",
+        kind=OperationKind.DISPATCH,
+        request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
+        request_fingerprint="fingerprint",
+        status=OperationStatus.RUNNING,
+        task_ids=[task_id],
+        execution_epoch=1,
+        started_at="2026-07-18T00:00:00+00:00",
+    )
+    await persist_operation(store, operation)
+
+    recovered = await coordinator.recover_operations("root")
+    assert recovered[0].status == OperationStatus.STOPPED
+    resumed = await coordinator.resume_operation(
+        "root", operation.operation_id, idempotency_key="resume-safe"
+    )
+    completed = await coordinator.await_operation("root", resumed.operation_id)
+
+    ledger = await store.load("root")
+    assert completed.status == OperationStatus.COMPLETED
+    assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
+    assert executor.worker_calls == executor.validator_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_recover_submitted_attempt_resumes_at_validation_only(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "resume submitted")
+    operation = BackgroundOperation(
+        operation_id="crashed-after-submit",
+        root_trace_id="root",
+        kind=OperationKind.DISPATCH,
+        request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
+        request_fingerprint="fingerprint",
+        status=OperationStatus.RUNNING,
+        task_ids=[task_id],
+        execution_epoch=1,
+        started_at="2026-07-18T00:00:00+00:00",
+    )
+    await persist_operation(store, operation)
+    reserved = await coordinator._create_attempt(
+        "root",
+        task_id,
+        "worker",
+        None,
+        operation_id=operation.operation_id,
+        execution_epoch=1,
+    )
+    await coordinator.submit_attempt(
+        {
+            "role": AgentRole.WORKER.value,
+            "root_trace_id": "root",
+            "task_id": task_id,
+            "attempt_id": reserved["attempt_id"],
+            "trace_id": reserved["worker_trace_id"],
+            "spec_version": 1,
+            "operation_id": operation.operation_id,
+            "execution_epoch": 1,
+            "tool_call_id": "submitted-before-crash",
+        },
+        AttemptSubmission(
+            summary="submitted",
+            artifact_refs=[ArtifactRef(uri="memory://submitted", version="1")],
+        ),
+    )
+
+    await coordinator.recover_operations("root")
+    resumed = await coordinator.resume_operation("root", operation.operation_id)
+    await coordinator.await_operation("root", resumed.operation_id)
+
+    ledger = await store.load("root")
+    assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
+    assert executor.worker_calls == 0
+    assert executor.validator_calls == 1
+    assert len(ledger.tasks[task_id].attempt_ids) == 1
+
+
+@pytest.mark.asyncio
+async def test_recover_started_attempt_is_not_resumable(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "unsafe crash")
+    operation = BackgroundOperation(
+        operation_id="crashed-during-worker",
+        root_trace_id="root",
+        kind=OperationKind.DISPATCH,
+        request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
+        request_fingerprint="fingerprint",
+        status=OperationStatus.RUNNING,
+        task_ids=[task_id],
+        execution_epoch=1,
+        started_at="2026-07-18T00:00:00+00:00",
+    )
+    await persist_operation(store, operation)
+    reserved = await coordinator._create_attempt(
+        "root",
+        task_id,
+        "worker",
+        None,
+        operation_id=operation.operation_id,
+        execution_epoch=1,
+    )
+    ledger = await store.load("root")
+    ledger.attempts[reserved["attempt_id"]].started_at = "2026-07-18T00:00:01+00:00"
+    await store.commit(ledger, ledger.revision)
+
+    await coordinator.recover_operations("root")
+
+    ledger = await store.load("root")
+    assert ledger.attempts[reserved["attempt_id"]].status == AttemptStatus.STOPPED
+    assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
+    with pytest.raises(TaskConflict, match="resume_not_safe"):
+        await coordinator.resume_operation("root", operation.operation_id)
+
+
+@pytest.mark.asyncio
+async def test_remote_stop_cannot_be_overwritten_and_submitted_attempt_resumes_validation(
+    tmp_path,
+):
+    running_executor = SubmittedBlockingExecutor()
+    coordinator_a, store, trace_store = await make_coordinator(
+        tmp_path, running_executor
+    )
+    task_id = await create_task(coordinator_a, "remote stop after submit")
+    operation = await coordinator_a.start_operation(
+        "root", OperationKind.DISPATCH, task_ids=[task_id]
+    )
+    await asyncio.wait_for(running_executor.submitted.wait(), timeout=1)
+
+    resume_executor = FakeExecutor([])
+    coordinator_b = second_coordinator(
+        tmp_path, store, trace_store, resume_executor
+    )
+    stopped = await coordinator_b.stop_operation("root", operation.operation_id)
+    running_executor.release.set()
+    await coordinator_a.runtime.wait("root", operation.operation_id)
+
+    ledger = await store.load("root")
+    assert stopped.status == OperationStatus.STOPPED
+    assert ledger.operations[operation.operation_id].status == OperationStatus.STOPPED
+    assert ledger.tasks[task_id].status == TaskStatus.AWAITING_VALIDATION
+    assert ledger.tasks[task_id].validation_ids == []
+
+    resumed = await coordinator_b.resume_operation("root", operation.operation_id)
+    completed = await coordinator_b.await_operation("root", resumed.operation_id)
+    ledger = await store.load("root")
+    assert completed.status == OperationStatus.COMPLETED
+    assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
+    assert resume_executor.worker_calls == 0
+    assert resume_executor.validator_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_past_deadline_never_starts_agents_and_is_part_of_idempotency(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "already expired")
+
+    operation = await coordinator.start_operation(
+        "root",
+        OperationKind.DISPATCH,
+        task_ids=[task_id],
+        deadline_at="2000-01-01T00:00:00Z",
+        idempotency_key="deadline-key",
+    )
+    completed = await coordinator.await_operation("root", operation.operation_id)
+    assert completed.status == OperationStatus.FAILED
+    assert "deadline" in (completed.error or "").lower()
+    assert executor.worker_calls == executor.validator_calls == 0
+
+    with pytest.raises(TaskConflict, match="different operation or request"):
+        await coordinator.start_operation(
+            "root",
+            OperationKind.DISPATCH,
+            task_ids=[task_id],
+            deadline_at="2040-01-01T00:00:00+00:00",
+            idempotency_key="deadline-key",
+        )
+
+    ledger = await store.load("root")
+    command = ledger.command_records["root:operation.dispatch:deadline-key"]
+    assert command.operation_id == operation.operation_id
+    events = await store.list_events("root", limit=1000)
+    started = [item for item in events.events if item.event_type == "operation_started"]
+    assert started[-1].operation_id == operation.operation_id
+
+
+@pytest.mark.asyncio
+async def test_await_operation_polls_shared_store_when_other_runtime_owns_task(tmp_path):
+    executor = FakeExecutor([], delay=0.05)
+    coordinator_a, store, trace_store = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator_a, "cross coordinator await")
+    operation = await coordinator_a.start_operation(
+        "root", OperationKind.DISPATCH, task_ids=[task_id]
+    )
+    while (await coordinator_a.get_operation("root", operation.operation_id)).status != OperationStatus.RUNNING:
+        await asyncio.sleep(0)
+
+    observer = second_coordinator(tmp_path, store, trace_store, FakeExecutor([]))
+    completed = await asyncio.wait_for(
+        observer.await_operation("root", operation.operation_id), timeout=2
+    )
+    assert completed.status == OperationStatus.COMPLETED
+
+
+@pytest.mark.asyncio
+async def test_unstarted_revalidation_is_reused_after_remote_stop_and_resume(tmp_path):
+    initial_executor = FakeExecutor([], submit_validator=False)
+    coordinator_a, store, trace_store = await make_coordinator(
+        tmp_path, initial_executor
+    )
+    task_id = await create_task(coordinator_a, "resume revalidation")
+    first = (await coordinator_a.dispatch_tasks("root", [task_id]))[0]
+    assert first.validation_id
+    assert (await store.load("root")).tasks[task_id].status == TaskStatus.NEEDS_REPLAN
+
+    entered = asyncio.Event()
+    release = asyncio.Event()
+    original_advance = coordinator_a.advance_validation
+
+    async def pause_before_validation(*args, **kwargs):
+        entered.set()
+        await release.wait()
+        return await original_advance(*args, **kwargs)
+
+    coordinator_a.advance_validation = pause_before_validation
+    operation = await coordinator_a.start_operation(
+        "root",
+        OperationKind.REVALIDATE,
+        task_id=task_id,
+        attempt_id=first.attempt_id,
+    )
+    await asyncio.wait_for(entered.wait(), timeout=1)
+
+    resume_executor = FakeExecutor([])
+    coordinator_b = second_coordinator(
+        tmp_path, store, trace_store, resume_executor
+    )
+    await coordinator_b.stop_operation("root", operation.operation_id)
+    release.set()
+    await coordinator_a.runtime.wait("root", operation.operation_id)
+
+    ledger = await store.load("root")
+    validation_id = ledger.operations[operation.operation_id].validation_ids[-1]
+    assert ledger.validations[validation_id].started_at is None
+    assert ledger.operations[operation.operation_id].status == OperationStatus.STOPPED
+
+    resumed = await coordinator_b.resume_operation("root", operation.operation_id)
+    completed = await coordinator_b.await_operation("root", resumed.operation_id)
+    ledger = await store.load("root")
+    assert completed.status == OperationStatus.COMPLETED
+    assert ledger.operations[operation.operation_id].validation_ids == [validation_id]
+    assert ledger.validations[validation_id].status.value == "completed"
+    assert resume_executor.validator_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_recovery_expires_reserved_unstarted_attempt_after_deadline(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "expired reserved attempt")
+    operation = BackgroundOperation(
+        operation_id="expired-attempt-operation",
+        root_trace_id="root",
+        kind=OperationKind.DISPATCH,
+        request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
+        request_fingerprint="fingerprint",
+        status=OperationStatus.RUNNING,
+        task_ids=[task_id],
+        execution_epoch=1,
+        deadline_at="2000-01-01T00:00:00+00:00",
+    )
+    await persist_operation(store, operation)
+    reserved = await coordinator._create_attempt(
+        "root",
+        task_id,
+        "worker",
+        None,
+        operation_id=operation.operation_id,
+        execution_epoch=1,
+    )
+
+    await coordinator.recover_operations("root")
+    ledger = await store.load("root")
+    assert ledger.operations[operation.operation_id].status == OperationStatus.FAILED
+    assert ledger.attempts[reserved["attempt_id"]].status == AttemptStatus.EXPIRED
+    assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
+
+
+@pytest.mark.asyncio
+async def test_recovery_expires_reserved_unstarted_validation_after_deadline(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "expired reserved validation")
+    operation = BackgroundOperation(
+        operation_id="expired-validation-operation",
+        root_trace_id="root",
+        kind=OperationKind.DISPATCH,
+        request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
+        request_fingerprint="fingerprint",
+        status=OperationStatus.RUNNING,
+        task_ids=[task_id],
+        execution_epoch=1,
+        deadline_at="2000-01-01T00:00:00+00:00",
+    )
+    await persist_operation(store, operation)
+    reserved = await coordinator._create_attempt(
+        "root",
+        task_id,
+        "worker",
+        None,
+        operation_id=operation.operation_id,
+        execution_epoch=1,
+    )
+    await coordinator.submit_attempt(
+        {
+            "role": AgentRole.WORKER.value,
+            "root_trace_id": "root",
+            "task_id": task_id,
+            "attempt_id": reserved["attempt_id"],
+            "trace_id": reserved["worker_trace_id"],
+            "spec_version": 1,
+            "operation_id": operation.operation_id,
+            "execution_epoch": 1,
+            "tool_call_id": "submit-before-expiry",
+        },
+        AttemptSubmission(
+            summary="submitted",
+            artifact_refs=[ArtifactRef(uri="memory://submitted", version="1")],
+        ),
+    )
+    validation_id = await coordinator._start_validation(
+        "root",
+        task_id,
+        reserved["attempt_id"],
+        operation_id=operation.operation_id,
+        execution_epoch=1,
+    )
+
+    await coordinator.recover_operations("root")
+    ledger = await store.load("root")
+    assert ledger.operations[operation.operation_id].status == OperationStatus.FAILED
+    assert ledger.validations[validation_id].status.value == "expired"
+    assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
+
+
+@pytest.mark.asyncio
+async def test_terminal_operation_cannot_reserve_a_late_attempt(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "reject late reservation")
+    operation = BackgroundOperation(
+        operation_id="stopped-operation",
+        root_trace_id="root",
+        kind=OperationKind.DISPATCH,
+        request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
+        request_fingerprint="fingerprint",
+        status=OperationStatus.STOPPED,
+        task_ids=[task_id],
+        execution_epoch=4,
+    )
+    await persist_operation(store, operation)
+
+    with pytest.raises(TaskConflict, match="stale|stopping"):
+        await coordinator._create_attempt(
+            "root",
+            task_id,
+            "worker",
+            None,
+            operation_id=operation.operation_id,
+            execution_epoch=4,
+        )
+
+    ledger = await store.load("root")
+    assert ledger.tasks[task_id].attempt_ids == []
+    assert ledger.tasks[task_id].status == TaskStatus.PENDING
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "criterion_ids",
+    [["c1", "c1"], ["c1", "unknown"]],
+)
+async def test_validation_report_rejects_duplicate_or_unknown_criteria(
+    tmp_path,
+    criterion_ids,
+):
+    executor = BlockingValidatorExecutor()
+    coordinator, _, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "strict report schema")
+    operation = await coordinator.start_operation(
+        "root", OperationKind.DISPATCH, task_ids=[task_id]
+    )
+    await asyncio.wait_for(executor.validator_started.wait(), timeout=1)
+    context = executor.validator_context
+
+    with pytest.raises(ValueError, match="duplicate|unknown"):
+        await coordinator.submit_validation(
+            {
+                **context,
+                "role": AgentRole.VALIDATOR.value,
+                "trace_id": context["validator_trace_id"],
+                "tool_call_id": f"bad-report-{criterion_ids[-1]}",
+            },
+            ValidationVerdict.PASSED,
+            [
+                CriterionResult(item, ValidationVerdict.PASSED, "checked")
+                for item in criterion_ids
+            ],
+            "bad report",
+            [],
+            [],
+            [],
+            "reject",
+        )
+    await coordinator.stop_operation("root", operation.operation_id)

+ 564 - 0
agent/tests/test_orchestration_v2_control.py

@@ -0,0 +1,564 @@
+import asyncio
+from dataclasses import replace
+from types import SimpleNamespace
+
+import pytest
+
+from agent import AgentExecutor
+from agent.orchestration.config import OrchestrationConfig
+from agent.orchestration.coordinator import TaskCoordinator
+from agent.orchestration.executor import LocalAgentExecutor
+from agent.orchestration.models import (
+    AttemptStatus,
+    AttemptSubmission,
+    ExecutionStats,
+    FailureCode,
+    TaskAttempt,
+    TaskStatus,
+    ValidationVerdict,
+    utc_now,
+)
+from agent.orchestration.protocols import AgentExecutor as ProtocolAgentExecutor
+from agent.orchestration.protocols import WorkerRunResult
+from agent.orchestration.store import (
+    FileSystemArtifactStore,
+    FileSystemTaskStore,
+    TraceEventSink,
+)
+
+from test_coordinator_integration import FakeExecutor, create_task, make_coordinator
+
+
+def test_v2_control_config_defaults_preserve_unbounded_v1_execution():
+    config = OrchestrationConfig()
+
+    assert config.worker_timeout_seconds is None
+    assert config.validator_timeout_seconds is None
+    assert config.stop_grace_seconds == 5.0
+
+
+def test_agent_executor_port_is_available_from_public_package():
+    assert AgentExecutor is ProtocolAgentExecutor
+
+
+@pytest.mark.parametrize(
+    ("field", "value"),
+    [
+        ("worker_timeout_seconds", 0),
+        ("worker_timeout_seconds", -0.1),
+        ("validator_timeout_seconds", 0),
+        ("validator_timeout_seconds", -1),
+        ("validator_timeout_seconds", float("nan")),
+        ("worker_timeout_seconds", float("inf")),
+        ("worker_timeout_seconds", True),
+        ("stop_grace_seconds", -0.1),
+        ("stop_grace_seconds", float("nan")),
+        ("stop_grace_seconds", float("inf")),
+        ("stop_grace_seconds", False),
+    ],
+)
+def test_v2_control_config_rejects_invalid_durations(field, value):
+    with pytest.raises(ValueError, match=field):
+        OrchestrationConfig(**{field: value})
+
+
+@pytest.mark.parametrize("field", ["max_parallel_tasks", "max_repair_continuations"])
+def test_v2_control_config_rejects_boolean_integer_limits(field):
+    with pytest.raises(ValueError, match=field):
+        OrchestrationConfig(**{field: True})
+
+
+def test_v2_control_config_allows_immediate_stop_and_explicit_timeouts():
+    config = OrchestrationConfig(
+        worker_timeout_seconds=1.5,
+        validator_timeout_seconds=2,
+        stop_grace_seconds=0,
+    )
+
+    assert config.worker_timeout_seconds == 1.5
+    assert config.validator_timeout_seconds == 2
+    assert config.stop_grace_seconds == 0
+
+
+@pytest.mark.asyncio
+async def test_local_executor_stop_delegates_every_request():
+    class Runner:
+        def __init__(self):
+            self.calls = []
+
+        async def stop(self, trace_id):
+            self.calls.append(trace_id)
+            return len(self.calls) == 1
+
+    runner = Runner()
+    executor = LocalAgentExecutor(runner)
+
+    assert await executor.stop("worker-trace") is True
+    assert await executor.stop("worker-trace") is False
+    assert runner.calls == ["worker-trace", "worker-trace"]
+
+
+@pytest.mark.asyncio
+async def test_local_executor_stop_does_not_hide_runner_failure():
+    class Runner:
+        async def stop(self, trace_id):
+            raise RuntimeError(f"cannot stop {trace_id}")
+
+    with pytest.raises(RuntimeError, match="cannot stop validator-trace"):
+        await LocalAgentExecutor(Runner()).stop("validator-trace")
+
+
+@pytest.mark.asyncio
+async def test_hard_cancellation_is_not_normalized_as_agent_failure():
+    class Runner:
+        async def run_result(self, **kwargs):
+            raise asyncio.CancelledError
+
+    with pytest.raises(asyncio.CancelledError):
+        await LocalAgentExecutor(Runner()).run_worker({
+            "worker_preset": "worker",
+            "worker_trace_id": "worker-trace",
+            "root_trace_id": "root",
+            "task_id": "task",
+            "spec_version": 1,
+            "attempt_id": "attempt",
+            "task_spec": {},
+            "continue_trace_id": None,
+        })
+
+
+@pytest.mark.asyncio
+async def test_failed_stats_lookup_does_not_mask_original_runner_error():
+    class Store:
+        async def get_trace(self, trace_id):
+            raise RuntimeError("stats store unavailable")
+
+    class Runner:
+        trace_store = Store()
+
+        async def run_result(self, **kwargs):
+            raise RuntimeError("original runner failure")
+
+    result = await LocalAgentExecutor(Runner()).run_worker({
+        "worker_preset": "worker",
+        "worker_trace_id": "worker-trace",
+        "root_trace_id": "root",
+        "task_id": "task",
+        "spec_version": 1,
+        "attempt_id": "attempt",
+        "task_spec": {},
+        "continue_trace_id": None,
+    })
+
+    assert result.error == "original runner failure"
+    assert result.execution_stats.failure_code == FailureCode.EXECUTOR_ERROR
+
+
+@pytest.mark.asyncio
+async def test_worker_protects_operation_epoch_and_deadline():
+    class Runner:
+        def __init__(self):
+            self.config = None
+
+        async def run_result(self, *, messages, config):
+            self.config = config
+            return {"status": "completed", "summary": "done"}
+
+    runner = Runner()
+    executor = LocalAgentExecutor(runner)
+    result = await executor.run_worker({
+        "worker_preset": "worker",
+        "worker_trace_id": "worker-trace",
+        "root_trace_id": "root",
+        "task_id": "task",
+        "spec_version": 2,
+        "attempt_id": "attempt",
+        "task_spec": {"objective": "test"},
+        "continue_trace_id": None,
+        "operation_id": "operation",
+        "execution_epoch": 7,
+        "deadline": "2030-01-01T00:00:00+00:00",
+        "untrusted": "must-not-leak",
+    })
+
+    assert result.trace_id == "worker-trace"
+    assert result.status == "completed"
+    assert runner.config.trace_id is None
+    assert runner.config.new_trace_id == "worker-trace"
+    assert runner.config.context == {
+        "root_trace_id": "root",
+        "task_id": "task",
+        "spec_version": 2,
+        "attempt_id": "attempt",
+        "completion_policy": "explicit_validation",
+        "operation_id": "operation",
+        "execution_epoch": 7,
+        "deadline": "2030-01-01T00:00:00+00:00",
+    }
+
+
+@pytest.mark.asyncio
+async def test_validator_omits_absent_v2_context_and_normalizes_empty_result():
+    class Runner:
+        async def run_result(self, *, messages, config):
+            self.config = config
+            return {}
+
+    runner = Runner()
+    result = await LocalAgentExecutor(runner).run_validator({
+        "validator_preset": "validator",
+        "validator_trace_id": "validator-trace",
+        "root_trace_id": "root",
+        "task_id": "task",
+        "spec_version": 1,
+        "attempt_id": "attempt",
+        "snapshot_id": "snapshot",
+        "validation_id": "validation",
+        "task_spec": {},
+        "artifact_snapshot": {},
+    })
+
+    assert result.trace_id == "validator-trace"
+    assert result.status == "failed"
+    assert result.summary == ""
+    assert result.error is None
+    assert runner.config.trace_id is None
+    assert runner.config.new_trace_id == "validator-trace"
+    assert "operation_id" not in runner.config.context
+    assert "execution_epoch" not in runner.config.context
+    assert "deadline" not in runner.config.context
+
+
+@pytest.mark.parametrize(
+    "kwargs",
+    [
+        {"primary_model": " "},
+        {"primary_model": 123},
+        {"total_tokens": -1},
+        {"total_tokens": True},
+        {"total_cost": -0.1},
+        {"total_cost": float("nan")},
+        {"total_cost": float("inf")},
+        {"total_cost": True},
+        {"failure_code": "unknown"},
+        {"failure_code": 123},
+    ],
+)
+def test_execution_stats_reject_invalid_values(kwargs):
+    with pytest.raises(ValueError, match="ExecutionStats"):
+        ExecutionStats(**kwargs)
+
+
+def test_execution_stats_normalizes_known_failure_code_string():
+    assert ExecutionStats(failure_code="timeout").failure_code == FailureCode.TIMEOUT
+
+
+def test_old_stage_without_stats_roundtrips_and_duration_is_derived():
+    attempt = TaskAttempt.from_dict({
+        "attempt_id": "attempt",
+        "task_id": "task",
+        "spec_version": 1,
+        "worker_trace_id": "trace",
+        "worker_preset": "worker",
+        "execution_mode": "new",
+        "started_at": "2026-07-18T00:00:00Z",
+        "completed_at": "2026-07-18T00:00:01.250+00:00",
+    })
+
+    assert attempt.execution_stats is None
+    assert attempt.duration_ms == 1250
+    assert replace(attempt, completed_at="2026-07-17T23:59:59Z").duration_ms == 0
+    assert replace(attempt, completed_at=None).duration_ms is None
+
+
+@pytest.mark.asyncio
+async def test_repair_continuation_records_only_trace_usage_delta():
+    trace = SimpleNamespace(
+        context={}, total_tokens=100, total_cost=1.0, model="repair-model"
+    )
+
+    class Coordinator:
+        async def validate_continue_from(self, *args):
+            return "trace"
+
+    class Store:
+        async def get_trace(self, trace_id):
+            return trace
+
+        async def update_trace(self, trace_id, **updates):
+            trace.context = updates["context"]
+
+    class Runner:
+        task_coordinator = Coordinator()
+        trace_store = Store()
+
+        async def run_result(self, **kwargs):
+            trace.total_tokens = 130
+            trace.total_cost = 1.4
+            return {
+                "trace_id": "trace",
+                "status": "completed",
+                "stats": {"total_tokens": 130, "total_cost": 1.4},
+            }
+
+    result = await LocalAgentExecutor(Runner()).run_worker({
+        "worker_preset": "worker",
+        "worker_trace_id": "trace",
+        "root_trace_id": "root",
+        "task_id": "task",
+        "spec_version": 1,
+        "attempt_id": "new-attempt",
+        "prior_attempt_id": "old-attempt",
+        "task_spec": {},
+        "continue_trace_id": "trace",
+    })
+
+    assert result.execution_stats.primary_model == "repair-model"
+    assert result.execution_stats.total_tokens == 30
+    assert result.execution_stats.total_cost == pytest.approx(0.4)
+
+
+class StatsExecutor(FakeExecutor):
+    async def run_worker(self, context):
+        result = await super().run_worker(context)
+        return replace(
+            result,
+            execution_stats=ExecutionStats(
+                primary_model="worker-model", total_tokens=12, total_cost=0.12
+            ),
+        )
+
+    async def run_validator(self, context):
+        result = await super().run_validator(context)
+        return replace(
+            result,
+            execution_stats=ExecutionStats(
+                primary_model="validator-model", total_tokens=7, total_cost=0.07
+            ),
+        )
+
+
+@pytest.mark.asyncio
+async def test_successful_stage_stats_persist_once_and_failed_verdict_is_not_run_failure(
+    tmp_path,
+):
+    executor = StatsExecutor([ValidationVerdict.FAILED])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "persist execution stats")
+
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+    ledger = await store.load("root")
+    attempt = ledger.attempts[result.attempt_id]
+    validation = ledger.validations[result.validation_id]
+
+    assert attempt.execution_stats.primary_model == "worker-model"
+    assert attempt.execution_stats.total_tokens == 12
+    assert validation.verdict == ValidationVerdict.FAILED
+    assert validation.execution_stats.primary_model == "validator-model"
+    assert validation.execution_stats.failure_code is None
+    assert attempt.duration_ms is not None
+    assert validation.duration_ms is not None
+
+
+@pytest.mark.asyncio
+async def test_completed_worker_without_submit_is_protocol_failure(tmp_path):
+    executor = StatsExecutor([], submit_worker=False)
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "protocol failure")
+
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+    attempt = (await store.load("root")).attempts[result.attempt_id]
+
+    assert attempt.execution_stats.failure_code == FailureCode.PROTOCOL_VIOLATION
+    assert attempt.execution_stats.total_tokens == 12
+
+
+@pytest.mark.asyncio
+async def test_protocol_failure_overrides_executor_supplied_failure_code(tmp_path):
+    class MisclassifiedExecutor(StatsExecutor):
+        async def run_worker(self, context):
+            result = await super().run_worker(context)
+            return replace(
+                result,
+                execution_stats=replace(
+                    result.execution_stats,
+                    failure_code=FailureCode.AGENT_FAILED,
+                ),
+            )
+
+    executor = MisclassifiedExecutor([], submit_worker=False)
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "protocol code wins")
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+    attempt = (await store.load("root")).attempts[result.attempt_id]
+
+    assert attempt.execution_stats.failure_code == FailureCode.PROTOCOL_VIOLATION
+
+
+@pytest.mark.asyncio
+async def test_late_failure_cannot_overwrite_concurrent_submission(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "concurrent submission")
+    operation = await coordinator.start_operation(
+        "root", "dispatch", task_ids=[task_id]
+    )
+    completed = await coordinator.await_operation("root", operation.operation_id)
+    ledger = await store.load("root")
+    attempt_id = completed.attempt_ids[0]
+    original = ledger.attempts[attempt_id]
+
+    await coordinator._mark_worker_failure(
+        "root",
+        task_id,
+        attempt_id,
+        "failed",
+        "late failure",
+        ExecutionStats(failure_code=FailureCode.AGENT_FAILED),
+        operation.operation_id,
+        completed.execution_epoch,
+    )
+    after = (await store.load("root")).attempts[attempt_id]
+
+    assert after.status == original.status
+    assert after.error == original.error
+    assert after.execution_stats == original.execution_stats
+
+
+@pytest.mark.asyncio
+async def test_timeout_persists_machine_failure_code(tmp_path):
+    class SlowExecutor(FakeExecutor):
+        async def run_worker(self, context):
+            await asyncio.sleep(1)
+            return WorkerRunResult(context["worker_trace_id"], "completed")
+
+    executor = SlowExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    coordinator.config = OrchestrationConfig(worker_timeout_seconds=0.001)
+    task_id = await create_task(coordinator, "timeout stats")
+
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+    attempt = (await store.load("root")).attempts[result.attempt_id]
+
+    assert attempt.status == AttemptStatus.EXPIRED
+    assert attempt.execution_stats.failure_code == FailureCode.TIMEOUT
+
+
+@pytest.mark.asyncio
+async def test_remote_stop_wins_over_late_worker_failure(tmp_path):
+    class LateFailureExecutor:
+        def __init__(self):
+            self.started = asyncio.Event()
+            self.release = asyncio.Event()
+
+        async def run_worker(self, context):
+            self.started.set()
+            await self.release.wait()
+            return WorkerRunResult(
+                context["worker_trace_id"],
+                "failed",
+                error="late failure",
+                execution_stats=ExecutionStats(
+                    total_tokens=3, failure_code=FailureCode.AGENT_FAILED
+                ),
+            )
+
+        async def run_validator(self, context):
+            raise AssertionError("validator must not run")
+
+        async def stop(self, trace_id):
+            return True
+
+    executor = LateFailureExecutor()
+    coordinator_a, store, trace_store = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator_a, "remote stop stats")
+    operation = await coordinator_a.start_operation("root", "dispatch", task_ids=[task_id])
+    await asyncio.wait_for(executor.started.wait(), timeout=1)
+
+    observer_executor = FakeExecutor([])
+    coordinator_b = TaskCoordinator(
+        store,
+        FileSystemArtifactStore(str(tmp_path)),
+        trace_store,
+        OrchestrationConfig(stop_grace_seconds=0),
+        TraceEventSink(str(tmp_path)),
+        observer_executor,
+    )
+    observer_executor.coordinator = coordinator_b
+    await coordinator_b.stop_operation("root", operation.operation_id)
+    executor.release.set()
+    await coordinator_a.runtime.wait("root", operation.operation_id)
+
+    ledger = await store.load("root")
+    attempt = ledger.attempts[ledger.operations[operation.operation_id].attempt_ids[0]]
+    assert attempt.status == AttemptStatus.STOPPED
+    assert attempt.execution_stats.failure_code == FailureCode.STOPPED
+    assert attempt.error == "Operation stopped"
+    assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
+
+
+@pytest.mark.asyncio
+async def test_cas_retry_does_not_overwrite_concurrent_attempt_submission(tmp_path):
+    class ConcurrentSubmissionStore:
+        def __init__(self, inner):
+            self.inner = inner
+            self.base_path = inner.base_path
+            self.injected = False
+
+        async def load(self, root_trace_id):
+            return await self.inner.load(root_trace_id)
+
+        async def commit(
+            self,
+            ledger,
+            expected_revision,
+            idempotency_key=None,
+            event=None,
+        ):
+            if (
+                event is not None
+                and event.event_type == "worker_failed"
+                and not self.injected
+            ):
+                self.injected = True
+                winner = await self.inner.load(ledger.root_trace_id)
+                attempt = next(
+                    item
+                    for item in winner.attempts.values()
+                    if item.status == AttemptStatus.RUNNING
+                )
+                attempt.status = AttemptStatus.SUBMITTED
+                attempt.submission = AttemptSubmission(summary="concurrent winner")
+                attempt.completed_at = utc_now()
+                winner.tasks[attempt.task_id].status = TaskStatus.AWAITING_VALIDATION
+                await self.inner.commit(winner, winner.revision)
+            return await self.inner.commit(
+                ledger,
+                expected_revision,
+                idempotency_key=idempotency_key,
+                event=event,
+            )
+
+        async def list_events(self, *args, **kwargs):
+            return await self.inner.list_events(*args, **kwargs)
+
+        async def list_recoverable(self, *args, **kwargs):
+            return await self.inner.list_recoverable(*args, **kwargs)
+
+    task_store = ConcurrentSubmissionStore(FileSystemTaskStore(str(tmp_path)))
+    executor = StatsExecutor([], submit_worker=False)
+    coordinator, store, _ = await make_coordinator(
+        tmp_path, executor, task_store=task_store
+    )
+    task_id = await create_task(coordinator, "CAS submit winner")
+
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+    ledger = await store.load("root")
+    attempt = ledger.attempts[result.attempt_id]
+
+    assert task_store.injected is True
+    assert attempt.status == AttemptStatus.SUBMITTED
+    assert attempt.submission.summary == "concurrent winner"
+    assert attempt.error is None
+    assert attempt.execution_stats is None
+    assert ledger.tasks[task_id].status == TaskStatus.AWAITING_VALIDATION

+ 456 - 0
agent/tests/test_orchestration_validation_policy.py

@@ -0,0 +1,456 @@
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from agent.orchestration.config import OrchestrationConfig
+from agent.orchestration.evidence import (
+    EvidenceBudgetExceeded,
+    EvidenceOwnershipError,
+    EvidenceProviderError,
+    EvidenceProviderResult,
+    EvidenceQuery,
+)
+from agent.orchestration.models import (
+    AgentRole,
+    ArtifactRef,
+    ArtifactSnapshot,
+    AttemptSubmission,
+    TaskAttempt,
+    TaskRecord,
+    TaskSpec,
+    ValidationMode,
+    ValidationPlan,
+    ValidationVerdict,
+)
+from agent.orchestration.protocols import WorkerRunResult
+from agent.orchestration.validation_policy import (
+    DefaultValidationPolicy,
+    DeterministicRuleResult,
+    RuleBasedDeterministicValidator,
+    ValidationContext,
+)
+
+from test_coordinator_integration import FakeExecutor, create_task, make_coordinator
+
+
+def make_context(root: str = "root") -> ValidationContext:
+    task = TaskRecord(
+        task_id="task-1",
+        goal_id=None,
+        parent_task_id=None,
+        display_path="1",
+        specs=[TaskSpec(version=1, objective="validate")],
+    )
+    attempt = TaskAttempt(
+        attempt_id="attempt-1",
+        task_id=task.task_id,
+        spec_version=1,
+        worker_trace_id="worker-1",
+        worker_preset="worker",
+        execution_mode="new",
+        snapshot_id="snapshot-1",
+    )
+    snapshot = ArtifactSnapshot(
+        snapshot_id="snapshot-1",
+        attempt_id=attempt.attempt_id,
+        normalized_content={"summary": "done"},
+        sha256="digest",
+        artifact_refs=[],
+        evidence_refs=[],
+    )
+    return ValidationContext(root, task, attempt, snapshot)
+
+
+class StaticRule:
+    def __init__(self, rule_id, verdict):
+        self.rule_id = rule_id
+        self.verdict = verdict
+
+    def evaluate(self, context):
+        assert context.snapshot.snapshot_id
+        return DeterministicRuleResult(self.rule_id, self.verdict, "checked")
+
+
+class AsyncRule(StaticRule):
+    async def evaluate(self, context):
+        await asyncio.sleep(0)
+        return super().evaluate(context)
+
+
+class ErrorRule:
+    rule_id = "error"
+
+    def evaluate(self, context):
+        raise RuntimeError("broken rule")
+
+
+class DeterministicPolicy:
+    def __init__(self, verdict=ValidationVerdict.PASSED):
+        self.verdict = verdict
+
+    def plan(self, context):
+        return ValidationPlan(
+            mode=ValidationMode.DETERMINISTIC,
+            validator_preset=None,
+            rule_ids=[item.criterion_id for item in context.task.current_spec.acceptance_criteria],
+        )
+
+
+class FixedPolicy:
+    def __init__(self, plan):
+        self.fixed_plan = plan
+
+    def plan(self, context):
+        return self.fixed_plan
+
+
+class InvalidBlankPolicy:
+    def plan(self, context):
+        plan = ValidationPlan()
+        object.__setattr__(plan, "validator_preset", " ")
+        return plan
+
+
+@pytest.mark.asyncio
+async def test_default_policy_and_validation_plan_roundtrip():
+    plan = DefaultValidationPolicy("careful-validator").plan(make_context())
+    assert plan.mode == ValidationMode.AGENT
+    assert plan.validator_preset == "careful-validator"
+    assert ValidationPlan.from_dict(plan.to_dict()) == plan
+
+    deterministic = ValidationPlan(
+        mode=ValidationMode.DETERMINISTIC,
+        validator_preset=None,
+        rule_ids=["schema", "checksum"],
+        max_evidence_queries=2,
+    )
+    assert ValidationPlan.from_dict(deterministic.to_dict()) == deterministic
+    assert ValidationPlan.from_dict(
+        {"mode": "deterministic", "rule_ids": []}
+    ).validator_preset is None
+    with pytest.raises(ValueError, match="cannot contain"):
+        ValidationPlan(rule_ids=["unexpected"])
+    with pytest.raises(ValueError, match="cannot be blank"):
+        ValidationPlan(validator_preset=" ")
+    for kwargs in (
+        {"max_evidence_queries": True},
+        {"max_evidence_queries": 1.5},
+        {"max_evidence_items_per_query": False},
+        {"evidence_timeout_seconds": True},
+    ):
+        with pytest.raises(ValueError):
+            ValidationPlan(**kwargs)
+    with pytest.raises(ValueError, match="cannot select"):
+        ValidationPlan(mode=ValidationMode.DETERMINISTIC)
+    with pytest.raises(ValueError, match="unique"):
+        ValidationPlan(
+            mode=ValidationMode.DETERMINISTIC,
+            validator_preset=None,
+            rule_ids=["same", "same"],
+        )
+
+
+@pytest.mark.asyncio
+async def test_deterministic_rules_aggregate_failures_and_errors():
+    context = make_context()
+    validator = RuleBasedDeterministicValidator(
+        [
+            StaticRule("pass", ValidationVerdict.PASSED),
+            AsyncRule("fail", ValidationVerdict.FAILED),
+            ErrorRule(),
+        ]
+    )
+    result = await validator.validate(
+        context,
+        ValidationPlan(
+            mode=ValidationMode.DETERMINISTIC,
+            validator_preset=None,
+            rule_ids=["pass", "missing", "error", "fail"],
+        ),
+    )
+    assert result.verdict == ValidationVerdict.FAILED
+    assert len(result.errors) == 2
+    assert result.to_dict()["verdict"] == "failed"
+
+    empty = await validator.validate(
+        context,
+        ValidationPlan(mode=ValidationMode.DETERMINISTIC, validator_preset=None),
+    )
+    assert empty.verdict == ValidationVerdict.INCONCLUSIVE
+    with pytest.raises(ValueError, match="deterministic plan"):
+        await validator.validate(context, ValidationPlan())
+    with pytest.raises(ValueError, match="Duplicate"):
+        RuleBasedDeterministicValidator(
+            [
+                StaticRule("same", ValidationVerdict.PASSED),
+                StaticRule("same", ValidationVerdict.PASSED),
+            ]
+        )
+
+
+@pytest.mark.asyncio
+async def test_coordinator_freezes_default_agent_plan(tmp_path):
+    executor = FakeExecutor([ValidationVerdict.PASSED])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    task_id = await create_task(coordinator, "default agent policy")
+
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+    report = (await store.load("root")).validations[result.validation_id]
+
+    assert executor.validator_calls == 1
+    assert report.validation_plan.mode == ValidationMode.AGENT
+    assert report.validation_plan.validator_preset == "validator"
+    assert ValidationPlan.from_dict(report.validation_plan) == report.validation_plan
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "verdict", [ValidationVerdict.PASSED, ValidationVerdict.FAILED]
+)
+async def test_coordinator_executes_deterministic_plan_without_agent_validator(
+    tmp_path, verdict
+):
+    executor = FakeExecutor([])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    coordinator.validation_policy = DeterministicPolicy()
+    coordinator.deterministic_validator = RuleBasedDeterministicValidator(
+        [StaticRule("c1", verdict)]
+    )
+    task_id = await create_task(coordinator, "deterministic")
+
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+    report = (await store.load("root")).validations[result.validation_id]
+
+    assert executor.worker_calls == 1
+    assert executor.validator_calls == 0
+    assert report.validation_plan.mode == ValidationMode.DETERMINISTIC
+    assert report.verdict == verdict
+    assert report.criterion_results[0].criterion_id == "c1"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("rule_ids", [[], ["unknown"]])
+async def test_deterministic_plan_must_cover_hard_criteria_without_unknowns(
+    tmp_path, rule_ids
+):
+    executor = FakeExecutor([])
+    coordinator, _, _ = await make_coordinator(tmp_path, executor)
+    coordinator.validation_policy = FixedPolicy(
+        ValidationPlan(
+            mode=ValidationMode.DETERMINISTIC,
+            validator_preset=None,
+            rule_ids=rule_ids,
+        )
+    )
+    coordinator.deterministic_validator = RuleBasedDeterministicValidator([])
+    task_id = await create_task(coordinator, "invalid deterministic mapping")
+
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+
+    assert "criteria" in (result.error or "")
+    assert executor.validator_calls == 0
+
+
+@pytest.mark.asyncio
+async def test_coordinator_rejects_custom_policy_with_blank_agent_preset(tmp_path):
+    executor = FakeExecutor([])
+    coordinator, _, _ = await make_coordinator(tmp_path, executor)
+    coordinator.validation_policy = InvalidBlankPolicy()
+    task_id = await create_task(coordinator, "invalid preset")
+
+    result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
+
+    assert "cannot be blank" in (result.error or "")
+    assert executor.validator_calls == 0
+
+
+class EvidencePolicy:
+    def __init__(self, max_queries=1, timeout=0.1):
+        self.max_queries = max_queries
+        self.timeout = timeout
+
+    def plan(self, context):
+        return ValidationPlan(
+            max_evidence_queries=self.max_queries,
+            max_evidence_items_per_query=1,
+            evidence_timeout_seconds=self.timeout,
+        )
+
+
+class EvidenceProvider:
+    def __init__(self, *, delay=0, error=None, item_count=1, items=None):
+        self.delay = delay
+        self.error = error
+        self.item_count = item_count
+        self.items = items
+        self.requests = []
+
+    async def query(self, request):
+        self.requests.append(request)
+        if self.delay:
+            await asyncio.sleep(self.delay)
+        if self.error:
+            raise self.error
+        return EvidenceProviderResult(
+            items=(
+                self.items
+                if self.items is not None
+                else [{"index": index} for index in range(self.item_count)]
+            ),
+            evidence_refs=[ArtifactRef(uri="memory://evidence", version="1")],
+        )
+
+
+class BlockingValidatorExecutor:
+    def __init__(self):
+        self.coordinator = None
+        self.context_ready = asyncio.Event()
+        self.context = None
+
+    async def run_worker(self, context):
+        await self.coordinator.submit_attempt(
+            {
+                **context,
+                "role": AgentRole.WORKER.value,
+                "trace_id": context["worker_trace_id"],
+                "tool_call_id": f"submit-{context['attempt_id']}",
+            },
+            AttemptSubmission("done", [ArtifactRef(uri="memory://work", version="1")]),
+        )
+        return WorkerRunResult(context["worker_trace_id"], "completed")
+
+    async def run_validator(self, context):
+        self.context = context
+        self.context_ready.set()
+        await asyncio.Event().wait()
+
+    async def stop(self, trace_id):
+        return True
+
+
+async def running_evidence_validation(tmp_path, provider, *, max_queries=1, timeout=0.1):
+    executor = BlockingValidatorExecutor()
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    coordinator.config = OrchestrationConfig(stop_grace_seconds=0)
+    coordinator.validation_policy = EvidencePolicy(max_queries, timeout)
+    coordinator.evidence_provider = provider
+    task_id = await create_task(coordinator, "evidence")
+    operation = await coordinator.start_operation("root", "dispatch", task_ids=[task_id])
+    await asyncio.wait_for(executor.context_ready.wait(), timeout=1)
+    context = executor.context
+    actor = {
+        **context,
+        "role": AgentRole.VALIDATOR.value,
+        "trace_id": context["validator_trace_id"],
+        "tool_call_id": "evidence-1",
+    }
+    request = EvidenceQuery(
+        root_trace_id="root",
+        task_id=task_id,
+        attempt_id=context["attempt_id"],
+        snapshot_id=context["snapshot_id"],
+        validation_id=context["validation_id"],
+        query="facts",
+        limit=100,
+    )
+    return coordinator, store, operation, actor, request
+
+
+@pytest.mark.asyncio
+async def test_coordinator_evidence_ownership_and_persistent_budget(tmp_path):
+    provider = EvidenceProvider()
+    coordinator, store, operation, actor, request = await running_evidence_validation(
+        tmp_path, provider
+    )
+    response = await coordinator.query_evidence(actor, request)
+    assert response.queries_used == 1
+    assert response.queries_remaining == 0
+    assert provider.requests[0].limit == 1
+    report = (await store.load("root")).validations[request.validation_id]
+    assert report.evidence_queries_used == 1
+    assert next(iter(report.evidence_query_results.values()))["status"] == "completed"
+
+    replay = await coordinator.query_evidence(actor, request)
+    assert replay.queries_used == 1
+    assert len(provider.requests) == 1
+    with pytest.raises(EvidenceBudgetExceeded):
+        await coordinator.query_evidence(
+            {**actor, "tool_call_id": "evidence-2"},
+            EvidenceQuery(**{**request.__dict__, "query": "more"}),
+        )
+    with pytest.raises(EvidenceOwnershipError):
+        await coordinator.query_evidence(
+            {**actor, "tool_call_id": "foreign"},
+            EvidenceQuery(**{**request.__dict__, "snapshot_id": "foreign"}),
+        )
+    with pytest.raises(EvidenceOwnershipError):
+        await coordinator.query_evidence(
+            {**actor, "trace_id": "foreign", "tool_call_id": "foreign-trace"},
+            request,
+        )
+    await coordinator.stop_operation("root", operation.operation_id)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("provider", "message"),
+    [
+        (EvidenceProvider(delay=0.05), "timed out"),
+        (EvidenceProvider(error=RuntimeError("offline")), "offline"),
+        (EvidenceProvider(item_count=2), "more than"),
+        (EvidenceProvider(items=[{"bad": object()}]), "non-JSON"),
+    ],
+)
+async def test_evidence_provider_failures_are_validation_errors(
+    tmp_path, provider, message
+):
+    timeout = 0.001 if provider.delay else 0.1
+    coordinator, store, operation, actor, request = await running_evidence_validation(
+        tmp_path,
+        provider,
+        timeout=timeout,
+    )
+    with pytest.raises(EvidenceProviderError, match=message):
+        await coordinator.query_evidence(actor, request)
+    assert (await store.load("root")).validations[request.validation_id].evidence_queries_used == 1
+    with pytest.raises(EvidenceProviderError, match=message):
+        await coordinator.query_evidence(actor, request)
+    assert len(provider.requests) == 1
+    await coordinator.stop_operation("root", operation.operation_id)
+
+
+@pytest.mark.asyncio
+async def test_concurrent_evidence_replay_calls_provider_once(tmp_path):
+    provider = EvidenceProvider(delay=0.02)
+    coordinator, _, operation, actor, request = await running_evidence_validation(
+        tmp_path,
+        provider,
+    )
+    responses = await asyncio.gather(
+        coordinator.query_evidence(actor, request),
+        coordinator.query_evidence(actor, request),
+    )
+    assert [item.queries_used for item in responses] == [1, 1]
+    assert len(provider.requests) == 1
+    await coordinator.stop_operation("root", operation.operation_id)
+
+
+def test_evidence_query_rejects_empty_scope_and_limit():
+    values = {
+        "root_trace_id": "root",
+        "task_id": "task",
+        "attempt_id": "attempt",
+        "snapshot_id": "snapshot",
+        "validation_id": "validation",
+        "query": "facts",
+    }
+    with pytest.raises(ValueError, match="query"):
+        EvidenceQuery(**{**values, "query": " "})
+    with pytest.raises(ValueError, match="limit"):
+        EvidenceQuery(**values, limit=0)
+    with pytest.raises(ValueError, match="limit"):
+        EvidenceQuery(**values, limit=True)
+    with pytest.raises(ValueError, match="root_trace_id"):
+        EvidenceQuery(**{**values, "root_trace_id": 123})

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä