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

重构(编排): 拆分任务图决策与目标投影

保持 TaskCoordinator 全部公开方法和状态转换语义不变,将任务拓扑、Planner 决策校验和 Goal 投影拆为内部协作组件,并整理执行器与后台操作的边界。
SamLee 22 часов назад
Родитель
Сommit
3980227487

+ 245 - 0
agent/agent/orchestration/_decision_engine.py

@@ -0,0 +1,245 @@
+"""Pure Planner-decision rules for the explicit orchestration aggregate."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import Any, Callable, Dict, Optional
+
+from ._task_graph import ACTIVE_TASK_STATUSES, TERMINAL_TASK_STATUSES, TaskGraph
+from .errors import TaskConflict
+from .models import (
+    DecisionAction,
+    PlannerDecision,
+    TaskLedger,
+    TaskRecord,
+    TaskStatus,
+    ValidationReport,
+    ValidationRunStatus,
+    ValidationVerdict,
+    json_values,
+    new_id,
+    utc_now,
+)
+from .state_machine import transition
+
+
+class DecisionEngine:
+    """Apply a Planner decision to a caller-owned in-memory ledger."""
+
+    def __init__(
+        self,
+        task_graph: TaskGraph,
+        max_repair_continuations: Callable[[], int],
+    ) -> None:
+        self._task_graph = task_graph
+        self._max_repair_continuations = max_repair_continuations
+
+    def apply(
+        self,
+        ledger: TaskLedger,
+        *,
+        task_id: str,
+        validation_id: Optional[str],
+        action: DecisionAction,
+        payload: Dict[str, Any],
+    ) -> Dict[str, Any]:
+        task = self._task(ledger, task_id)
+        before = task.status
+        is_root = task.task_id == ledger.root_task_id
+        validation = ledger.validations.get(validation_id) if validation_id else None
+        attempt_id = (
+            validation.attempt_id
+            if validation
+            else task.attempt_ids[-1]
+            if task.attempt_ids
+            else None
+        )
+        reason = str(payload.get("reason", "")).strip()
+        if not reason and action != DecisionAction.UNBLOCK:
+            raise ValueError("Planner decision reason is required")
+
+        if action == DecisionAction.ACCEPT:
+            self.guard_accept(task, validation, ledger)
+            target = TaskStatus.COMPLETED
+        elif action == DecisionAction.REPAIR:
+            self.guard_replan(task, validation, ledger)
+            count_key = str(task.current_spec_version)
+            used = task.repair_count_by_version.get(count_key, 0)
+            if used >= self._max_repair_continuations():
+                raise TaskConflict(
+                    "Repair continuation limit reached; retry, revise, split, or block"
+                )
+            task.repair_count_by_version[count_key] = used + 1
+            target = TaskStatus.PENDING
+        elif action == DecisionAction.RETRY:
+            self._require_replan_state(task, "Retry")
+            target = TaskStatus.PENDING
+        elif action == DecisionAction.REVISE:
+            self._require_replan_state(task, "Revise")
+            objective = payload.get("objective", task.current_spec.objective)
+            version = task.current_spec_version + 1
+            draft = {
+                "objective": objective,
+                "acceptance_criteria": payload.get(
+                    "acceptance_criteria",
+                    [
+                        json_values(asdict(item))
+                        for item in task.current_spec.acceptance_criteria
+                    ],
+                ),
+                "context_refs": payload.get(
+                    "context_refs",
+                    task.current_spec.context_refs,
+                ),
+            }
+            task.specs.append(TaskGraph.task_spec_from_draft(draft, version=version))
+            task.current_spec_version = version
+            target = TaskStatus.PENDING
+        elif action == DecisionAction.SPLIT:
+            self._require_replan_state(task, "Split")
+            drafts = payload.get("tasks") or []
+            if not drafts:
+                raise ValueError("Split requires payload.tasks")
+            child_ids = self._task_graph.create_child_records(ledger, task, drafts)
+            payload["child_task_ids"] = child_ids
+            target = TaskStatus.WAITING_CHILDREN
+        elif action == DecisionAction.BLOCK:
+            if task.status in TERMINAL_TASK_STATUSES:
+                raise TaskConflict("Terminal task cannot be blocked")
+            if any(
+                child.status in ACTIVE_TASK_STATUSES
+                for child in TaskGraph.nonterminal_descendants(ledger, task)
+            ):
+                raise TaskConflict("Task with active descendants cannot be blocked")
+            task.blocked_reason = reason
+            target = TaskStatus.BLOCKED
+        elif action == DecisionAction.UNBLOCK:
+            if task.status != TaskStatus.BLOCKED:
+                raise TaskConflict("Only a blocked task can be unblocked")
+            task.blocked_reason = None
+            target = TaskStatus.NEEDS_REPLAN
+        elif action == DecisionAction.CANCEL:
+            self._guard_replace_or_cancel(ledger, task, is_root, "cancelled")
+            target = TaskStatus.CANCELLED
+        elif action == DecisionAction.SUPERSEDE:
+            self._guard_replace_or_cancel(ledger, task, is_root, "superseded")
+            replacement = payload.get("replacement") or {}
+            replacement_ids = self._task_graph.create_sibling_records(
+                ledger,
+                task,
+                [replacement],
+            )
+            task.superseded_by = replacement_ids[0]
+            payload["replacement_task_id"] = replacement_ids[0]
+            target = TaskStatus.SUPERSEDED
+        elif action == DecisionAction.REVALIDATE:
+            raise ValueError("Use revalidate_attempt() / validate_attempt tool")
+        else:
+            raise ValueError(f"Unsupported decision action: {action.value}")
+
+        transition(before, target)
+        task.status = target
+        task.updated_at = utc_now()
+        decision = PlannerDecision(
+            decision_id=new_id(),
+            task_id=task_id,
+            action=action,
+            reason=reason,
+            from_status=before,
+            to_status=target,
+            attempt_id=attempt_id,
+            validation_id=validation_id,
+            payload=payload,
+        )
+        ledger.decisions[decision.decision_id] = decision
+        task.decision_ids.append(decision.decision_id)
+        TaskGraph.update_parent_after_child(ledger, task)
+        return {
+            "task_id": task_id,
+            "decision_id": decision.decision_id,
+            "action": action.value,
+            "status": target.value,
+            "payload": payload,
+        }
+
+    @staticmethod
+    def guard_accept(
+        task: TaskRecord,
+        validation: Optional[ValidationReport],
+        ledger: TaskLedger,
+    ) -> None:
+        if task.status != TaskStatus.AWAITING_DECISION:
+            raise TaskConflict("Accept requires awaiting_decision")
+        if not validation or validation.status != ValidationRunStatus.COMPLETED:
+            raise TaskConflict("Accept requires a completed validation")
+        if validation.spec_version != task.current_spec_version:
+            raise TaskConflict("Validation belongs to an obsolete TaskSpec version")
+        if validation.verdict != ValidationVerdict.PASSED:
+            raise TaskConflict("Only a passed validation can be accepted")
+        if not task.attempt_ids or validation.attempt_id != task.attempt_ids[-1]:
+            raise TaskConflict("Validation does not belong to the current attempt")
+        attempt = ledger.attempts[validation.attempt_id]
+        if validation.snapshot_id != attempt.snapshot_id:
+            raise TaskConflict("Validation does not belong to the current snapshot")
+        if TaskGraph.nonterminal_descendants(ledger, task):
+            raise TaskConflict(
+                "Task cannot be accepted while descendants are non-terminal"
+            )
+
+    @staticmethod
+    def guard_replan(
+        task: TaskRecord,
+        validation: Optional[ValidationReport],
+        ledger: TaskLedger,
+    ) -> None:
+        if task.status != TaskStatus.AWAITING_DECISION:
+            raise TaskConflict("Repair requires awaiting_decision")
+        if not validation or validation.status != ValidationRunStatus.COMPLETED:
+            raise TaskConflict("Repair requires a completed validation")
+        if validation.spec_version != task.current_spec_version:
+            raise TaskConflict(
+                "Repair validation belongs to an obsolete TaskSpec version"
+            )
+        if not task.attempt_ids or validation.attempt_id != task.attempt_ids[-1]:
+            raise TaskConflict(
+                "Repair validation does not belong to the current attempt"
+            )
+        attempt = ledger.attempts[validation.attempt_id]
+        if validation.snapshot_id != attempt.snapshot_id:
+            raise TaskConflict(
+                "Repair validation does not belong to the current snapshot"
+            )
+        if validation.verdict == ValidationVerdict.PASSED:
+            raise TaskConflict("Passed work should be accepted, not repaired")
+
+    @staticmethod
+    def _task(ledger: TaskLedger, task_id: str) -> TaskRecord:
+        try:
+            return ledger.tasks[task_id]
+        except KeyError as exc:
+            raise ValueError(f"Task not found: {task_id}") from exc
+
+    @staticmethod
+    def _require_replan_state(task: TaskRecord, action: str) -> None:
+        if task.status not in (
+            TaskStatus.AWAITING_DECISION,
+            TaskStatus.NEEDS_REPLAN,
+        ):
+            raise TaskConflict(f"{action} requires awaiting_decision or needs_replan")
+
+    @staticmethod
+    def _guard_replace_or_cancel(
+        ledger: TaskLedger,
+        task: TaskRecord,
+        is_root: bool,
+        action: str,
+    ) -> None:
+        if is_root:
+            raise TaskConflict(f"The root task cannot be {action}")
+        if task.status in TERMINAL_TASK_STATUSES:
+            raise TaskConflict("Task is already terminal")
+        if TaskGraph.nonterminal_descendants(ledger, task):
+            raise TaskConflict(f"Task with non-terminal descendants cannot be {action}")
+
+
+__all__ = ["DecisionEngine"]

+ 175 - 0
agent/agent/orchestration/_goal_projection.py

@@ -0,0 +1,175 @@
+"""Best-effort TaskLedger-to-GoalTree compatibility projection."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Awaitable, Callable, Dict, Optional
+
+from agent.trace.goal_models import GoalTree
+
+from .models import TaskLedger, TaskRecord, TaskStatus
+from .protocols import TaskStore
+
+
+logger = logging.getLogger(__name__)
+
+
+GOAL_STATUS_BY_TASK_STATUS = {
+    TaskStatus.PENDING: "pending",
+    TaskStatus.RUNNING: "in_progress",
+    TaskStatus.AWAITING_VALIDATION: "in_progress",
+    TaskStatus.VALIDATING: "in_progress",
+    TaskStatus.AWAITING_DECISION: "in_progress",
+    TaskStatus.NEEDS_REPLAN: "in_progress",
+    TaskStatus.WAITING_CHILDREN: "in_progress",
+    TaskStatus.BLOCKED: "in_progress",
+    TaskStatus.COMPLETED: "completed",
+    TaskStatus.CANCELLED: "abandoned",
+    TaskStatus.SUPERSEDED: "abandoned",
+}
+
+
+class GoalProjection:
+    """Project authoritative task state into the legacy GoalTree view.
+
+    The projection never commits a ledger directly. Linking a projected Goal
+    back to its Task is delegated to the coordinator's mutation callback so the
+    coordinator remains the single persistence boundary.
+    """
+
+    def __init__(
+        self,
+        *,
+        task_store: TaskStore,
+        get_trace_store: Callable[[], Any],
+        mutate: Callable[..., Awaitable[Dict[str, Any]]],
+    ) -> None:
+        self._task_store = task_store
+        self._get_trace_store = get_trace_store
+        self._mutate = mutate
+
+    async def project_state(self, root_trace_id: str, task_id: str) -> None:
+        trace_store = self._get_trace_store()
+        if not trace_store:
+            return
+        ledger = await self._task_store.load(root_trace_id)
+        task = _task(ledger, task_id)
+        if not task.goal_id:
+            return
+        summary = None
+        if task.decision_ids:
+            summary = ledger.decisions[task.decision_ids[-1]].reason
+        await trace_store.update_goal(
+            root_trace_id,
+            task.goal_id,
+            cascade_completion=False,
+            status=GOAL_STATUS_BY_TASK_STATUS[task.status],
+            summary=summary,
+        )
+
+    async def project_compatibility(
+        self,
+        root_trace_id: str,
+        task_id: str,
+        *,
+        ensure: bool = False,
+        after_task_id: Optional[str] = None,
+    ) -> None:
+        """Run projection without invalidating an authoritative Ledger commit."""
+
+        if not self._get_trace_store():
+            return
+        try:
+            if ensure:
+                await self.ensure(
+                    root_trace_id,
+                    task_id,
+                    after_task_id=after_task_id,
+                )
+            await self.project_state(root_trace_id, task_id)
+        except Exception as exc:
+            self.log_failure(root_trace_id, task_id, exc)
+
+    @staticmethod
+    def log_failure(
+        root_trace_id: str,
+        task_id: str,
+        error: Exception,
+    ) -> None:
+        logger.warning(
+            "Goal compatibility projection failed after Ledger commit "
+            "(%s, %s): %s",
+            root_trace_id,
+            task_id,
+            error,
+        )
+
+    async def ensure(
+        self,
+        root_trace_id: str,
+        task_id: str,
+        after_task_id: Optional[str] = None,
+    ) -> None:
+        trace_store = self._get_trace_store()
+        if not trace_store:
+            return
+        ledger = await self._task_store.load(root_trace_id)
+        task = _task(ledger, task_id)
+        tree = await trace_store.get_goal_tree(root_trace_id)
+        if tree is None:
+            tree = GoalTree(mission=ledger.root_objective)
+        if task.goal_id and tree.find(task.goal_id):
+            return
+        parent_goal_id = None
+        if task.parent_task_id:
+            parent = ledger.tasks[task.parent_task_id]
+            if not parent.goal_id:
+                await self.ensure(root_trace_id, parent.task_id)
+                ledger = await self._task_store.load(root_trace_id)
+                parent = ledger.tasks[task.parent_task_id]
+                tree = await trace_store.get_goal_tree(root_trace_id) or tree
+            parent_goal_id = parent.goal_id
+        after_goal_id = None
+        if after_task_id and after_task_id in ledger.tasks:
+            after_goal_id = ledger.tasks[after_task_id].goal_id
+        if after_goal_id and tree.find(after_goal_id):
+            goal = tree.add_goals_after(
+                after_goal_id,
+                descriptions=[task.current_spec.objective],
+                reasons=["TaskLedger compatibility projection"],
+            )[0]
+        else:
+            goal = tree.add_goals(
+                descriptions=[task.current_spec.objective],
+                reasons=["TaskLedger compatibility projection"],
+                parent_id=parent_goal_id,
+            )[0]
+        await trace_store.update_goal_tree(root_trace_id, tree)
+
+        def link(current: TaskLedger) -> Dict[str, Any]:
+            current_task = _task(current, task_id)
+            if current_task.goal_id is None:
+                current_task.goal_id = goal.id
+            return {"task_id": task_id, "goal_id": current_task.goal_id}
+
+        await self._mutate(root_trace_id, "goal_projection_linked", link)
+
+    async def reconcile(self, root_trace_id: str) -> Dict[str, Any]:
+        ledger = await self._task_store.load(root_trace_id)
+        for task_id in ledger.tasks:
+            await self.ensure(root_trace_id, task_id)
+            await self.project_state(root_trace_id, task_id)
+        return {
+            "root_trace_id": root_trace_id,
+            "reconciled_tasks": len(ledger.tasks),
+        }
+
+
+def _task(ledger: TaskLedger, task_id: str) -> TaskRecord:
+    try:
+        return ledger.tasks[task_id]
+    except KeyError as exc:
+        raise ValueError(f"Task not found: {task_id}") from exc
+
+
+__all__ = ["GOAL_STATUS_BY_TASK_STATUS", "GoalProjection"]

