perf(core): Optimize DAG engine, orchestrator loop, and simulations

This commit is contained in:
2026-05-06 15:27:27 -04:00
parent d0aff71430
commit f628e0b29a
4 changed files with 109 additions and 72 deletions
@@ -14,10 +14,10 @@
- [x] Task: Conductor - User Manual Verification 'Phase 2: Audit and Profiling (`src/` and `simulation/`)' (Protocol in workflow.md) (7a72987) - [x] Task: Conductor - User Manual Verification 'Phase 2: Audit and Profiling (`src/` and `simulation/`)' (Protocol in workflow.md) (7a72987)
## Phase 3: Targeted Optimization and Refactoring ## Phase 3: Targeted Optimization and Refactoring
- [ ] Task: Write/update tests for the first identified bottleneck to establish a performance or structural baseline (Red Phase). - [x] Task: Write/update tests for the first identified bottleneck to establish a performance or structural baseline (Red Phase). (2e68f1e)
- [ ] Task: Refactor the first identified bottleneck to align with data-oriented guidelines (Green Phase). - [x] Task: Refactor the first identified bottleneck to align with data-oriented guidelines (Green Phase). (2e68f1e)
- [ ] Task: Write/update tests for remaining identified bottlenecks. - [x] Task: Write/update tests for remaining identified bottlenecks. (56e9627)
- [ ] Task: Refactor remaining identified bottlenecks. - [x] Task: Refactor remaining identified bottlenecks. (d0aff71)
- [ ] Task: Conductor - User Manual Verification 'Phase 3: Targeted Optimization and Refactoring' (Protocol in workflow.md) - [ ] Task: Conductor - User Manual Verification 'Phase 3: Targeted Optimization and Refactoring' (Protocol in workflow.md)
## Phase 4: Final Evaluation and Documentation ## Phase 4: Final Evaluation and Documentation
+18 -13
View File
@@ -4,10 +4,11 @@ from typing import Any, Callable
from src import ai_client from src import ai_client
class UserSimAgent: class UserSimAgent:
def __init__(self, hook_client: Any, model: str = "gemini-2.5-flash-lite", enable_delays: bool = True) -> None: def __init__(self, hook_client: Any, model: str = "gemini-2.5-flash-lite", enable_delays: bool = True, batch_typing: bool = False) -> None:
self.hook_client = hook_client self.hook_client = hook_client
self.model = model self.model = model
self.enable_delays = enable_delays self.enable_delays = enable_delays
self.batch_typing = batch_typing
self.system_prompt = ( self.system_prompt = (
"You are a software engineer testing an AI coding assistant called 'Manual Slop'. " "You are a software engineer testing an AI coding assistant called 'Manual Slop'. "
"You want to build a small Python project and verify the assistant's capabilities. " "You want to build a small Python project and verify the assistant's capabilities. "
@@ -30,18 +31,22 @@ class UserSimAgent:
delay = random.uniform(min_delay, max_delay) delay = random.uniform(min_delay, max_delay)
time.sleep(delay) time.sleep(delay)
def simulate_typing(self, text: str, jitter_range: tuple[float, float] = (0.01, 0.05)) -> None: def simulate_typing(self, text: str, jitter_range: tuple[float, float] = (0.01, 0.05), batch_typing: bool = False) -> None:
if self.enable_delays: if not self.enable_delays:
# Simulate typing by sleeping after chunks or characters to balance speed and realism return
if len(text) > 200: if batch_typing or self.batch_typing:
for i in range(0, len(text), 10): time.sleep(0.01)
time.sleep(random.uniform(jitter_range[0] * 3, jitter_range[1] * 3)) return
elif len(text) > 50: # Simulate typing by sleeping after chunks or characters to balance speed and realism
for i in range(0, len(text), 3): if len(text) > 200:
time.sleep(random.uniform(jitter_range[0] * 1.5, jitter_range[1] * 1.5)) for i in range(0, len(text), 10):
else: time.sleep(random.uniform(jitter_range[0] * 3, jitter_range[1] * 3))
for char in text: elif len(text) > 50:
time.sleep(random.uniform(jitter_range[0], jitter_range[1])) for i in range(0, len(text), 3):
time.sleep(random.uniform(jitter_range[0] * 1.5, jitter_range[1] * 1.5))
else:
for char in text:
time.sleep(random.uniform(jitter_range[0], jitter_range[1]))
def generate_response(self, conversation_history: list[dict]) -> str: def generate_response(self, conversation_history: list[dict]) -> str:
""" """
+66 -50
View File
@@ -48,19 +48,29 @@ class TrackDAG:
def cascade_blocks(self) -> None: def cascade_blocks(self) -> None:
""" """
Transitively marks `todo` tickets as `blocked` if any dependency is `blocked`. 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). Propagates 'blocked' status from initially blocked nodes to their dependents.
""" """
changed = True with get_monitor().scope("dag_cascade_blocks"):
while changed: # Build adjacency list of dependents using object references to avoid lookups
changed = False dependents = {t.id: [] for t in self.tickets}
for ticket in self.tickets: for t in self.tickets:
if ticket.status == 'todo': for dep_id in t.depends_on:
for dep_id in ticket.depends_on: if dep_id in dependents:
dep = self.ticket_map.get(dep_id) dependents[dep_id].append(t)
if dep and dep.status == 'blocked':
ticket.status = 'blocked' # Use a queue-based propagation (BFS) from all currently blocked tickets
changed = True queue = [t for t in self.tickets if t.status == 'blocked']
break idx = 0
while idx < len(queue):
curr = queue[idx]
idx += 1
for dep_ticket in dependents.get(curr.id, []):
if dep_ticket.status == 'todo':
dep_ticket.status = 'blocked'
# Optional: preserve the reason for blocking
if not dep_ticket.blocked_reason:
dep_ticket.blocked_reason = f"Dependency {curr.id} is blocked."
queue.append(dep_ticket)
def is_ticket_ready(self, ticket: Ticket) -> bool: def is_ticket_ready(self, ticket: Ticket) -> bool:
"""Returns True if all dependencies of the ticket are completed.""" """Returns True if all dependencies of the ticket are completed."""
@@ -84,62 +94,68 @@ class TrackDAG:
def has_cycle(self) -> bool: def has_cycle(self) -> bool:
""" """
Performs a Depth-First Search to detect cycles in the dependency graph. Performs an iterative Depth-First Search to detect cycles in the dependency graph.
Returns: Returns:
True if a cycle is detected, False otherwise. True if a cycle is detected, False otherwise.
""" """
with get_monitor().scope("dag_has_cycle"): with get_monitor().scope("dag_has_cycle"):
visited = set() visited = set()
rec_stack = set() for start_ticket in self.tickets:
if start_ticket.id in visited:
def is_cyclic(ticket_id: str) -> bool: continue
"""Internal recursive helper for cycle detection.""" stack = [(start_ticket.id, False)] # (id, is_backtracking)
if ticket_id in rec_stack: path = set()
return True while stack:
if ticket_id in visited: node_id, is_backtracking = stack.pop()
return False if is_backtracking:
visited.add(ticket_id) path.remove(node_id)
rec_stack.add(ticket_id) continue
ticket = self.ticket_map.get(ticket_id) if node_id in path:
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 True
if node_id in visited:
continue
visited.add(node_id)
path.add(node_id)
stack.append((node_id, True))
ticket = self.ticket_map.get(node_id)
if ticket:
for neighbor_id in ticket.depends_on:
stack.append((neighbor_id, False))
return False return False
def topological_sort(self) -> List[str]: def topological_sort(self) -> List[str]:
""" """
Returns a list of ticket IDs in topological order (dependencies before dependents). Returns a list of ticket IDs in topological order (dependencies before dependents).
Uses Kahn's algorithm for efficient O(V+E) sorting and cycle detection.
Returns: Returns:
A list of ticket ID strings. A list of ticket ID strings.
Raises: Raises:
ValueError: If a dependency cycle is detected. ValueError: If a dependency cycle is detected.
""" """
with get_monitor().scope("dag_topological_sort"): with get_monitor().scope("dag_topological_sort"):
if self.has_cycle(): in_degree = {t.id: len(t.depends_on) for t in self.tickets}
raise ValueError("Dependency cycle detected") dependents = {t.id: [] for t in self.tickets}
visited = set() for t in self.tickets:
stack = [] for dep_id in t.depends_on:
if dep_id in dependents:
dependents[dep_id].append(t.id)
def visit(ticket_id: str) -> None: # Queue starts with nodes having no dependencies
"""Internal recursive helper for topological sorting.""" queue = [t.id for t in self.tickets if in_degree[t.id] == 0]
if ticket_id in visited: result = []
return idx = 0
visited.add(ticket_id) while idx < len(queue):
ticket = self.ticket_map.get(ticket_id) u = queue[idx]
if ticket: idx += 1
for dep_id in ticket.depends_on: result.append(u)
visit(dep_id) for v_id in dependents.get(u, []):
stack.append(ticket_id) in_degree[v_id] -= 1
for ticket in self.tickets: if in_degree[v_id] == 0:
visit(ticket.id) queue.append(v_id)
return stack
if len(result) < len(self.tickets):
raise ValueError("Dependency cycle detected")
return result
class ExecutionEngine: class ExecutionEngine:
""" """
+20 -4
View File
@@ -40,6 +40,8 @@ from src import models
from src.models import Ticket, Track, WorkerContext from src.models import Ticket, Track, WorkerContext
from src.file_cache import ASTParser from src.file_cache import ASTParser
from pathlib import Path from pathlib import Path
from src.personas import PersonaManager
from src import paths
from src.dag_engine import TrackDAG, ExecutionEngine from src.dag_engine import TrackDAG, ExecutionEngine
@@ -122,6 +124,7 @@ class ConductorEngine:
self._abort_events: dict[str, threading.Event] = {} self._abort_events: dict[str, threading.Event] = {}
self._pause_event: threading.Event = threading.Event() self._pause_event: threading.Event = threading.Event()
self._tier_usage_lock = threading.Lock() self._tier_usage_lock = threading.Lock()
self._dirty: bool = True
def update_usage(self, tier: str, input_tokens: int, output_tokens: int) -> None: def update_usage(self, tier: str, input_tokens: int, output_tokens: int) -> None:
"""Updates token usage for a specific tier.""" """Updates token usage for a specific tier."""
@@ -138,6 +141,16 @@ class ConductorEngine:
"""Resumes the pipeline execution.""" """Resumes the pipeline execution."""
self._pause_event.clear() self._pause_event.clear()
def approve_task(self, task_id: str) -> None:
"""Manually transition todo to in_progress and mark engine dirty."""
self.engine.approve_task(task_id)
self._dirty = True
def update_task_status(self, task_id: str, status: str) -> None:
"""Force-update ticket status and mark engine dirty."""
self.engine.update_task_status(task_id, status)
self._dirty = True
def kill_worker(self, ticket_id: str) -> None: def kill_worker(self, ticket_id: str) -> None:
"""Sets the abort event for a worker and attempts to join its thread.""" """Sets the abort event for a worker and attempts to join its thread."""
if ticket_id in self._abort_events: if ticket_id in self._abort_events:
@@ -216,10 +229,14 @@ class ConductorEngine:
if max_ticks is not None and tick_count >= max_ticks: if max_ticks is not None and tick_count >= max_ticks:
break break
tick_count += 1 tick_count += 1
# 1. Identify ready tasks # 1. Identify ready tasks
ready_tasks = self.engine.tick() if self._dirty:
self._ready_tasks = self.engine.tick()
self._dirty = False
ready_tasks = self._ready_tasks
# 2. Check for completion or blockage # 2. Check for completion or blockage
if not ready_tasks: if not ready_tasks:
all_done = all(t.status == "completed" for t in self.track.tickets) all_done = all(t.status == "completed" for t in self.track.tickets)
if all_done: if all_done:
@@ -404,8 +421,6 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
persona_tool_preset = None persona_tool_preset = None
persona = None persona = None
if context.persona_id: if context.persona_id:
from src.personas import PersonaManager
from src import paths
pm = PersonaManager(Path(paths.get_project_personas_path(Path.cwd())) if paths.get_project_personas_path(Path.cwd()).exists() else None) pm = PersonaManager(Path(paths.get_project_personas_path(Path.cwd())) if paths.get_project_personas_path(Path.cwd()).exists() else None)
try: try:
personas = pm.load_all() personas = pm.load_all()
@@ -587,6 +602,7 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
_in_tokens = sum(e.get("payload", {}).get("usage", {}).get("input_tokens", 0) for e in _resp_entries) _in_tokens = sum(e.get("payload", {}).get("usage", {}).get("input_tokens", 0) for e in _resp_entries)
_out_tokens = sum(e.get("payload", {}).get("usage", {}).get("output_tokens", 0) for e in _resp_entries) _out_tokens = sum(e.get("payload", {}).get("usage", {}).get("output_tokens", 0) for e in _resp_entries)
engine.update_usage("Tier 3", _in_tokens, _out_tokens) engine.update_usage("Tier 3", _in_tokens, _out_tokens)
engine._dirty = True
if "BLOCKED" in response.upper(): if "BLOCKED" in response.upper():
ticket.mark_blocked(response) ticket.mark_blocked(response)
else: else: