_goal_projection.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. """Best-effort TaskLedger-to-GoalTree compatibility projection."""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Any, Awaitable, Callable, Dict, Optional
  5. from agent.trace.goal_models import GoalTree
  6. from .models import TaskLedger, TaskRecord, TaskStatus
  7. from .protocols import TaskStore
  8. logger = logging.getLogger(__name__)
  9. GOAL_STATUS_BY_TASK_STATUS = {
  10. TaskStatus.PENDING: "pending",
  11. TaskStatus.RUNNING: "in_progress",
  12. TaskStatus.AWAITING_VALIDATION: "in_progress",
  13. TaskStatus.VALIDATING: "in_progress",
  14. TaskStatus.AWAITING_DECISION: "in_progress",
  15. TaskStatus.NEEDS_REPLAN: "in_progress",
  16. TaskStatus.WAITING_CHILDREN: "in_progress",
  17. TaskStatus.BLOCKED: "in_progress",
  18. TaskStatus.COMPLETED: "completed",
  19. TaskStatus.CANCELLED: "abandoned",
  20. TaskStatus.SUPERSEDED: "abandoned",
  21. }
  22. class GoalProjection:
  23. """Project authoritative task state into the legacy GoalTree view.
  24. The projection never commits a ledger directly. Linking a projected Goal
  25. back to its Task is delegated to the coordinator's mutation callback so the
  26. coordinator remains the single persistence boundary.
  27. """
  28. def __init__(
  29. self,
  30. *,
  31. task_store: TaskStore,
  32. get_trace_store: Callable[[], Any],
  33. mutate: Callable[..., Awaitable[Dict[str, Any]]],
  34. ) -> None:
  35. self._task_store = task_store
  36. self._get_trace_store = get_trace_store
  37. self._mutate = mutate
  38. async def project_state(self, root_trace_id: str, task_id: str) -> None:
  39. trace_store = self._get_trace_store()
  40. if not trace_store:
  41. return
  42. ledger = await self._task_store.load(root_trace_id)
  43. task = _task(ledger, task_id)
  44. if not task.goal_id:
  45. return
  46. summary = None
  47. if task.decision_ids:
  48. summary = ledger.decisions[task.decision_ids[-1]].reason
  49. await trace_store.update_goal(
  50. root_trace_id,
  51. task.goal_id,
  52. cascade_completion=False,
  53. status=GOAL_STATUS_BY_TASK_STATUS[task.status],
  54. summary=summary,
  55. )
  56. async def project_compatibility(
  57. self,
  58. root_trace_id: str,
  59. task_id: str,
  60. *,
  61. ensure: bool = False,
  62. after_task_id: Optional[str] = None,
  63. ) -> None:
  64. """Run projection without invalidating an authoritative Ledger commit."""
  65. if not self._get_trace_store():
  66. return
  67. try:
  68. if ensure:
  69. await self.ensure(
  70. root_trace_id,
  71. task_id,
  72. after_task_id=after_task_id,
  73. )
  74. await self.project_state(root_trace_id, task_id)
  75. except Exception as exc:
  76. self.log_failure(root_trace_id, task_id, exc)
  77. @staticmethod
  78. def log_failure(
  79. root_trace_id: str,
  80. task_id: str,
  81. error: Exception,
  82. ) -> None:
  83. logger.warning(
  84. "Goal compatibility projection failed after Ledger commit "
  85. "(%s, %s): %s",
  86. root_trace_id,
  87. task_id,
  88. error,
  89. )
  90. async def ensure(
  91. self,
  92. root_trace_id: str,
  93. task_id: str,
  94. after_task_id: Optional[str] = None,
  95. ) -> None:
  96. trace_store = self._get_trace_store()
  97. if not trace_store:
  98. return
  99. ledger = await self._task_store.load(root_trace_id)
  100. task = _task(ledger, task_id)
  101. tree = await trace_store.get_goal_tree(root_trace_id)
  102. if tree is None:
  103. tree = GoalTree(mission=ledger.root_objective)
  104. if task.goal_id and tree.find(task.goal_id):
  105. return
  106. parent_goal_id = None
  107. if task.parent_task_id:
  108. parent = ledger.tasks[task.parent_task_id]
  109. if not parent.goal_id:
  110. await self.ensure(root_trace_id, parent.task_id)
  111. ledger = await self._task_store.load(root_trace_id)
  112. parent = ledger.tasks[task.parent_task_id]
  113. tree = await trace_store.get_goal_tree(root_trace_id) or tree
  114. parent_goal_id = parent.goal_id
  115. after_goal_id = None
  116. if after_task_id and after_task_id in ledger.tasks:
  117. after_goal_id = ledger.tasks[after_task_id].goal_id
  118. if after_goal_id and tree.find(after_goal_id):
  119. goal = tree.add_goals_after(
  120. after_goal_id,
  121. descriptions=[task.current_spec.objective],
  122. reasons=["TaskLedger compatibility projection"],
  123. )[0]
  124. else:
  125. goal = tree.add_goals(
  126. descriptions=[task.current_spec.objective],
  127. reasons=["TaskLedger compatibility projection"],
  128. parent_id=parent_goal_id,
  129. )[0]
  130. await trace_store.update_goal_tree(root_trace_id, tree)
  131. def link(current: TaskLedger) -> Dict[str, Any]:
  132. current_task = _task(current, task_id)
  133. if current_task.goal_id is None:
  134. current_task.goal_id = goal.id
  135. return {"task_id": task_id, "goal_id": current_task.goal_id}
  136. await self._mutate(root_trace_id, "goal_projection_linked", link)
  137. async def reconcile(self, root_trace_id: str) -> Dict[str, Any]:
  138. ledger = await self._task_store.load(root_trace_id)
  139. for task_id in ledger.tasks:
  140. await self.ensure(root_trace_id, task_id)
  141. await self.project_state(root_trace_id, task_id)
  142. return {
  143. "root_trace_id": root_trace_id,
  144. "reconciled_tasks": len(ledger.tasks),
  145. }
  146. def _task(ledger: TaskLedger, task_id: str) -> TaskRecord:
  147. try:
  148. return ledger.tasks[task_id]
  149. except KeyError as exc:
  150. raise ValueError(f"Task not found: {task_id}") from exc
  151. __all__ = ["GOAL_STATUS_BY_TASK_STATUS", "GoalProjection"]