+ 353 - 0
agent/agent/orchestration/_task_graph.py

@@ -0,0 +1,353 @@
+"""Pure task-tree operations used by :mod:`agent.orchestration.coordinator`.
+
+The task ledger remains owned and committed by ``TaskCoordinator``.  This
+module only mutates the in-memory aggregate handed to it; it never performs
+I/O, acquires locks, or commits state on its own.
+"""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+from .errors import OrchestrationError, TaskConflict
+from .models import (
+    DecisionAction,
+    PlannerDecision,
+    TaskAttempt,
+    TaskLedger,
+    TaskRecord,
+    TaskSpec,
+    TaskStatus,
+    ValidationReport,
+    ValidationRunStatus,
+    ValidationVerdict,
+    json_values,
+    new_id,
+    utc_now,
+)
+from .state_machine import transition
+
+
+TERMINAL_TASK_STATUSES = {
+    TaskStatus.COMPLETED,
+    TaskStatus.CANCELLED,
+    TaskStatus.SUPERSEDED,
+}
+
+ACTIVE_TASK_STATUSES = {
+    TaskStatus.RUNNING,
+    TaskStatus.AWAITING_VALIDATION,
+    TaskStatus.VALIDATING,
+}
+
+
+def path_key(display_path: str) -> Tuple[int, ...]:
+    """Return a stable numeric sort key for a dotted task display path."""
+
+    try:
+        return tuple(int(part) for part in display_path.split("."))
+    except ValueError:
+        return (10**9,)
+
+
+class TaskGraph:
+    """Maintain task hierarchy and accepted child-result bindings.
+
+    All methods operate on a caller-owned ``TaskLedger``.  Keeping persistence
+    out of this component preserves ``TaskCoordinator`` as the sole writer.
+    """
+
+    @staticmethod
+    def accepted_child_results(
+        ledger: TaskLedger,
+        task: TaskRecord,
+        attempt: Optional[TaskAttempt] = None,
+    ) -> List[Dict[str, Any]]:
+        """Build immutable, accepted direct-child inputs for a parent Worker."""
+
+        results: List[Dict[str, Any]] = []
+        if attempt is None:
+            decision_ids = TaskGraph.accepted_child_decision_ids(ledger, task)
+        elif attempt.accepted_child_decision_ids is None:
+            raise OrchestrationError(
+                "Attempt has no frozen accepted child decision binding"
+            )
+        else:
+            decision_ids = attempt.accepted_child_decision_ids
+        if len(set(decision_ids)) != len(decision_ids):
+            raise OrchestrationError("Attempt child decision bindings are not unique")
+        expected_decision_ids = TaskGraph.accepted_child_decision_ids(ledger, task)
+        if tuple(decision_ids) != expected_decision_ids:
+            raise OrchestrationError(
+                "Attempt child decision bindings do not match direct children in "
+                "display_path order"
+            )
+        direct_children = set(task.child_task_ids)
+        prior_path: Optional[Tuple[int, ...]] = None
+        for decision_id in decision_ids:
+            decision = ledger.decisions.get(decision_id)
+            if (
+                decision is None
+                or decision.task_id not in direct_children
+                or decision.action != DecisionAction.ACCEPT
+            ):
+                raise OrchestrationError(
+                    "Attempt child decision binding is not a direct-child ACCEPT"
+                )
+            child = ledger.tasks[decision.task_id]
+            child_path = path_key(child.display_path)
+            if prior_path is not None and child_path < prior_path:
+                raise OrchestrationError(
+                    "Attempt child decision bindings are not in display_path order"
+                )
+            prior_path = child_path
+            bound_decision, child_attempt, validation = TaskGraph.accepted_result_binding(
+                ledger, child
+            )
+            if bound_decision.decision_id != decision_id:
+                raise OrchestrationError(
+                    "Attempt child decision binding is not the child's accepted result"
+                )
+            results.append(
+                {
+                    "task_id": child.task_id,
+                    "task_spec": json_values(asdict(child.current_spec)),
+                    "attempt_id": child_attempt.attempt_id,
+                    "snapshot_id": child_attempt.snapshot_id,
+                    "submission": json_values(asdict(child_attempt.submission)),
+                    "validation": {
+                        "validation_id": validation.validation_id,
+                        "summary": validation.summary,
+                        "evidence_refs": json_values(
+                            asdict(validation)["evidence_refs"]
+                        ),
+                    },
+                }
+            )
+        return results
+
+    @staticmethod
+    def accepted_child_decision_ids(
+        ledger: TaskLedger,
+        task: TaskRecord,
+    ) -> Tuple[str, ...]:
+        decision_ids: List[str] = []
+        children = sorted(
+            (ledger.tasks[child_id] for child_id in task.child_task_ids),
+            key=lambda child: path_key(child.display_path),
+        )
+        for child in children:
+            if child.status == TaskStatus.COMPLETED:
+                decision, _attempt, _validation = TaskGraph.accepted_result_binding(
+                    ledger, child
+                )
+                decision_ids.append(decision.decision_id)
+        return tuple(decision_ids)
+
+    @staticmethod
+    def root_task(ledger: TaskLedger) -> TaskRecord:
+        if not ledger.root_task_id:
+            raise TaskConflict(
+                "Rootless task ledger is incompatible with mission execution; "
+                "rebuild the development trace"
+            )
+        root = ledger.tasks.get(ledger.root_task_id)
+        top_level = [
+            task.task_id
+            for task in ledger.tasks.values()
+            if task.parent_task_id is None
+        ]
+        if not root or root.parent_task_id is not None or top_level != [root.task_id]:
+            raise OrchestrationError("Task ledger Root Task structure is invalid")
+        return root
+
+    @staticmethod
+    def accepted_result_binding(
+        ledger: TaskLedger,
+        task: TaskRecord,
+    ) -> Tuple[PlannerDecision, TaskAttempt, ValidationReport]:
+        if task.status != TaskStatus.COMPLETED or not task.decision_ids:
+            raise OrchestrationError(f"Task {task.task_id} has no accepted result")
+        decision = ledger.decisions.get(task.decision_ids[-1])
+        if (
+            not decision
+            or decision.task_id != task.task_id
+            or decision.action != DecisionAction.ACCEPT
+            or not decision.attempt_id
+            or not decision.validation_id
+        ):
+            raise OrchestrationError(
+                f"Task {task.task_id} has an invalid accept decision"
+            )
+        attempt = ledger.attempts.get(decision.attempt_id)
+        validation = ledger.validations.get(decision.validation_id)
+        if (
+            not attempt
+            or attempt.task_id != task.task_id
+            or not attempt.submission
+            or not attempt.snapshot_id
+            or attempt.spec_version != task.current_spec_version
+            or not validation
+            or validation.task_id != task.task_id
+            or validation.attempt_id != attempt.attempt_id
+            or validation.snapshot_id != attempt.snapshot_id
+            or validation.spec_version != task.current_spec_version
+            or validation.status != ValidationRunStatus.COMPLETED
+            or validation.verdict != ValidationVerdict.PASSED
+        ):
+            raise OrchestrationError(
+                f"Task {task.task_id} has an invalid accepted result binding"
+            )
+        return decision, attempt, validation
+
+    @staticmethod
+    def nonterminal_descendants(
+        ledger: TaskLedger,
+        task: TaskRecord,
+    ) -> List[TaskRecord]:
+        descendants: List[TaskRecord] = []
+        pending_ids = list(task.child_task_ids)
+        while pending_ids:
+            child = ledger.tasks[pending_ids.pop()]
+            pending_ids.extend(child.child_task_ids)
+            if child.status not in TERMINAL_TASK_STATUSES:
+                descendants.append(child)
+        return descendants
+
+    def create_child_records(
+        self,
+        ledger: TaskLedger,
+        parent: TaskRecord,
+        drafts: Sequence[Dict[str, Any]],
+    ) -> List[str]:
+        return self.create_records(ledger, drafts, parent.task_id, parent.display_path)
+
+    def create_sibling_records(
+        self,
+        ledger: TaskLedger,
+        task: TaskRecord,
+        drafts: Sequence[Dict[str, Any]],
+    ) -> List[str]:
+        parent_path = (
+            ledger.tasks[task.parent_task_id].display_path
+            if task.parent_task_id
+            else ""
+        )
+        return self.create_records(ledger, drafts, task.parent_task_id, parent_path)
+
+    @staticmethod
+    def add_task_record(
+        ledger: TaskLedger,
+        draft: Dict[str, Any],
+        parent_task_id: Optional[str],
+        display_path: str,
+    ) -> str:
+        task_id = new_id()
+        spec = TaskGraph.task_spec_from_draft(draft, version=1)
+        ledger.tasks[task_id] = TaskRecord(
+            task_id=task_id,
+            goal_id=None,
+            parent_task_id=parent_task_id,
+            display_path=display_path,
+            specs=[spec],
+        )
+        return task_id
+
+    @staticmethod
+    def task_spec_from_draft(draft: Dict[str, Any], *, version: int) -> TaskSpec:
+        if not isinstance(draft, dict):
+            raise ValueError("Task draft must be an object")
+        return TaskSpec.from_dict(
+            {
+                "version": version,
+                "objective": draft.get("objective", ""),
+                "acceptance_criteria": draft.get("acceptance_criteria", []),
+                "context_refs": draft.get("context_refs", ()),
+            }
+        )
+
+    def create_records(
+        self,
+        ledger: TaskLedger,
+        drafts: Sequence[Dict[str, Any]],
+        parent_task_id: Optional[str],
+        parent_path: str,
+    ) -> List[str]:
+        siblings = sorted(
+            (
+                task
+                for task in ledger.tasks.values()
+                if task.parent_task_id == parent_task_id
+            ),
+            key=lambda item: path_key(item.display_path),
+        )
+        created: List[str] = []
+        for offset, draft in enumerate(drafts, start=1):
+            index = len(siblings) + offset
+            display_path = f"{parent_path}.{index}" if parent_path else str(index)
+            task_id = self.add_task_record(
+                ledger,
+                draft,
+                parent_task_id,
+                display_path,
+            )
+            if parent_task_id:
+                ledger.tasks[parent_task_id].child_task_ids.append(task_id)
+            created.append(task_id)
+        return created
+
+    @staticmethod
+    def mark_parent_waiting(parent: TaskRecord) -> None:
+        if parent.status == TaskStatus.WAITING_CHILDREN:
+            return
+        transition(parent.status, TaskStatus.WAITING_CHILDREN)
+        parent.status = TaskStatus.WAITING_CHILDREN
+        parent.updated_at = utc_now()
+
+    @staticmethod
+    def update_parent_after_child(ledger: TaskLedger, child: TaskRecord) -> None:
+        if not child.parent_task_id or child.status not in TERMINAL_TASK_STATUSES:
+            return
+        parent = ledger.tasks[child.parent_task_id]
+        if parent.status != TaskStatus.WAITING_CHILDREN:
+            return
+        children = [ledger.tasks[child_id] for child_id in parent.child_task_ids]
+        if children and all(
+            candidate.status in TERMINAL_TASK_STATUSES for candidate in children
+        ):
+            transition(parent.status, TaskStatus.NEEDS_REPLAN)
+            parent.status = TaskStatus.NEEDS_REPLAN
+        parent.updated_at = utc_now()
+
+    @staticmethod
+    def display_path(
+        ledger: TaskLedger,
+        parent_task_id: Optional[str],
+        index: int,
+    ) -> str:
+        if not parent_task_id:
+            return str(index)
+        return f"{ledger.tasks[parent_task_id].display_path}.{index}"
+
+    @staticmethod
+    def rebase_task_path(
+        ledger: TaskLedger,
+        task: TaskRecord,
+        display_path: str,
+    ) -> None:
+        task.display_path = display_path
+        for index, child_id in enumerate(task.child_task_ids, start=1):
+            TaskGraph.rebase_task_path(
+                ledger,
+                ledger.tasks[child_id],
+                f"{display_path}.{index}",
+            )
+
+
+__all__ = [
+    "ACTIVE_TASK_STATUSES",
+    "TERMINAL_TASK_STATUSES",
+    "TaskGraph",
+    "path_key",
+]

