Files
manual_slop/src/dag_engine.py
2026-03-08 01:46:34 -05:00

196 lines
6.1 KiB
Python

"""
DAG Engine - Directed Acyclic Graph execution for MMA ticket orchestration.
This module provides the core graph data structures and state machine logic
for executing implementation tickets in dependency order within the MMA
(Multi-Model Agent) system.
Key Classes:
- TrackDAG: Graph representation with cycle detection, topological sorting,
and transitive blocking propagation.
- ExecutionEngine: Tick-based state machine that evaluates the DAG and
manages task status transitions.
Architecture Integration:
- TrackDAG is constructed from a list of Ticket objects (from models.py)
- ExecutionEngine is consumed by ConductorEngine (multi_agent_conductor.py)
- The tick() method is called in the main orchestration loop to determine
which tasks are ready for execution
Thread Safety:
- This module is NOT thread-safe. Callers must synchronize access if used
from multiple threads (e.g., the ConductorEngine's async loop).
See Also:
- docs/guide_mma.md for the full MMA orchestration documentation
- src/models.py for Ticket and Track data structures
- src/multi_agent_conductor.py for ConductorEngine integration
"""
from typing import List
from src.models import Ticket
class TrackDAG:
"""
Manages a Directed Acyclic Graph of implementation tickets.
Provides methods for dependency resolution, cycle detection, and topological sorting.
"""
def __init__(self, tickets: List[Ticket]) -> None:
"""
Initializes the TrackDAG with a list of Ticket objects.
Args:
tickets: A list of Ticket instances defining the graph nodes and edges.
"""
self.tickets = tickets
self.ticket_map = {t.id: t for t in tickets}
def cascade_blocks(self) -> None:
"""
Transitively marks `todo` tickets as `blocked` if any dependency is `blocked`.
Runs until stable (handles multi-hop chains: A→B→C where A blocked cascades to B then C).
"""
changed = True
while changed:
changed = False
for ticket in self.tickets:
if ticket.status == 'todo':
for dep_id in ticket.depends_on:
dep = self.ticket_map.get(dep_id)
if dep and dep.status == 'blocked':
ticket.status = 'blocked'
changed = True
break
def get_ready_tasks(self) -> List[Ticket]:
"""
Returns a list of tickets that are in 'todo' status and whose dependencies are all 'completed'.
Returns:
A list of Ticket objects ready for execution.
"""
ready = []
for ticket in self.tickets:
if ticket.status == 'todo':
# Check if all dependencies exist and are completed
all_done = True
for dep_id in ticket.depends_on:
dep = self.ticket_map.get(dep_id)
if not dep or dep.status != 'completed':
all_done = False
break
if all_done:
ready.append(ticket)
return ready
def has_cycle(self) -> bool:
"""
Performs a Depth-First Search to detect cycles in the dependency graph.
Returns:
True if a cycle is detected, False otherwise.
"""
visited = set()
rec_stack = set()
def is_cyclic(ticket_id: str) -> bool:
"""Internal recursive helper for cycle detection."""
if ticket_id in rec_stack:
return True
if ticket_id in visited:
return False
visited.add(ticket_id)
rec_stack.add(ticket_id)
ticket = self.ticket_map.get(ticket_id)
if ticket:
for neighbor in ticket.depends_on:
if is_cyclic(neighbor):
return True
rec_stack.remove(ticket_id)
return False
for ticket in self.tickets:
if ticket.id not in visited:
if is_cyclic(ticket.id):
return True
return False
def topological_sort(self) -> List[str]:
"""
Returns a list of ticket IDs in topological order (dependencies before dependents).
Returns:
A list of ticket ID strings.
Raises:
ValueError: If a dependency cycle is detected.
"""
if self.has_cycle():
raise ValueError("Dependency cycle detected")
visited = set()
stack = []
def visit(ticket_id: str) -> None:
"""Internal recursive helper for topological sorting."""
if ticket_id in visited:
return
visited.add(ticket_id)
ticket = self.ticket_map.get(ticket_id)
if ticket:
for dep_id in ticket.depends_on:
visit(dep_id)
stack.append(ticket_id)
for ticket in self.tickets:
visit(ticket.id)
return stack
class ExecutionEngine:
"""
A state machine that governs the progression of tasks within a TrackDAG.
Handles automatic queueing and manual task approval.
"""
def __init__(self, dag: TrackDAG, auto_queue: bool = False) -> None:
"""
Initializes the ExecutionEngine.
Args:
dag: The TrackDAG instance to manage.
auto_queue: If True, ready tasks will automatically move to 'in_progress'.
"""
self.dag = dag
self.auto_queue = auto_queue
def tick(self) -> List[Ticket]:
"""
Evaluates the DAG and returns a list of tasks that are currently 'ready' for execution.
If auto_queue is enabled, tasks without 'step_mode' will be marked as 'in_progress'.
Returns:
A list of ready Ticket objects.
"""
self.dag.cascade_blocks()
ready = self.dag.get_ready_tasks()
return ready
def approve_task(self, task_id: str) -> None:
"""
Manually transitions a task from 'todo' to 'in_progress' if its dependencies are met.
Args:
task_id: The ID of the task to approve.
"""
ticket = self.dag.ticket_map.get(task_id)
if ticket and ticket.status == "todo":
# Check if dependencies are met first
all_done = True
for dep_id in ticket.depends_on:
dep = self.dag.ticket_map.get(dep_id)
if not dep or dep.status != "completed":
all_done = False
break
if all_done:
ticket.status = "in_progress"
def update_task_status(self, task_id: str, status: str) -> None:
"""
Force-updates the status of a specific task.
Args:
task_id: The ID of the task.
status: The new status string (e.g., 'todo', 'in_progress', 'completed', 'blocked').
"""
ticket = self.dag.ticket_map.get(task_id)
if ticket:
ticket.status = status