+ 59 - 461
agent/agent/orchestration/coordinator.py

@@ -11,8 +11,6 @@ from dataclasses import asdict, is_dataclass
 from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
 from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
 from uuid import uuid4
 from uuid import uuid4
 
 
-from agent.trace.goal_models import GoalTree
-
 from .config import OrchestrationConfig
 from .config import OrchestrationConfig
 from .models import (
 from .models import (
     AgentRole,
     AgentRole,
@@ -55,7 +53,7 @@ from .protocols import (
 from .state_machine import transition
 from .state_machine import transition
 from .store import RevisionConflict, TaskStoreNotFound
 from .store import RevisionConflict, TaskStoreNotFound
 from .errors import OrchestrationError, TaskConflict
 from .errors import OrchestrationError, TaskConflict
-from .operations import OperationController, stage_timeout
+from .operations import OperationController, _failure_stats, stage_timeout
 from .evidence import (
 from .evidence import (
     EvidenceBudgetExceeded,
     EvidenceBudgetExceeded,
     EvidenceOwnershipError,
     EvidenceOwnershipError,
@@ -72,24 +70,18 @@ from .validation_policy import (
     ValidationContext,
     ValidationContext,
     ValidationPolicy,
     ValidationPolicy,
 )
 )
+from ._goal_projection import GoalProjection
+from ._decision_engine import DecisionEngine
+from ._task_graph import (
+    TERMINAL_TASK_STATUSES,
+    TaskGraph,
+    path_key as _path_key,
+)
 
 
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
-TERMINAL_TASK_STATUSES = {
-    TaskStatus.COMPLETED,
-    TaskStatus.CANCELLED,
-    TaskStatus.SUPERSEDED,
-}
-
-ACTIVE_TASK_STATUSES = {
-    TaskStatus.RUNNING,
-    TaskStatus.AWAITING_VALIDATION,
-    TaskStatus.VALIDATING,
-}
-
-
 class TaskCoordinator:
 class TaskCoordinator:
     """Coordinates Task -> Attempt -> Validation -> Planner Decision.
     """Coordinates Task -> Attempt -> Validation -> Planner Decision.
 
 
@@ -121,6 +113,16 @@ class TaskCoordinator:
         self.deterministic_validator = deterministic_validator
         self.deterministic_validator = deterministic_validator
         self.evidence_provider = evidence_provider
         self.evidence_provider = evidence_provider
         self._locks: Dict[str, asyncio.Lock] = {}
         self._locks: Dict[str, asyncio.Lock] = {}
+        self._task_graph = TaskGraph()
+        self._decision_engine = DecisionEngine(
+            self._task_graph,
+            max_repair_continuations=lambda: self.config.max_repair_continuations,
+        )
+        self._goal_projection = GoalProjection(
+            task_store=self.task_store,
+            get_trace_store=lambda: self.trace_store,
+            mutate=self._mutate,
+        )
         self._operations = OperationController(
         self._operations = OperationController(
             task_store=self.task_store,
             task_store=self.task_store,
             mutate=self._mutate,
             mutate=self._mutate,
@@ -2239,126 +2241,13 @@ class TaskCoordinator:
         payload = dict(payload or {})
         payload = dict(payload or {})
 
 
         def mutate(ledger: TaskLedger) -> Dict[str, Any]:
         def mutate(ledger: TaskLedger) -> Dict[str, Any]:
-            task = _task(ledger, task_id)
-            before = task.status
-            is_root = task.task_id == ledger.root_task_id
-            validation = ledger.validations.get(validation_id) if validation_id else None
-            attempt_id = validation.attempt_id if validation else (task.attempt_ids[-1] if task.attempt_ids else None)
-            reason = str(payload.get("reason", "")).strip()
-            if not reason and action not in (DecisionAction.UNBLOCK,):
-                raise ValueError("Planner decision reason is required")
-
-            if action == DecisionAction.ACCEPT:
-                self._guard_accept(task, validation, ledger)
-                target = TaskStatus.COMPLETED
-            elif action == DecisionAction.REPAIR:
-                self._guard_replan_decision(task, validation, ledger)
-                count_key = str(task.current_spec_version)
-                used = task.repair_count_by_version.get(count_key, 0)
-                if used >= self.config.max_repair_continuations:
-                    raise TaskConflict("Repair continuation limit reached; retry, revise, split, or block")
-                task.repair_count_by_version[count_key] = used + 1
-                target = TaskStatus.PENDING
-            elif action == DecisionAction.RETRY:
-                if task.status not in (TaskStatus.AWAITING_DECISION, TaskStatus.NEEDS_REPLAN):
-                    raise TaskConflict("Retry requires awaiting_decision or needs_replan")
-                target = TaskStatus.PENDING
-            elif action == DecisionAction.REVISE:
-                if task.status not in (TaskStatus.AWAITING_DECISION, TaskStatus.NEEDS_REPLAN):
-                    raise TaskConflict("Revise requires awaiting_decision or needs_replan")
-                objective = (
-                    payload["objective"]
-                    if "objective" in payload
-                    else task.current_spec.objective
-                )
-                version = task.current_spec_version + 1
-                draft = {
-                    "objective": objective,
-                    "acceptance_criteria": (
-                        payload["acceptance_criteria"]
-                        if "acceptance_criteria" in payload
-                        else [_plain(asdict(x)) for x in task.current_spec.acceptance_criteria]
-                    ),
-                    "context_refs": payload.get(
-                        "context_refs", task.current_spec.context_refs
-                    ),
-                }
-                task.specs.append(self._task_spec_from_draft(draft, version=version))
-                task.current_spec_version = version
-                target = TaskStatus.PENDING
-            elif action == DecisionAction.SPLIT:
-                if task.status not in (TaskStatus.AWAITING_DECISION, TaskStatus.NEEDS_REPLAN):
-                    raise TaskConflict("Split requires awaiting_decision or needs_replan")
-                drafts = payload.get("tasks") or []
-                if not drafts:
-                    raise ValueError("Split requires payload.tasks")
-                child_ids = self._create_child_records(ledger, task, drafts)
-                payload["child_task_ids"] = child_ids
-                target = TaskStatus.WAITING_CHILDREN
-            elif action == DecisionAction.BLOCK:
-                if task.status in TERMINAL_TASK_STATUSES:
-                    raise TaskConflict("Terminal task cannot be blocked")
-                if any(
-                    child.status in ACTIVE_TASK_STATUSES
-                    for child in self._nonterminal_descendants(ledger, task)
-                ):
-                    raise TaskConflict("Task with active descendants cannot be blocked")
-                task.blocked_reason = reason
-                target = TaskStatus.BLOCKED
-            elif action == DecisionAction.UNBLOCK:
-                if task.status != TaskStatus.BLOCKED:
-                    raise TaskConflict("Only a blocked task can be unblocked")
-                task.blocked_reason = None
-                target = TaskStatus.NEEDS_REPLAN
-            elif action == DecisionAction.CANCEL:
-                if is_root:
-                    raise TaskConflict("The root task cannot be cancelled")
-                if task.status in TERMINAL_TASK_STATUSES:
-                    raise TaskConflict("Task is already terminal")
-                if self._nonterminal_descendants(ledger, task):
-                    raise TaskConflict("Task with non-terminal descendants cannot be cancelled")
-                target = TaskStatus.CANCELLED
-            elif action == DecisionAction.SUPERSEDE:
-                if is_root:
-                    raise TaskConflict("The root task cannot be superseded")
-                if task.status in TERMINAL_TASK_STATUSES:
-                    raise TaskConflict("Task is already terminal")
-                if self._nonterminal_descendants(ledger, task):
-                    raise TaskConflict("Task with non-terminal descendants cannot be superseded")
-                replacement = payload.get("replacement") or {}
-                replacement_ids = self._create_sibling_records(ledger, task, [replacement])
-                task.superseded_by = replacement_ids[0]
-                payload["replacement_task_id"] = replacement_ids[0]
-                target = TaskStatus.SUPERSEDED
-            elif action == DecisionAction.REVALIDATE:
-                raise ValueError("Use revalidate_attempt() / validate_attempt tool")
-            else:
-                raise ValueError(f"Unsupported decision action: {action.value}")
-
-            transition(before, target)
-            task.status = target
-            task.updated_at = utc_now()
-            decision = PlannerDecision(
-                decision_id=new_id(),
+            return self._decision_engine.apply(
+                ledger,
                 task_id=task_id,
                 task_id=task_id,
-                action=action,
-                reason=reason,
-                from_status=before,
-                to_status=target,
-                attempt_id=attempt_id,
                 validation_id=validation_id,
                 validation_id=validation_id,
+                action=action,
                 payload=payload,
                 payload=payload,
             )
             )
-            ledger.decisions[decision.decision_id] = decision
-            task.decision_ids.append(decision.decision_id)
-            self._update_parent_after_child(ledger, task)
-            return {
-                "task_id": task_id,
-                "decision_id": decision.decision_id,
-                "action": action.value,
-                "status": target.value,
-                "payload": payload,
-            }
 
 
         result = await self._mutate(
         result = await self._mutate(
             root_trace_id,
             root_trace_id,
@@ -2398,21 +2287,7 @@ class TaskCoordinator:
         validation: Optional[ValidationReport],
         validation: Optional[ValidationReport],
         ledger: TaskLedger,
         ledger: TaskLedger,
     ) -> None:
     ) -> None:
-        if task.status != TaskStatus.AWAITING_DECISION:
-            raise TaskConflict("Accept requires awaiting_decision")
-        if not validation or validation.status != ValidationRunStatus.COMPLETED:
-            raise TaskConflict("Accept requires a completed validation")
-        if validation.spec_version != task.current_spec_version:
-            raise TaskConflict("Validation belongs to an obsolete TaskSpec version")
-        if validation.verdict != ValidationVerdict.PASSED:
-            raise TaskConflict("Only a passed validation can be accepted")
-        if not task.attempt_ids or validation.attempt_id != task.attempt_ids[-1]:
-            raise TaskConflict("Validation does not belong to the current attempt")
-        attempt = ledger.attempts[validation.attempt_id]
-        if validation.snapshot_id != attempt.snapshot_id:
-            raise TaskConflict("Validation does not belong to the current snapshot")
-        if TaskCoordinator._nonterminal_descendants(ledger, task):
-            raise TaskConflict("Task cannot be accepted while descendants are non-terminal")
+        DecisionEngine.guard_accept(task, validation, ledger)
 
 
     @staticmethod
     @staticmethod
     def _guard_replan_decision(
     def _guard_replan_decision(
@@ -2420,18 +2295,7 @@ class TaskCoordinator:
         validation: Optional[ValidationReport],
         validation: Optional[ValidationReport],
         ledger: TaskLedger,
         ledger: TaskLedger,
     ) -> None:
     ) -> None:
-        if task.status != TaskStatus.AWAITING_DECISION:
-            raise TaskConflict("Repair requires awaiting_decision")
-        if not validation or validation.status != ValidationRunStatus.COMPLETED:
-            raise TaskConflict("Repair requires a completed validation")
-        if validation.spec_version != task.current_spec_version:
-            raise TaskConflict("Repair validation belongs to an obsolete TaskSpec version")
-        if not task.attempt_ids or validation.attempt_id != task.attempt_ids[-1]:
-            raise TaskConflict("Repair validation does not belong to the current attempt")
-        if validation.snapshot_id != ledger.attempts[validation.attempt_id].snapshot_id:
-            raise TaskConflict("Repair validation does not belong to the current snapshot")
-        if validation.verdict == ValidationVerdict.PASSED:
-            raise TaskConflict("Passed work should be accepted, not repaired")
+        DecisionEngine.guard_replan(task, validation, ledger)
 
 
     async def revalidate_attempt(
     async def revalidate_attempt(
         self,
         self,
@@ -2762,145 +2626,32 @@ class TaskCoordinator:
         task: TaskRecord,
         task: TaskRecord,
         attempt: Optional[TaskAttempt] = None,
         attempt: Optional[TaskAttempt] = None,
     ) -> List[Dict[str, Any]]:
     ) -> List[Dict[str, Any]]:
-        """Build immutable, accepted direct-child inputs for a parent Worker."""
-
-        results: List[Dict[str, Any]] = []
-        if attempt is None:
-            decision_ids = TaskCoordinator._accepted_child_decision_ids(ledger, task)
-        elif attempt.accepted_child_decision_ids is None:
-            raise OrchestrationError(
-                "Attempt has no frozen accepted child decision binding"
-            )
-        else:
-            decision_ids = attempt.accepted_child_decision_ids
-        if len(set(decision_ids)) != len(decision_ids):
-            raise OrchestrationError("Attempt child decision bindings are not unique")
-        expected_decision_ids = TaskCoordinator._accepted_child_decision_ids(
-            ledger, task
-        )
-        if tuple(decision_ids) != expected_decision_ids:
-            raise OrchestrationError(
-                "Attempt child decision bindings do not match direct children in display_path order"
-            )
-        direct_children = {child_id for child_id in task.child_task_ids}
-        prior_path: Optional[Tuple[int, ...]] = None
-        for decision_id in decision_ids:
-            decision = ledger.decisions.get(decision_id)
-            if (
-                decision is None
-                or decision.task_id not in direct_children
-                or decision.action != DecisionAction.ACCEPT
-            ):
-                raise OrchestrationError(
-                    "Attempt child decision binding is not a direct-child ACCEPT"
-                )
-            child = ledger.tasks[decision.task_id]
-            path = _path_key(child.display_path)
-            if prior_path is not None and path < prior_path:
-                raise OrchestrationError(
-                    "Attempt child decision bindings are not in display_path order"
-                )
-            prior_path = path
-            bound_decision, child_attempt, validation = (
-                TaskCoordinator._accepted_result_binding(ledger, child)
-            )
-            if bound_decision.decision_id != decision_id:
-                raise OrchestrationError(
-                    "Attempt child decision binding is not the child's accepted result"
-                )
-            results.append({
-                "task_id": child.task_id,
-                "task_spec": _plain(asdict(child.current_spec)),
-                "attempt_id": child_attempt.attempt_id,
-                "snapshot_id": child_attempt.snapshot_id,
-                "submission": _plain(asdict(child_attempt.submission)),
-                "validation": {
-                    "validation_id": validation.validation_id,
-                    "summary": validation.summary,
-                    "evidence_refs": _plain(asdict(validation)["evidence_refs"]),
-                },
-            })
-        return results
+        return TaskGraph.accepted_child_results(ledger, task, attempt)
 
 
     @staticmethod
     @staticmethod
     def _accepted_child_decision_ids(
     def _accepted_child_decision_ids(
         ledger: TaskLedger,
         ledger: TaskLedger,
         task: TaskRecord,
         task: TaskRecord,
     ) -> Tuple[str, ...]:
     ) -> Tuple[str, ...]:
-        decision_ids: List[str] = []
-        children = sorted(
-            (ledger.tasks[child_id] for child_id in task.child_task_ids),
-            key=lambda child: _path_key(child.display_path),
-        )
-        for child in children:
-            if child.status == TaskStatus.COMPLETED:
-                decision, _attempt, _validation = (
-                    TaskCoordinator._accepted_result_binding(ledger, child)
-                )
-                decision_ids.append(decision.decision_id)
-        return tuple(decision_ids)
+        return TaskGraph.accepted_child_decision_ids(ledger, task)
 
 
     @staticmethod
     @staticmethod
     def _root_task(ledger: TaskLedger) -> TaskRecord:
     def _root_task(ledger: TaskLedger) -> TaskRecord:
-        if not ledger.root_task_id:
-            raise TaskConflict(
-                "Rootless task ledger is incompatible with mission execution; "
-                "rebuild the development trace"
-            )
-        root = ledger.tasks.get(ledger.root_task_id)
-        top_level = [task.task_id for task in ledger.tasks.values() if task.parent_task_id is None]
-        if not root or root.parent_task_id is not None or top_level != [root.task_id]:
-            raise OrchestrationError("Task ledger Root Task structure is invalid")
-        return root
+        return TaskGraph.root_task(ledger)
 
 
     @staticmethod
     @staticmethod
     def _accepted_result_binding(
     def _accepted_result_binding(
         ledger: TaskLedger,
         ledger: TaskLedger,
         task: TaskRecord,
         task: TaskRecord,
     ) -> Tuple[PlannerDecision, TaskAttempt, ValidationReport]:
     ) -> Tuple[PlannerDecision, TaskAttempt, ValidationReport]:
-        if task.status != TaskStatus.COMPLETED or not task.decision_ids:
-            raise OrchestrationError(f"Task {task.task_id} has no accepted result")
-        decision = ledger.decisions.get(task.decision_ids[-1])
-        if (
-            not decision
-            or decision.task_id != task.task_id
-            or decision.action != DecisionAction.ACCEPT
-            or not decision.attempt_id
-            or not decision.validation_id
-        ):
-            raise OrchestrationError(f"Task {task.task_id} has an invalid accept decision")
-        attempt = ledger.attempts.get(decision.attempt_id)
-        validation = ledger.validations.get(decision.validation_id)
-        if (
-            not attempt
-            or attempt.task_id != task.task_id
-            or not attempt.submission
-            or not attempt.snapshot_id
-            or attempt.spec_version != task.current_spec_version
-            or not validation
-            or validation.task_id != task.task_id
-            or validation.attempt_id != attempt.attempt_id
-            or validation.snapshot_id != attempt.snapshot_id
-            or validation.spec_version != task.current_spec_version
-            or validation.status != ValidationRunStatus.COMPLETED
-            or validation.verdict != ValidationVerdict.PASSED
-        ):
-            raise OrchestrationError(f"Task {task.task_id} has an invalid accepted result binding")
-        return decision, attempt, validation
+        return TaskGraph.accepted_result_binding(ledger, task)
 
 
     @staticmethod
     @staticmethod
     def _nonterminal_descendants(
     def _nonterminal_descendants(
         ledger: TaskLedger,
         ledger: TaskLedger,
         task: TaskRecord,
         task: TaskRecord,
     ) -> List[TaskRecord]:
     ) -> List[TaskRecord]:
-        descendants: List[TaskRecord] = []
-        pending_ids = list(task.child_task_ids)
-        while pending_ids:
-            child = ledger.tasks[pending_ids.pop()]
-            pending_ids.extend(child.child_task_ids)
-            if child.status not in TERMINAL_TASK_STATUSES:
-                descendants.append(child)
-        return descendants
+        return TaskGraph.nonterminal_descendants(ledger, task)
 
 
     def _create_child_records(
     def _create_child_records(
         self,
         self,
@@ -2908,7 +2659,7 @@ class TaskCoordinator:
         parent: TaskRecord,
         parent: TaskRecord,
         drafts: Sequence[Dict[str, Any]],
         drafts: Sequence[Dict[str, Any]],
     ) -> List[str]:
     ) -> List[str]:
-        return self._create_records(ledger, drafts, parent.task_id, parent.display_path)
+        return self._task_graph.create_child_records(ledger, parent, drafts)
 
 
     def _create_sibling_records(
     def _create_sibling_records(
         self,
         self,
@@ -2916,8 +2667,7 @@ class TaskCoordinator:
         task: TaskRecord,
         task: TaskRecord,
         drafts: Sequence[Dict[str, Any]],
         drafts: Sequence[Dict[str, Any]],
     ) -> List[str]:
     ) -> List[str]:
-        parent_path = ledger.tasks[task.parent_task_id].display_path if task.parent_task_id else ""
-        return self._create_records(ledger, drafts, task.parent_task_id, parent_path)
+        return self._task_graph.create_sibling_records(ledger, task, drafts)
 
 
     @staticmethod
     @staticmethod
     def _add_task_record(
     def _add_task_record(
@@ -2926,27 +2676,16 @@ class TaskCoordinator:
         parent_task_id: Optional[str],
         parent_task_id: Optional[str],
         display_path: str,
         display_path: str,
     ) -> str:
     ) -> str:
-        task_id = new_id()
-        spec = TaskCoordinator._task_spec_from_draft(draft, version=1)
-        ledger.tasks[task_id] = TaskRecord(
-            task_id=task_id,
-            goal_id=None,
-            parent_task_id=parent_task_id,
-            display_path=display_path,
-            specs=[spec],
+        return TaskGraph.add_task_record(
+            ledger,
+            draft,
+            parent_task_id,
+            display_path,
         )
         )
-        return task_id
 
 
     @staticmethod
     @staticmethod
     def _task_spec_from_draft(draft: Dict[str, Any], *, version: int) -> TaskSpec:
     def _task_spec_from_draft(draft: Dict[str, Any], *, version: int) -> TaskSpec:
-        if not isinstance(draft, dict):
-            raise ValueError("Task draft must be an object")
-        return TaskSpec.from_dict({
-            "version": version,
-            "objective": draft.get("objective", ""),
-            "acceptance_criteria": draft.get("acceptance_criteria", []),
-            "context_refs": draft.get("context_refs", ()),
-        })
+        return TaskGraph.task_spec_from_draft(draft, version=version)
 
 
     def _create_records(
     def _create_records(
         self,
         self,
@@ -2955,76 +2694,23 @@ class TaskCoordinator:
         parent_task_id: Optional[str],
         parent_task_id: Optional[str],
         parent_path: str,
         parent_path: str,
     ) -> List[str]:
     ) -> List[str]:
-        siblings = sorted(
-            (t for t in ledger.tasks.values() if t.parent_task_id == parent_task_id),
-            key=lambda item: _path_key(item.display_path),
+        return self._task_graph.create_records(
+            ledger,
+            drafts,
+            parent_task_id,
+            parent_path,
         )
         )
-        created: List[str] = []
-        for offset, draft in enumerate(drafts, start=1):
-            index = len(siblings) + offset
-            display_path = f"{parent_path}.{index}" if parent_path else str(index)
-            task_id = self._add_task_record(
-                ledger,
-                draft,
-                parent_task_id,
-                display_path,
-            )
-            if parent_task_id:
-                ledger.tasks[parent_task_id].child_task_ids.append(task_id)
-            created.append(task_id)
-        return created
 
 
     @staticmethod
     @staticmethod
     def _mark_parent_waiting(parent: TaskRecord) -> None:
     def _mark_parent_waiting(parent: TaskRecord) -> None:
-        if parent.status == TaskStatus.WAITING_CHILDREN:
-            return
-        transition(parent.status, TaskStatus.WAITING_CHILDREN)
-        parent.status = TaskStatus.WAITING_CHILDREN
-        parent.updated_at = utc_now()
+        TaskGraph.mark_parent_waiting(parent)
 
 
     @staticmethod
     @staticmethod
     def _update_parent_after_child(ledger: TaskLedger, child: TaskRecord) -> None:
     def _update_parent_after_child(ledger: TaskLedger, child: TaskRecord) -> None:
-        if not child.parent_task_id or child.status not in TERMINAL_TASK_STATUSES:
-            return
-        parent = ledger.tasks[child.parent_task_id]
-        if parent.status != TaskStatus.WAITING_CHILDREN:
-            return
-        children = [ledger.tasks[x] for x in parent.child_task_ids]
-        if children and all(x.status in TERMINAL_TASK_STATUSES for x in children):
-            transition(parent.status, TaskStatus.NEEDS_REPLAN)
-            parent.status = TaskStatus.NEEDS_REPLAN
-        parent.updated_at = utc_now()
+        TaskGraph.update_parent_after_child(ledger, child)
 
 
     async def project_goal_state(self, root_trace_id: str, task_id: str) -> None:
     async def project_goal_state(self, root_trace_id: str, task_id: str) -> None:
-        if not self.trace_store:
-            return
-        ledger = await self.task_store.load(root_trace_id)
-        task = _task(ledger, task_id)
-        if not task.goal_id:
-            return
-        status_map = {
-            TaskStatus.PENDING: "pending",
-            TaskStatus.RUNNING: "in_progress",
-            TaskStatus.AWAITING_VALIDATION: "in_progress",
-            TaskStatus.VALIDATING: "in_progress",
-            TaskStatus.AWAITING_DECISION: "in_progress",
-            TaskStatus.NEEDS_REPLAN: "in_progress",
-            TaskStatus.WAITING_CHILDREN: "in_progress",
-            TaskStatus.BLOCKED: "in_progress",
-            TaskStatus.COMPLETED: "completed",
-            TaskStatus.CANCELLED: "abandoned",
-            TaskStatus.SUPERSEDED: "abandoned",
-        }
-        summary = None
-        if task.decision_ids:
-            summary = ledger.decisions[task.decision_ids[-1]].reason
-        await self.trace_store.update_goal(
-            root_trace_id,
-            task.goal_id,
-            cascade_completion=False,
-            status=status_map[task.status],
-            summary=summary,
-        )
+        await self._goal_projection.project_state(root_trace_id, task_id)
 
 
     async def _project_goal_compatibility(
     async def _project_goal_compatibility(
         self,
         self,
@@ -3034,25 +2720,12 @@ class TaskCoordinator:
         ensure: bool = False,
         ensure: bool = False,
         after_task_id: Optional[str] = None,
         after_task_id: Optional[str] = None,
     ) -> None:
     ) -> None:
-        """Best-effort GoalTree projection after an authoritative commit.
-
-        Automatic projection must never turn a successful Ledger mutation into
-        an apparent command failure. Explicit reconcile_goal_tree() deliberately
-        keeps using the strict helpers so operators can observe repair failures.
-        """
-
-        if not self.trace_store:
-            return
-        try:
-            if ensure:
-                await self._ensure_goal_projection(
-                    root_trace_id,
-                    task_id,
-                    after_task_id=after_task_id,
-                )
-            await self.project_goal_state(root_trace_id, task_id)
-        except Exception as exc:
-            self._log_projection_failure(root_trace_id, task_id, exc)
+        await self._goal_projection.project_compatibility(
+            root_trace_id,
+            task_id,
+            ensure=ensure,
+            after_task_id=after_task_id,
+        )
 
 
     @staticmethod
     @staticmethod
     def _log_projection_failure(
     def _log_projection_failure(
@@ -3060,13 +2733,7 @@ class TaskCoordinator:
         task_id: str,
         task_id: str,
         error: Exception,
         error: Exception,
     ) -> None:
     ) -> None:
-        logger.warning(
-            "Goal compatibility projection failed after Ledger commit "
-            "(%s, %s): %s",
-            root_trace_id,
-            task_id,
-            error,
-        )
+        GoalProjection.log_failure(root_trace_id, task_id, error)
 
 
     async def _ensure_goal_projection(
     async def _ensure_goal_projection(
         self,
         self,
@@ -3074,55 +2741,14 @@ class TaskCoordinator:
         task_id: str,
         task_id: str,
         after_task_id: Optional[str] = None,
         after_task_id: Optional[str] = None,
     ) -> None:
     ) -> None:
-        if not self.trace_store:
-            return
-        ledger = await self.task_store.load(root_trace_id)
-        task = _task(ledger, task_id)
-        tree = await self.trace_store.get_goal_tree(root_trace_id)
-        if tree is None:
-            tree = GoalTree(mission=ledger.root_objective)
-        if task.goal_id and tree.find(task.goal_id):
-            return
-        parent_goal_id = None
-        if task.parent_task_id:
-            parent = ledger.tasks[task.parent_task_id]
-            if not parent.goal_id:
-                await self._ensure_goal_projection(root_trace_id, parent.task_id)
-                ledger = await self.task_store.load(root_trace_id)
-                parent = ledger.tasks[task.parent_task_id]
-                tree = await self.trace_store.get_goal_tree(root_trace_id) or tree
-            parent_goal_id = parent.goal_id
-        after_goal_id = None
-        if after_task_id and after_task_id in ledger.tasks:
-            after_goal_id = ledger.tasks[after_task_id].goal_id
-        if after_goal_id and tree.find(after_goal_id):
-            goal = tree.add_goals_after(
-                after_goal_id,
-                descriptions=[task.current_spec.objective],
-                reasons=["TaskLedger compatibility projection"],
-            )[0]
-        else:
-            goal = tree.add_goals(
-                descriptions=[task.current_spec.objective],
-                reasons=["TaskLedger compatibility projection"],
-                parent_id=parent_goal_id,
-            )[0]
-        await self.trace_store.update_goal_tree(root_trace_id, tree)
-
-        def link(current: TaskLedger) -> Dict[str, Any]:
-            current_task = _task(current, task_id)
-            if current_task.goal_id is None:
-                current_task.goal_id = goal.id
-            return {"task_id": task_id, "goal_id": current_task.goal_id}
-
-        await self._mutate(root_trace_id, "goal_projection_linked", link)
+        await self._goal_projection.ensure(
+            root_trace_id,
+            task_id,
+            after_task_id=after_task_id,
+        )
 
 
     async def reconcile_goal_tree(self, root_trace_id: str) -> Dict[str, Any]:
     async def reconcile_goal_tree(self, root_trace_id: str) -> Dict[str, Any]:
-        ledger = await self.task_store.load(root_trace_id)
-        for task_id in ledger.tasks:
-            await self._ensure_goal_projection(root_trace_id, task_id)
-            await self.project_goal_state(root_trace_id, task_id)
-        return {"root_trace_id": root_trace_id, "reconciled_tasks": len(ledger.tasks)}
+        return await self._goal_projection.reconcile(root_trace_id)
 
 
     async def _tasks_result(self, root_trace_id: str, task_ids: Iterable[str]) -> Dict[str, Any]:
     async def _tasks_result(self, root_trace_id: str, task_ids: Iterable[str]) -> Dict[str, Any]:
         ledger = await self.task_store.load(root_trace_id)
         ledger = await self.task_store.load(root_trace_id)
@@ -3142,9 +2768,7 @@ class TaskCoordinator:
 
 
     @staticmethod
     @staticmethod
     def _display_path(ledger: TaskLedger, parent_task_id: Optional[str], index: int) -> str:
     def _display_path(ledger: TaskLedger, parent_task_id: Optional[str], index: int) -> str:
-        if not parent_task_id:
-            return str(index)
-        return f"{ledger.tasks[parent_task_id].display_path}.{index}"
+        return TaskGraph.display_path(ledger, parent_task_id, index)
 
 
     @staticmethod
     @staticmethod
     def _rebase_task_path(
     def _rebase_task_path(
@@ -3152,13 +2776,7 @@ class TaskCoordinator:
         task: TaskRecord,
         task: TaskRecord,
         display_path: str,
         display_path: str,
     ) -> None:
     ) -> None:
-        task.display_path = display_path
-        for index, child_id in enumerate(task.child_task_ids, start=1):
-            TaskCoordinator._rebase_task_path(
-                ledger,
-                ledger.tasks[child_id],
-                f"{display_path}.{index}",
-            )
+        TaskGraph.rebase_task_path(ledger, task, display_path)
 
 
 
 
 def _task(ledger: TaskLedger, task_id: str) -> TaskRecord:
 def _task(ledger: TaskLedger, task_id: str) -> TaskRecord:
@@ -3275,24 +2893,4 @@ def _failure_code(run_status: str, *, protocol_failure: bool) -> FailureCode:
     }.get(run_status, FailureCode.PROTOCOL_VIOLATION)
     }.get(run_status, FailureCode.PROTOCOL_VIOLATION)
 
 
 
 
-def _failure_stats(
-    stats: Optional[ExecutionStats],
-    fallback: FailureCode,
-    *,
-    override: bool = False,
-) -> ExecutionStats:
-    if stats is None:
-        return ExecutionStats(failure_code=fallback)
-    if stats.failure_code is not None and not override:
-        return stats
-    return replace(stats, failure_code=fallback)
-
-
-def _path_key(display_path: str) -> Tuple[int, ...]:
-    try:
-        return tuple(int(part) for part in display_path.split("."))
-    except ValueError:
-        return (10**9,)
-
-
 __all__ = ["TaskCoordinator", "OrchestrationError", "TaskConflict"]
 __all__ = ["TaskCoordinator", "OrchestrationError", "TaskConflict"]

+ 6 - 1
agent/agent/orchestration/executor.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import json
 import json
+import logging
 from collections.abc import Mapping
 from collections.abc import Mapping
 from copy import deepcopy
 from copy import deepcopy
 from dataclasses import replace
 from dataclasses import replace
@@ -11,7 +12,7 @@ from types import MappingProxyType
 from typing import Any, Dict, Iterable, Optional, Tuple
 from typing import Any, Dict, Iterable, Optional, Tuple
 
 
 from agent.core.runner import RunConfig
 from agent.core.runner import RunConfig
-from agent.tools.builtin.knowledge import KnowledgeConfig
+from agent.core.knowledge_config import KnowledgeConfig
 
 
 from .models import AgentRole, CompletionPolicy, ExecutionStats, FailureCode
 from .models import AgentRole, CompletionPolicy, ExecutionStats, FailureCode
 from .protocols import ValidatorRunResult, WorkerRunResult
 from .protocols import ValidatorRunResult, WorkerRunResult
@@ -23,6 +24,9 @@ from .run_config import (
 )
 )
 
 
 
 
+logger = logging.getLogger(__name__)
+
+
 class LocalAgentExecutor:
 class LocalAgentExecutor:
     """Run workers and validators with the same Runner in isolated traces."""
     """Run workers and validators with the same Runner in isolated traces."""
 
 
@@ -298,6 +302,7 @@ class LocalAgentExecutor:
         try:
         try:
             return await trace_store.get_trace(trace_id)
             return await trace_store.get_trace(trace_id)
         except Exception:
         except Exception:
+            logger.debug("Unable to reload local sub-trace %s", trace_id, exc_info=True)
             return None
             return None
 
 
     @staticmethod
     @staticmethod

+ 3 - 1
agent/agent/orchestration/operations.py

@@ -785,10 +785,12 @@ def stage_timeout(
 def _failure_stats(
 def _failure_stats(
     stats: Optional[ExecutionStats],
     stats: Optional[ExecutionStats],
     fallback: FailureCode,
     fallback: FailureCode,
+    *,
+    override: bool = False,
 ) -> ExecutionStats:
 ) -> ExecutionStats:
     if stats is None:
     if stats is None:
         return ExecutionStats(failure_code=fallback)
         return ExecutionStats(failure_code=fallback)
-    if stats.failure_code is not None:
+    if stats.failure_code is not None and not override:
         return stats
         return stats
     return replace(stats, failure_code=fallback)
     return replace(stats, failure_code=fallback)