Compare commits
18 Commits
a5684bf773
...
6c887e498d
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c887e498d | |||
| bf1faac4ea | |||
| a744b39e4f | |||
| c2c0b41571 | |||
| 5f748c4de3 | |||
| 6548ce6496 | |||
| c15e8b8d1f | |||
| 2d355d4461 | |||
| a9436cbdad | |||
| 2429b7c1b4 | |||
| 154957fe57 | |||
| f85ec9d06f | |||
| a3cfeff9d8 | |||
| 3c0d412219 | |||
| 46e11bccdc | |||
| b845b89543 | |||
| 134a11cdc2 | |||
| e1a3712d9a |
18
.gemini/agents/tier1-orchestrator.md
Normal file
18
.gemini/agents/tier1-orchestrator.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: tier1-orchestrator
|
||||||
|
description: Tier 1 Orchestrator for product alignment and high-level planning.
|
||||||
|
model: gemini-3.1-pro-preview
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- list_directory
|
||||||
|
- glob
|
||||||
|
- grep_search
|
||||||
|
- google_web_search
|
||||||
|
- web_fetch
|
||||||
|
- codebase_investigator
|
||||||
|
- activate_skill
|
||||||
|
- discovered_tool_run_powershell
|
||||||
|
---
|
||||||
|
STRICT SYSTEM DIRECTIVE: You are a Tier 1 Orchestrator.
|
||||||
|
Focused on product alignment, high-level planning, and track initialization.
|
||||||
|
ONLY output the requested text. No pleasantries.
|
||||||
20
.gemini/agents/tier2-tech-lead.md
Normal file
20
.gemini/agents/tier2-tech-lead.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: tier2-tech-lead
|
||||||
|
description: Tier 2 Tech Lead for architectural design and execution.
|
||||||
|
model: gemini-3-flash-preview
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- write_file
|
||||||
|
- replace
|
||||||
|
- list_directory
|
||||||
|
- glob
|
||||||
|
- grep_search
|
||||||
|
- google_web_search
|
||||||
|
- web_fetch
|
||||||
|
- codebase_investigator
|
||||||
|
- activate_skill
|
||||||
|
- discovered_tool_run_powershell
|
||||||
|
---
|
||||||
|
STRICT SYSTEM DIRECTIVE: You are a Tier 2 Tech Lead.
|
||||||
|
Focused on architectural design and track execution.
|
||||||
|
ONLY output the requested text. No pleasantries.
|
||||||
22
.gemini/agents/tier3-worker.md
Normal file
22
.gemini/agents/tier3-worker.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: tier3-worker
|
||||||
|
description: Stateless Tier 3 Worker for code implementation and TDD.
|
||||||
|
model: gemini-3-flash-preview
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- write_file
|
||||||
|
- replace
|
||||||
|
- list_directory
|
||||||
|
- glob
|
||||||
|
- grep_search
|
||||||
|
- google_web_search
|
||||||
|
- web_fetch
|
||||||
|
- codebase_investigator
|
||||||
|
- activate_skill
|
||||||
|
- discovered_tool_run_powershell
|
||||||
|
---
|
||||||
|
STRICT SYSTEM DIRECTIVE: You are a stateless Tier 3 Worker (Contributor).
|
||||||
|
Your goal is to implement specific code changes or tests based on the provided task.
|
||||||
|
You have access to tools for reading and writing files, codebase investigation, and web tools.
|
||||||
|
You CAN execute PowerShell scripts or run shell commands via discovered_tool_run_powershell for verification and testing.
|
||||||
|
Follow TDD and return success status or code changes. No pleasantries, no conversational filler.
|
||||||
20
.gemini/agents/tier4-qa.md
Normal file
20
.gemini/agents/tier4-qa.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: tier4-qa
|
||||||
|
description: Stateless Tier 4 QA Agent for log analysis and diagnostics.
|
||||||
|
model: gemini-2.5-flash-lite
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- list_directory
|
||||||
|
- glob
|
||||||
|
- grep_search
|
||||||
|
- google_web_search
|
||||||
|
- web_fetch
|
||||||
|
- codebase_investigator
|
||||||
|
- activate_skill
|
||||||
|
- discovered_tool_run_powershell
|
||||||
|
---
|
||||||
|
STRICT SYSTEM DIRECTIVE: You are a stateless Tier 4 QA Agent.
|
||||||
|
Your goal is to analyze errors, summarize logs, or verify tests.
|
||||||
|
You have access to tools for reading files, exploring the codebase, and web tools.
|
||||||
|
You CAN execute PowerShell scripts or run shell commands via discovered_tool_run_powershell for diagnostics.
|
||||||
|
ONLY output the requested analysis. No pleasantries.
|
||||||
22
.gemini/policies/mma_headless.toml
Normal file
22
.gemini/policies/mma_headless.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[[rule]]
|
||||||
|
toolName = [
|
||||||
|
"read_file",
|
||||||
|
"write_file",
|
||||||
|
"replace",
|
||||||
|
"list_directory",
|
||||||
|
"glob",
|
||||||
|
"grep_search",
|
||||||
|
"search_files",
|
||||||
|
"get_file_summary",
|
||||||
|
"google_web_search",
|
||||||
|
"web_fetch",
|
||||||
|
"codebase_investigator",
|
||||||
|
"cli_help",
|
||||||
|
"activate_skill",
|
||||||
|
"run_shell_command",
|
||||||
|
"run_powershell",
|
||||||
|
"discovered_tool_run_powershell"
|
||||||
|
]
|
||||||
|
decision = "allow"
|
||||||
|
priority = 900
|
||||||
|
description = "Allow all MMA tools for sub-agents in headless mode."
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"experimental": {
|
||||||
|
"enableAgents": true
|
||||||
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"discoveryCommand": "python C:/projects/manual_slop/scripts/tool_discovery.py",
|
"discoveryCommand": "python C:/projects/manual_slop/scripts/tool_discovery.py",
|
||||||
"whitelist": [
|
"whitelist": [
|
||||||
|
|||||||
19
conductor/archive/mma_data_architecture_dag_engine/plan.md
Normal file
19
conductor/archive/mma_data_architecture_dag_engine/plan.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Implementation Plan: MMA Data Architecture & DAG Engine
|
||||||
|
|
||||||
|
## Phase 1: Track-Scoped State Management
|
||||||
|
- [x] Task: Define the data schema for a Track (Metadata, Discussion History, Task List). [2efe80e]
|
||||||
|
- [x] Task: Update `project_manager.py` to create and read from `tracks/<track_id>/state.toml`. [e1a3712]
|
||||||
|
- [x] Task: Ensure Tier 2 (Tech Lead) history is securely scoped to the active track's state file. [b845b89]
|
||||||
|
|
||||||
|
## Phase 2: Python DAG Engine
|
||||||
|
- [x] Task: Create a `Task` class with `status` (Blocked, Ready, In Progress, Review, Done) and `depends_on` fields. [a3cfeff]
|
||||||
|
- [x] Task: Implement a topological sorting algorithm to resolve execution order. [f85ec9d]
|
||||||
|
- [x] Task: Write robust unit tests verifying cycle detection and dependency resolution. [f85ec9d]
|
||||||
|
|
||||||
|
## Phase 3: Execution State Machine
|
||||||
|
- [x] Task: Implement the core loop that evaluates the DAG and identifies "Ready" tasks. [154957f]
|
||||||
|
- [x] Task: Create configuration settings for "Auto-Queue" vs "Manual Step" execution modes. [154957f]
|
||||||
|
- [x] Task: Connect the state machine to the backend dispatcher, preparing it for GUI integration. [2429b7c]
|
||||||
|
|
||||||
|
## Phase: Review Fixes
|
||||||
|
- [x] Task: Apply review suggestions [6548ce6]
|
||||||
@@ -15,7 +15,10 @@ To serve as an expert-level utility for personal developer use on small projects
|
|||||||
- **Tier 2 (Tech Lead):** Technical oversight and track execution (`/conductor:implement`) using `gemini-2.5-flash`. Maintains persistent context throughout implementation.
|
- **Tier 2 (Tech Lead):** Technical oversight and track execution (`/conductor:implement`) using `gemini-2.5-flash`. Maintains persistent context throughout implementation.
|
||||||
- **Tier 3 (Worker):** Surgical code implementation and TDD using `gemini-2.5-flash` or `deepseek-v3`. Operates statelessly with tool access and dependency skeletons.
|
- **Tier 3 (Worker):** Surgical code implementation and TDD using `gemini-2.5-flash` or `deepseek-v3`. Operates statelessly with tool access and dependency skeletons.
|
||||||
- **Tier 4 (QA):** Error analysis and diagnostics using `gemini-2.5-flash` or `deepseek-v3`. Operates statelessly with tool access.
|
- **Tier 4 (QA):** Error analysis and diagnostics using `gemini-2.5-flash` or `deepseek-v3`. Operates statelessly with tool access.
|
||||||
- **MMA Delegation Engine:** Utilizes the `mma-exec` CLI and `mma.ps1` helper to route tasks, ensuring role-scoped context and detailed observability via timestamped sub-agent logs. Supports dynamic ticket creation and dependency resolution via an automated Dispatcher Loop.
|
- **MMA Delegation Engine:** Route tasks, ensuring role-scoped context and detailed observability via timestamped sub-agent logs. Supports dynamic ticket creation and dependency resolution via an automated Dispatcher Loop.
|
||||||
|
- **Track-Scoped State Management:** Segregates discussion history and task progress into per-track state files (e.g., `conductor/tracks/<track_id>/state.toml`). This prevents global context pollution and ensures the Tech Lead session is isolated to the specific track's objective.
|
||||||
|
- **Native DAG Execution Engine:** Employs a Python-based Directed Acyclic Graph (DAG) engine to manage complex task dependencies, supporting automated topological sorting and robust cycle detection.
|
||||||
|
- **Programmable Execution State Machine:** Governing the transition between "Auto-Queue" (autonomous worker spawning) and "Step Mode" (explicit manual approval for each task transition).
|
||||||
- **Role-Scoped Documentation:** Automated mapping of foundational documents to specific tiers to prevent token bloat and maintain high-signal context.
|
- **Role-Scoped Documentation:** Automated mapping of foundational documents to specific tiers to prevent token bloat and maintain high-signal context.
|
||||||
- **Strict Memory Siloing:** Employs tree-sitter AST-based interface extraction (Skeleton View, Curated View) and "Context Amnesia" to provide workers only with the absolute minimum context required, preventing hallucination loops.
|
- **Strict Memory Siloing:** Employs tree-sitter AST-based interface extraction (Skeleton View, Curated View) and "Context Amnesia" to provide workers only with the absolute minimum context required, preventing hallucination loops.
|
||||||
- **Explicit Execution Control:** All AI-generated PowerShell scripts require explicit human confirmation via interactive UI dialogs before execution, supported by a global "Linear Execution Clutch" for deterministic debugging.
|
- **Explicit Execution Control:** All AI-generated PowerShell scripts require explicit human confirmation via interactive UI dialogs before execution, supported by a global "Linear Execution Clutch" for deterministic debugging.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
- **pytest:** For unit and integration testing, leveraging custom fixtures for live GUI verification.
|
- **pytest:** For unit and integration testing, leveraging custom fixtures for live GUI verification.
|
||||||
- **ApiHookClient:** A dedicated IPC client for automated GUI interaction and state inspection.
|
- **ApiHookClient:** A dedicated IPC client for automated GUI interaction and state inspection.
|
||||||
- **mma-exec / mma.ps1:** Python-based execution engine and PowerShell wrapper for managing the 4-Tier MMA hierarchy and automated documentation mapping.
|
- **mma-exec / mma.ps1:** Python-based execution engine and PowerShell wrapper for managing the 4-Tier MMA hierarchy and automated documentation mapping.
|
||||||
|
- **dag_engine.py:** A native Python utility implementing `TrackDAG` and `ExecutionEngine` for dependency resolution, cycle detection, and programmable task execution loops.
|
||||||
|
|
||||||
## Architectural Patterns
|
## Architectural Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ This file tracks all major tracks for the project. Each track has its own detail
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] **Track: MMA Data Architecture & DAG Engine**
|
- [~] **Track: Tiered Context Scoping & HITL Approval**
|
||||||
*Link: [./tracks/mma_data_architecture_dag_engine/](./tracks/mma_data_architecture_dag_engine/)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- [ ] **Track: Tiered Context Scoping & HITL Approval**
|
|
||||||
*Link: [./tracks/tiered_context_scoping_hitl_approval/](./tracks/tiered_context_scoping_hitl_approval/)*
|
*Link: [./tracks/tiered_context_scoping_hitl_approval/](./tracks/tiered_context_scoping_hitl_approval/)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# Implementation Plan: MMA Data Architecture & DAG Engine
|
|
||||||
|
|
||||||
## Phase 1: Track-Scoped State Management
|
|
||||||
- [x] Task: Define the data schema for a Track (Metadata, Discussion History, Task List). [2efe80e]
|
|
||||||
- [ ] Task: Update `project_manager.py` to create and read from `tracks/<track_id>/state.toml`.
|
|
||||||
- [ ] Task: Ensure Tier 2 (Tech Lead) history is securely scoped to the active track's state file.
|
|
||||||
|
|
||||||
## Phase 2: Python DAG Engine
|
|
||||||
- [ ] Task: Create a `Task` class with `status` (Blocked, Ready, In Progress, Review, Done) and `depends_on` fields.
|
|
||||||
- [ ] Task: Implement a topological sorting algorithm to resolve execution order.
|
|
||||||
- [ ] Task: Write robust unit tests verifying cycle detection and dependency resolution.
|
|
||||||
|
|
||||||
## Phase 3: Execution State Machine
|
|
||||||
- [ ] Task: Implement the core loop that evaluates the DAG and identifies "Ready" tasks.
|
|
||||||
- [ ] Task: Create configuration settings for "Auto-Queue" vs "Manual Step" execution modes.
|
|
||||||
- [ ] Task: Connect the state machine to the backend dispatcher, preparing it for GUI integration.
|
|
||||||
@@ -56,43 +56,29 @@ def generate_tickets(track_brief: str, module_skeletons: str) -> list[dict]:
|
|||||||
# Restore old system prompt
|
# Restore old system prompt
|
||||||
ai_client.set_custom_system_prompt(old_system_prompt)
|
ai_client.set_custom_system_prompt(old_system_prompt)
|
||||||
|
|
||||||
|
from dag_engine import TrackDAG
|
||||||
|
from models import Ticket
|
||||||
|
|
||||||
def topological_sort(tickets: list[dict]) -> list[dict]:
|
def topological_sort(tickets: list[dict]) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Sorts a list of tickets based on their 'depends_on' field.
|
Sorts a list of tickets based on their 'depends_on' field.
|
||||||
Raises ValueError if a circular dependency or missing internal dependency is detected.
|
Raises ValueError if a circular dependency or missing internal dependency is detected.
|
||||||
"""
|
"""
|
||||||
# 1. Map ID to ticket and build graph
|
# 1. Convert to Ticket objects for TrackDAG
|
||||||
|
ticket_objs = []
|
||||||
|
for t_data in tickets:
|
||||||
|
ticket_objs.append(Ticket.from_dict(t_data))
|
||||||
|
|
||||||
|
# 2. Use TrackDAG for validation and sorting
|
||||||
|
dag = TrackDAG(ticket_objs)
|
||||||
|
try:
|
||||||
|
sorted_ids = dag.topological_sort()
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"DAG Validation Error: {e}")
|
||||||
|
|
||||||
|
# 3. Return sorted dictionaries
|
||||||
ticket_map = {t['id']: t for t in tickets}
|
ticket_map = {t['id']: t for t in tickets}
|
||||||
adj = {t['id']: [] for t in tickets}
|
return [ticket_map[tid] for tid in sorted_ids]
|
||||||
in_degree = {t['id']: 0 for t in tickets}
|
|
||||||
|
|
||||||
for t in tickets:
|
|
||||||
for dep_id in t.get('depends_on', []):
|
|
||||||
if dep_id not in ticket_map:
|
|
||||||
raise ValueError(f"Missing dependency: Ticket '{t['id']}' depends on '{dep_id}', but '{dep_id}' is not in the ticket list.")
|
|
||||||
adj[dep_id].append(t['id'])
|
|
||||||
in_degree[t['id']] += 1
|
|
||||||
|
|
||||||
# 2. Find nodes with in-degree 0
|
|
||||||
queue = [t['id'] for t in tickets if in_degree[t['id']] == 0]
|
|
||||||
sorted_ids = []
|
|
||||||
|
|
||||||
# 3. Process queue
|
|
||||||
while queue:
|
|
||||||
u_id = queue.pop(0)
|
|
||||||
sorted_ids.append(u_id)
|
|
||||||
for v_id in adj[u_id]:
|
|
||||||
in_degree[v_id] -= 1
|
|
||||||
if in_degree[v_id] == 0:
|
|
||||||
queue.append(v_id)
|
|
||||||
|
|
||||||
# 4. Check for cycles
|
|
||||||
if len(sorted_ids) != len(tickets):
|
|
||||||
# Find which tickets are part of a cycle (or blocked by one)
|
|
||||||
remaining = [t_id for t_id in ticket_map if t_id not in sorted_ids]
|
|
||||||
raise ValueError(f"Circular dependency detected among tickets: {remaining}")
|
|
||||||
|
|
||||||
return [ticket_map[t_id] for t_id in sorted_ids]
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Quick test if run directly
|
# Quick test if run directly
|
||||||
|
|||||||
161
dag_engine.py
Normal file
161
dag_engine.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from 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]):
|
||||||
|
"""
|
||||||
|
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 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):
|
||||||
|
"""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):
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
ready = self.dag.get_ready_tasks()
|
||||||
|
|
||||||
|
if self.auto_queue:
|
||||||
|
for ticket in ready:
|
||||||
|
if not ticket.step_mode:
|
||||||
|
ticket.status = "in_progress"
|
||||||
|
|
||||||
|
return ready
|
||||||
|
|
||||||
|
def approve_task(self, task_id: str):
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
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
|
||||||
30
gui_2.py
30
gui_2.py
@@ -754,6 +754,12 @@ class App:
|
|||||||
self.active_track = None
|
self.active_track = None
|
||||||
self.active_tickets = []
|
self.active_tickets = []
|
||||||
|
|
||||||
|
# Load track-scoped history if track is active
|
||||||
|
if self.active_track:
|
||||||
|
track_history = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir)
|
||||||
|
if track_history:
|
||||||
|
self.disc_entries = _parse_history_entries(track_history, self.disc_roles)
|
||||||
|
|
||||||
def _save_active_project(self):
|
def _save_active_project(self):
|
||||||
if self.active_project_path:
|
if self.active_project_path:
|
||||||
try:
|
try:
|
||||||
@@ -790,6 +796,10 @@ class App:
|
|||||||
|
|
||||||
def _flush_disc_entries_to_project(self):
|
def _flush_disc_entries_to_project(self):
|
||||||
history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries]
|
history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries]
|
||||||
|
if self.active_track:
|
||||||
|
project_manager.save_track_history(self.active_track.id, history_strings, self.ui_files_base_dir)
|
||||||
|
return
|
||||||
|
|
||||||
disc_sec = self.project.setdefault("discussion", {})
|
disc_sec = self.project.setdefault("discussion", {})
|
||||||
discussions = disc_sec.setdefault("discussions", {})
|
discussions = disc_sec.setdefault("discussions", {})
|
||||||
disc_data = discussions.setdefault(self.active_discussion, project_manager.default_discussion())
|
disc_data = discussions.setdefault(self.active_discussion, project_manager.default_discussion())
|
||||||
@@ -1398,7 +1408,8 @@ class App:
|
|||||||
self._save_active_project()
|
self._save_active_project()
|
||||||
self._flush_to_config()
|
self._flush_to_config()
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
flat = project_manager.flat_config(self.project, self.active_discussion)
|
track_id = self.active_track.id if self.active_track else None
|
||||||
|
flat = project_manager.flat_config(self.project, self.active_discussion, track_id=track_id)
|
||||||
full_md, path, file_items = aggregate.run(flat)
|
full_md, path, file_items = aggregate.run(flat)
|
||||||
# Build stable markdown (no history) for Gemini caching
|
# Build stable markdown (no history) for Gemini caching
|
||||||
screenshot_base_dir = Path(flat.get("screenshots", {}).get("base_dir", "."))
|
screenshot_base_dir = Path(flat.get("screenshots", {}).get("base_dir", "."))
|
||||||
@@ -2077,11 +2088,24 @@ class App:
|
|||||||
track_id = f"track_{uuid.uuid4().hex[:8]}"
|
track_id = f"track_{uuid.uuid4().hex[:8]}"
|
||||||
track = Track(id=track_id, description=title, tickets=tickets)
|
track = Track(id=track_id, description=title, tickets=tickets)
|
||||||
|
|
||||||
# 4. Initialize ConductorEngine and run_linear loop
|
# Initialize track state in the filesystem
|
||||||
|
from models import TrackState, Metadata
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.now()
|
||||||
|
meta = Metadata(id=track_id, name=title, status="todo", created_at=now, updated_at=now)
|
||||||
|
state = TrackState(metadata=meta, discussion=[], tasks=tickets)
|
||||||
|
project_manager.save_track_state(track_id, state, self.ui_files_base_dir)
|
||||||
|
|
||||||
|
# 4. Initialize ConductorEngine and run loop
|
||||||
engine = multi_agent_conductor.ConductorEngine(track, self.event_queue)
|
engine = multi_agent_conductor.ConductorEngine(track, self.event_queue)
|
||||||
|
|
||||||
|
# Use current full markdown context for the track execution
|
||||||
|
track_id_param = track.id
|
||||||
|
flat = project_manager.flat_config(self.project, self.active_discussion, track_id=track_id_param)
|
||||||
|
full_md, _, _ = aggregate.run(flat)
|
||||||
|
|
||||||
# Schedule the coroutine on the internal event loop
|
# Schedule the coroutine on the internal event loop
|
||||||
asyncio.run_coroutine_threadsafe(engine.run_linear(), self._loop)
|
asyncio.run_coroutine_threadsafe(engine.run(md_content=full_md), self._loop)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ai_status = f"Track start error: {e}"
|
self.ai_status = f"Track start error: {e}"
|
||||||
print(f"ERROR in _start_track_logic: {e}")
|
print(f"ERROR in _start_track_logic: {e}")
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ Collapsed=0
|
|||||||
DockId=0x0000000F,2
|
DockId=0x0000000F,2
|
||||||
|
|
||||||
[Window][Theme]
|
[Window][Theme]
|
||||||
Pos=0,21
|
Pos=0,17
|
||||||
Size=639,824
|
Size=393,824
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000005,1
|
DockId=0x00000005,1
|
||||||
|
|
||||||
@@ -89,14 +89,14 @@ Size=900,700
|
|||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Diagnostics]
|
[Window][Diagnostics]
|
||||||
Pos=641,21
|
Pos=395,17
|
||||||
Size=1092,908
|
Size=734,545
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000010,0
|
DockId=0x00000010,0
|
||||||
|
|
||||||
[Window][Context Hub]
|
[Window][Context Hub]
|
||||||
Pos=0,21
|
Pos=0,17
|
||||||
Size=639,824
|
Size=393,824
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000005,0
|
DockId=0x00000005,0
|
||||||
|
|
||||||
@@ -107,26 +107,26 @@ Collapsed=0
|
|||||||
DockId=0x0000000D,0
|
DockId=0x0000000D,0
|
||||||
|
|
||||||
[Window][Discussion Hub]
|
[Window][Discussion Hub]
|
||||||
Pos=1735,21
|
Pos=1131,17
|
||||||
Size=783,1586
|
Size=549,1183
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000004,0
|
DockId=0x00000004,0
|
||||||
|
|
||||||
[Window][Operations Hub]
|
[Window][Operations Hub]
|
||||||
Pos=641,21
|
Pos=395,17
|
||||||
Size=1092,908
|
Size=734,545
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000010,1
|
DockId=0x00000010,1
|
||||||
|
|
||||||
[Window][Files & Media]
|
[Window][Files & Media]
|
||||||
Pos=0,847
|
Pos=0,843
|
||||||
Size=639,760
|
Size=393,357
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000006,1
|
DockId=0x00000006,1
|
||||||
|
|
||||||
[Window][AI Settings]
|
[Window][AI Settings]
|
||||||
Pos=0,847
|
Pos=0,843
|
||||||
Size=639,760
|
Size=393,357
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000006,0
|
DockId=0x00000006,0
|
||||||
|
|
||||||
@@ -136,14 +136,14 @@ Size=416,325
|
|||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][MMA Dashboard]
|
[Window][MMA Dashboard]
|
||||||
Pos=641,931
|
Pos=395,564
|
||||||
Size=1092,676
|
Size=734,636
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000011,0
|
DockId=0x00000011,0
|
||||||
|
|
||||||
[Window][Log Management]
|
[Window][Log Management]
|
||||||
Pos=1735,21
|
Pos=1131,17
|
||||||
Size=783,1586
|
Size=549,1183
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000004,1
|
DockId=0x00000004,1
|
||||||
|
|
||||||
@@ -167,20 +167,20 @@ Column 6 Weight=1.0079
|
|||||||
DockNode ID=0x00000008 Pos=3125,170 Size=593,1157 Split=Y
|
DockNode ID=0x00000008 Pos=3125,170 Size=593,1157 Split=Y
|
||||||
DockNode ID=0x00000009 Parent=0x00000008 SizeRef=1029,147 Selected=0x0469CA7A
|
DockNode ID=0x00000009 Parent=0x00000008 SizeRef=1029,147 Selected=0x0469CA7A
|
||||||
DockNode ID=0x0000000A Parent=0x00000008 SizeRef=1029,145 Selected=0xDF822E02
|
DockNode ID=0x0000000A Parent=0x00000008 SizeRef=1029,145 Selected=0xDF822E02
|
||||||
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=0,21 Size=2518,1586 Split=Y
|
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=0,17 Size=1680,1183 Split=Y
|
||||||
DockNode ID=0x0000000C Parent=0xAFC85805 SizeRef=1362,1041 Split=X Selected=0x5D11106F
|
DockNode ID=0x0000000C Parent=0xAFC85805 SizeRef=1362,1041 Split=X Selected=0x5D11106F
|
||||||
DockNode ID=0x00000003 Parent=0x0000000C SizeRef=1733,1183 Split=X
|
DockNode ID=0x00000003 Parent=0x0000000C SizeRef=1129,1183 Split=X
|
||||||
DockNode ID=0x0000000B Parent=0x00000003 SizeRef=404,1186 Split=Y Selected=0xF4139CA2
|
DockNode ID=0x0000000B Parent=0x00000003 SizeRef=404,1186 Split=Y Selected=0xF4139CA2
|
||||||
DockNode ID=0x00000002 Parent=0x0000000B SizeRef=1029,1119 Split=X Selected=0xF4139CA2
|
DockNode ID=0x00000002 Parent=0x0000000B SizeRef=1029,1119 Split=X Selected=0xF4139CA2
|
||||||
DockNode ID=0x00000007 Parent=0x00000002 SizeRef=639,858 Split=Y Selected=0x8CA2375C
|
DockNode ID=0x00000007 Parent=0x00000002 SizeRef=393,858 Split=Y Selected=0x8CA2375C
|
||||||
DockNode ID=0x00000005 Parent=0x00000007 SizeRef=295,824 Selected=0xF4139CA2
|
DockNode ID=0x00000005 Parent=0x00000007 SizeRef=295,824 Selected=0xF4139CA2
|
||||||
DockNode ID=0x00000006 Parent=0x00000007 SizeRef=295,724 CentralNode=1 Selected=0x7BD57D6A
|
DockNode ID=0x00000006 Parent=0x00000007 SizeRef=295,724 CentralNode=1 Selected=0x7BD57D6A
|
||||||
DockNode ID=0x0000000E Parent=0x00000002 SizeRef=1092,858 Split=Y Selected=0x418C7449
|
DockNode ID=0x0000000E Parent=0x00000002 SizeRef=734,858 Split=Y Selected=0x418C7449
|
||||||
DockNode ID=0x00000010 Parent=0x0000000E SizeRef=868,887 Selected=0x418C7449
|
DockNode ID=0x00000010 Parent=0x0000000E SizeRef=868,545 Selected=0xB4CBF21A
|
||||||
DockNode ID=0x00000011 Parent=0x0000000E SizeRef=868,661 Selected=0x3AEC3498
|
DockNode ID=0x00000011 Parent=0x0000000E SizeRef=868,636 Selected=0x3AEC3498
|
||||||
DockNode ID=0x00000001 Parent=0x0000000B SizeRef=1029,775 Selected=0x8B4EBFA6
|
DockNode ID=0x00000001 Parent=0x0000000B SizeRef=1029,775 Selected=0x8B4EBFA6
|
||||||
DockNode ID=0x0000000D Parent=0x00000003 SizeRef=435,1186 Selected=0x363E93D6
|
DockNode ID=0x0000000D Parent=0x00000003 SizeRef=435,1186 Selected=0x363E93D6
|
||||||
DockNode ID=0x00000004 Parent=0x0000000C SizeRef=783,1183 Selected=0x6F2B5B04
|
DockNode ID=0x00000004 Parent=0x0000000C SizeRef=549,1183 Selected=0x6F2B5B04
|
||||||
DockNode ID=0x0000000F Parent=0xAFC85805 SizeRef=1362,451 Selected=0xDD6419BC
|
DockNode ID=0x0000000F Parent=0xAFC85805 SizeRef=1362,451 Selected=0xDD6419BC
|
||||||
|
|
||||||
;;;<<<Layout_655921752_Default>>>;;;
|
;;;<<<Layout_655921752_Default>>>;;;
|
||||||
|
|||||||
@@ -99,8 +99,19 @@ def _is_allowed(path: Path) -> bool:
|
|||||||
rp = path.resolve(strict=True)
|
rp = path.resolve(strict=True)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
rp = path.resolve()
|
rp = path.resolve()
|
||||||
|
|
||||||
if rp in _allowed_paths:
|
if rp in _allowed_paths:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Allow current working directory and subpaths by default if no base_dirs
|
||||||
|
cwd = Path.cwd().resolve()
|
||||||
|
if not _base_dirs:
|
||||||
|
try:
|
||||||
|
rp.relative_to(cwd)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
for bd in _base_dirs:
|
for bd in _base_dirs:
|
||||||
try:
|
try:
|
||||||
rp.relative_to(bd)
|
rp.relative_to(bd)
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ If you run a test or command that fails with a significant error or large traceb
|
|||||||
1. **DO NOT** analyze the raw logs in your own context window.
|
1. **DO NOT** analyze the raw logs in your own context window.
|
||||||
2. **DO** spawn a stateless Tier 4 agent to diagnose the failure.
|
2. **DO** spawn a stateless Tier 4 agent to diagnose the failure.
|
||||||
3. *Command:* `uv run python scripts/mma_exec.py --role tier4-qa "Analyze this failure and summarize the root cause: [LOG_DATA]"`
|
3. *Command:* `uv run python scripts/mma_exec.py --role tier4-qa "Analyze this failure and summarize the root cause: [LOG_DATA]"`
|
||||||
|
4. Avoid direct reads to files, use file summaries or ast skeletons for files if they are code and we have a tool for parsing them.
|
||||||
|
|
||||||
## 3. Persistent Tech Lead Memory (Tier 2)
|
## 3. Persistent Tech Lead Memory (Tier 2)
|
||||||
Unlike the stateless sub-agents (Tiers 3 & 4), the **Tier 2 Tech Lead** maintains persistent context throughout the implementation of a track. Do NOT apply "Context Amnesia" to your own session during track implementation. You are responsible for the continuity of the technical strategy.
|
Unlike the stateless sub-agents (Tiers 3 & 4), the **Tier 2 Tech Lead** maintains persistent context throughout the implementation of a track. Do NOT apply "Context Amnesia" to your own session during track implementation. You are responsible for the continuity of the technical strategy.
|
||||||
|
|
||||||
## 4. AST Skeleton Views
|
## 4. AST Skeleton Views
|
||||||
To minimize context bloat for Tier 3, use "Skeleton Views" of dependencies (extracted via `mcp_client.py` or similar) instead of full file contents, unless the Tier 3 worker is explicitly modifying that specific file.
|
To minimize context bloat for Tier 2 & 3, use "Skeleton Views" of dependencies (extracted via `mcp_client.py` or similar) instead of full file contents, unless the Tier 3 worker is explicitly modifying that specific file.
|
||||||
|
|
||||||
<examples>
|
<examples>
|
||||||
### Example 1: Spawning a Tier 4 QA Agent
|
### Example 1: Spawning a Tier 4 QA Agent
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import events
|
|||||||
from models import Ticket, Track, WorkerContext
|
from models import Ticket, Track, WorkerContext
|
||||||
from file_cache import ASTParser
|
from file_cache import ASTParser
|
||||||
|
|
||||||
|
from dag_engine import TrackDAG, ExecutionEngine
|
||||||
|
|
||||||
class ConductorEngine:
|
class ConductorEngine:
|
||||||
"""
|
"""
|
||||||
Orchestrates the execution of tickets within a track.
|
Orchestrates the execution of tickets within a track.
|
||||||
"""
|
"""
|
||||||
def __init__(self, track: Track, event_queue: Optional[events.AsyncEventQueue] = None):
|
def __init__(self, track: Track, event_queue: Optional[events.AsyncEventQueue] = None, auto_queue: bool = False):
|
||||||
self.track = track
|
self.track = track
|
||||||
self.event_queue = event_queue
|
self.event_queue = event_queue
|
||||||
self.tier_usage = {
|
self.tier_usage = {
|
||||||
@@ -20,6 +22,8 @@ class ConductorEngine:
|
|||||||
"Tier 3": {"input": 0, "output": 0},
|
"Tier 3": {"input": 0, "output": 0},
|
||||||
"Tier 4": {"input": 0, "output": 0},
|
"Tier 4": {"input": 0, "output": 0},
|
||||||
}
|
}
|
||||||
|
self.dag = TrackDAG(self.track.tickets)
|
||||||
|
self.engine = ExecutionEngine(self.dag, auto_queue=auto_queue)
|
||||||
|
|
||||||
async def _push_state(self, status: str = "running", active_tier: str = None):
|
async def _push_state(self, status: str = "running", active_tier: str = None):
|
||||||
if not self.event_queue:
|
if not self.event_queue:
|
||||||
@@ -59,58 +63,83 @@ class ConductorEngine:
|
|||||||
step_mode=ticket_data.get("step_mode", False)
|
step_mode=ticket_data.get("step_mode", False)
|
||||||
)
|
)
|
||||||
self.track.tickets.append(ticket)
|
self.track.tickets.append(ticket)
|
||||||
|
|
||||||
|
# Rebuild DAG and Engine after parsing new tickets
|
||||||
|
self.dag = TrackDAG(self.track.tickets)
|
||||||
|
self.engine = ExecutionEngine(self.dag, auto_queue=self.engine.auto_queue)
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
print(f"Error parsing JSON tickets: {e}")
|
print(f"Error parsing JSON tickets: {e}")
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
print(f"Missing required field in ticket definition: {e}")
|
print(f"Missing required field in ticket definition: {e}")
|
||||||
|
|
||||||
async def run_linear(self):
|
async def run(self, md_content: str = ""):
|
||||||
"""
|
"""
|
||||||
Executes tickets sequentially according to their dependencies.
|
Main execution loop using the DAG engine.
|
||||||
Iterates through the track's executable tickets until no more can be run.
|
Args:
|
||||||
Supports dynamic execution as tickets added during runtime will be picked up
|
md_content: The full markdown context (history + files) for AI workers.
|
||||||
in the next iteration of the main loop.
|
|
||||||
"""
|
"""
|
||||||
await self._push_state(status="running", active_tier="Tier 2 (Tech Lead)")
|
await self._push_state(status="running", active_tier="Tier 2 (Tech Lead)")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
executable = self.track.get_executable_tickets()
|
# 1. Identify ready tasks
|
||||||
if not executable:
|
ready_tasks = self.engine.tick()
|
||||||
# Check if we are finished or blocked
|
|
||||||
|
# 2. Check for completion or blockage
|
||||||
|
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:
|
||||||
print("Track completed successfully.")
|
print("Track completed successfully.")
|
||||||
await self._push_state(status="done", active_tier=None)
|
await self._push_state(status="done", active_tier=None)
|
||||||
else:
|
else:
|
||||||
# If we have no executable tickets but some are not completed, we might be blocked
|
# Check if any tasks are in-progress or could be ready
|
||||||
# or there are simply no more tickets to run at this moment.
|
if any(t.status == "in_progress" for t in self.track.tickets):
|
||||||
incomplete = [t for t in self.track.tickets if t.status != "completed"]
|
# Wait for async tasks to complete
|
||||||
if not incomplete:
|
await asyncio.sleep(1)
|
||||||
print("Track completed successfully.")
|
continue
|
||||||
await self._push_state(status="done", active_tier=None)
|
|
||||||
else:
|
print("No more executable tickets. Track is blocked or finished.")
|
||||||
print(f"No more executable tickets. {len(incomplete)} tickets remain incomplete.")
|
await self._push_state(status="blocked", active_tier=None)
|
||||||
await self._push_state(status="blocked", active_tier=None)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
for ticket in executable:
|
|
||||||
# We re-check status in case it was modified by a parallel/dynamic process
|
|
||||||
# (though run_linear is currently single-threaded)
|
|
||||||
if ticket.status != "todo":
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f"Executing ticket {ticket.id}: {ticket.description}")
|
# 3. Process ready tasks
|
||||||
ticket.status = "running"
|
loop = asyncio.get_event_loop()
|
||||||
await self._push_state(active_tier=f"Tier 3 (Worker): {ticket.id}")
|
for ticket in ready_tasks:
|
||||||
|
# If auto_queue is on and step_mode is off, engine.tick() already marked it 'in_progress'
|
||||||
|
# but we need to verify and handle the lifecycle.
|
||||||
|
if ticket.status == "in_progress" or (not ticket.step_mode and self.engine.auto_queue):
|
||||||
|
ticket.status = "in_progress"
|
||||||
|
print(f"Executing ticket {ticket.id}: {ticket.description}")
|
||||||
|
await self._push_state(active_tier=f"Tier 3 (Worker): {ticket.id}")
|
||||||
|
|
||||||
|
context = WorkerContext(
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
model_name="gemini-2.5-flash-lite",
|
||||||
|
messages=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Offload the blocking lifecycle call to a thread to avoid blocking the async event loop.
|
||||||
|
# We pass the md_content so the worker has full context.
|
||||||
|
context_files = ticket.context_requirements if ticket.context_requirements else None
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
run_worker_lifecycle,
|
||||||
|
ticket,
|
||||||
|
context,
|
||||||
|
context_files,
|
||||||
|
self.event_queue,
|
||||||
|
self,
|
||||||
|
md_content
|
||||||
|
)
|
||||||
|
await self._push_state(active_tier="Tier 2 (Tech Lead)")
|
||||||
|
|
||||||
# For now, we use a default model name or take it from config
|
elif ticket.status == "todo" and (ticket.step_mode or not self.engine.auto_queue):
|
||||||
context = WorkerContext(
|
# Task is ready but needs approval
|
||||||
ticket_id=ticket.id,
|
print(f"Ticket {ticket.id} is ready and awaiting approval.")
|
||||||
model_name="gemini-2.5-flash-lite",
|
await self._push_state(active_tier=f"Awaiting Approval: {ticket.id}")
|
||||||
messages=[]
|
# In a real UI, this would wait for a user event.
|
||||||
)
|
# For now, we'll treat it as a pause point if not auto-queued.
|
||||||
run_worker_lifecycle(ticket, context, event_queue=self.event_queue, engine=self)
|
pass
|
||||||
await self._push_state(active_tier="Tier 2 (Tech Lead)")
|
|
||||||
|
|
||||||
def confirm_execution(payload: str, event_queue: events.AsyncEventQueue, ticket_id: str) -> bool:
|
def confirm_execution(payload: str, event_queue: events.AsyncEventQueue, ticket_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -152,10 +181,17 @@ def confirm_execution(payload: str, event_queue: events.AsyncEventQueue, ticket_
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files: List[str] = None, event_queue: events.AsyncEventQueue = None, engine: Optional['ConductorEngine'] = None):
|
def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files: List[str] = None, event_queue: events.AsyncEventQueue = None, engine: Optional['ConductorEngine'] = None, md_content: str = ""):
|
||||||
"""
|
"""
|
||||||
Simulates the lifecycle of a single agent working on a ticket.
|
Simulates the lifecycle of a single agent working on a ticket.
|
||||||
Calls the AI client and updates the ticket status based on the response.
|
Calls the AI client and updates the ticket status based on the response.
|
||||||
|
Args:
|
||||||
|
ticket: The ticket to process.
|
||||||
|
context: The worker context.
|
||||||
|
context_files: List of files to include in the context.
|
||||||
|
event_queue: Queue for pushing state updates and receiving approvals.
|
||||||
|
engine: The conductor engine.
|
||||||
|
md_content: The markdown context (history + files) for AI workers.
|
||||||
"""
|
"""
|
||||||
# Enforce Context Amnesia: each ticket starts with a clean slate.
|
# Enforce Context Amnesia: each ticket starts with a clean slate.
|
||||||
ai_client.reset_session()
|
ai_client.reset_session()
|
||||||
@@ -165,6 +201,11 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
|
|||||||
parser = ASTParser(language="python")
|
parser = ASTParser(language="python")
|
||||||
for i, file_path in enumerate(context_files):
|
for i, file_path in enumerate(context_files):
|
||||||
try:
|
try:
|
||||||
|
abs_path = Path(file_path)
|
||||||
|
if not abs_path.is_absolute() and engine:
|
||||||
|
# Resolve relative to project base if possible
|
||||||
|
# (This is a bit simplified, but helps)
|
||||||
|
pass
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if i == 0:
|
if i == 0:
|
||||||
@@ -188,8 +229,6 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
|
|||||||
"start your response with 'BLOCKED' and explain why."
|
"start your response with 'BLOCKED' and explain why."
|
||||||
)
|
)
|
||||||
|
|
||||||
# In a real scenario, we would pass md_content from the aggregator
|
|
||||||
# and manage the conversation history in the context.
|
|
||||||
# HITL Clutch: pass the queue and ticket_id to confirm_execution
|
# HITL Clutch: pass the queue and ticket_id to confirm_execution
|
||||||
def clutch_callback(payload: str) -> bool:
|
def clutch_callback(payload: str) -> bool:
|
||||||
if not event_queue:
|
if not event_queue:
|
||||||
@@ -197,7 +236,7 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
|
|||||||
return confirm_execution(payload, event_queue, ticket.id)
|
return confirm_execution(payload, event_queue, ticket.id)
|
||||||
|
|
||||||
response = ai_client.send(
|
response = ai_client.send(
|
||||||
md_content="",
|
md_content=md_content,
|
||||||
user_message=user_message,
|
user_message=user_message,
|
||||||
base_dir=".",
|
base_dir=".",
|
||||||
pre_tool_callback=clutch_callback if ticket.step_mode else None,
|
pre_tool_callback=clutch_callback if ticket.step_mode else None,
|
||||||
|
|||||||
@@ -225,11 +225,17 @@ def migrate_from_legacy_config(cfg: dict) -> dict:
|
|||||||
|
|
||||||
# ── flat config for aggregate.run() ─────────────────────────────────────────
|
# ── flat config for aggregate.run() ─────────────────────────────────────────
|
||||||
|
|
||||||
def flat_config(proj: dict, disc_name: str | None = None) -> dict:
|
def flat_config(proj: dict, disc_name: str | None = None, track_id: str | None = None) -> dict:
|
||||||
"""Return a flat config dict compatible with aggregate.run()."""
|
"""Return a flat config dict compatible with aggregate.run()."""
|
||||||
disc_sec = proj.get("discussion", {})
|
disc_sec = proj.get("discussion", {})
|
||||||
name = disc_name or disc_sec.get("active", "main")
|
|
||||||
disc_data = disc_sec.get("discussions", {}).get(name, {})
|
if track_id:
|
||||||
|
history = load_track_history(track_id, proj.get("files", {}).get("base_dir", "."))
|
||||||
|
else:
|
||||||
|
name = disc_name or disc_sec.get("active", "main")
|
||||||
|
disc_data = disc_sec.get("discussions", {}).get(name, {})
|
||||||
|
history = disc_data.get("history", [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"project": proj.get("project", {}),
|
"project": proj.get("project", {}),
|
||||||
"output": proj.get("output", {}),
|
"output": proj.get("output", {}),
|
||||||
@@ -237,6 +243,69 @@ def flat_config(proj: dict, disc_name: str | None = None) -> dict:
|
|||||||
"screenshots": proj.get("screenshots", {}),
|
"screenshots": proj.get("screenshots", {}),
|
||||||
"discussion": {
|
"discussion": {
|
||||||
"roles": disc_sec.get("roles", []),
|
"roles": disc_sec.get("roles", []),
|
||||||
"history": disc_data.get("history", []),
|
"history": history,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── track state persistence ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def save_track_state(track_id: str, state: 'TrackState', base_dir: str | Path = "."):
|
||||||
|
"""
|
||||||
|
Saves a TrackState object to conductor/tracks/<track_id>/state.toml.
|
||||||
|
"""
|
||||||
|
track_dir = Path(base_dir) / "conductor" / "tracks" / track_id
|
||||||
|
track_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
state_file = track_dir / "state.toml"
|
||||||
|
data = clean_nones(state.to_dict())
|
||||||
|
with open(state_file, "wb") as f:
|
||||||
|
tomli_w.dump(data, f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_track_state(track_id: str, base_dir: str | Path = ".") -> 'TrackState':
|
||||||
|
"""
|
||||||
|
Loads a TrackState object from conductor/tracks/<track_id>/state.toml.
|
||||||
|
"""
|
||||||
|
from models import TrackState
|
||||||
|
state_file = Path(base_dir) / "conductor" / "tracks" / track_id / "state.toml"
|
||||||
|
if not state_file.exists():
|
||||||
|
return None
|
||||||
|
with open(state_file, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
return TrackState.from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
def load_track_history(track_id: str, base_dir: str | Path = ".") -> list:
|
||||||
|
"""
|
||||||
|
Loads the discussion history for a specific track from its state.toml.
|
||||||
|
Returns a list of entry strings formatted with @timestamp.
|
||||||
|
"""
|
||||||
|
from models import TrackState
|
||||||
|
state = load_track_state(track_id, base_dir)
|
||||||
|
if not state:
|
||||||
|
return []
|
||||||
|
|
||||||
|
history = []
|
||||||
|
for entry in state.discussion:
|
||||||
|
e = dict(entry)
|
||||||
|
ts = e.get("ts")
|
||||||
|
if isinstance(ts, datetime.datetime):
|
||||||
|
e["ts"] = ts.strftime(TS_FMT)
|
||||||
|
history.append(entry_to_str(e))
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
def save_track_history(track_id: str, history: list, base_dir: str | Path = "."):
|
||||||
|
"""
|
||||||
|
Saves the discussion history for a specific track to its state.toml.
|
||||||
|
'history' is expected to be a list of formatted strings.
|
||||||
|
"""
|
||||||
|
from models import TrackState
|
||||||
|
state = load_track_state(track_id, base_dir)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
|
||||||
|
roles = ["User", "AI", "Vendor API", "System", "Reasoning"]
|
||||||
|
entries = [str_to_entry(h, roles) for h in history]
|
||||||
|
state.discussion = entries
|
||||||
|
save_track_state(track_id, state, base_dir)
|
||||||
|
|||||||
36
reproduce_issue.py
Normal file
36
reproduce_issue.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import pytest
|
||||||
|
from models import Ticket
|
||||||
|
from dag_engine import TrackDAG, ExecutionEngine
|
||||||
|
|
||||||
|
def test_auto_queue_and_step_mode():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="todo", assigned_to="worker")
|
||||||
|
t2 = Ticket(id="T2", description="Task 2", status="todo", assigned_to="worker", step_mode=True)
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2])
|
||||||
|
# Expectation: ExecutionEngine takes auto_queue parameter
|
||||||
|
try:
|
||||||
|
engine = ExecutionEngine(dag, auto_queue=True)
|
||||||
|
except TypeError:
|
||||||
|
pytest.fail("ExecutionEngine does not accept auto_queue parameter")
|
||||||
|
|
||||||
|
# Tick 1: T1 should be 'in-progress' because auto_queue=True
|
||||||
|
# T2 should remain 'todo' because step_mode=True
|
||||||
|
engine.tick()
|
||||||
|
|
||||||
|
assert t1.status == "in_progress"
|
||||||
|
assert t2.status == "todo"
|
||||||
|
|
||||||
|
# Approve T2
|
||||||
|
try:
|
||||||
|
engine.approve_task("T2")
|
||||||
|
except AttributeError:
|
||||||
|
pytest.fail("ExecutionEngine does not have approve_task method")
|
||||||
|
|
||||||
|
assert t2.status == "in_progress"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
test_auto_queue_and_step_mode()
|
||||||
|
print("Test passed (unexpectedly)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Test failed as expected: {e}")
|
||||||
@@ -176,17 +176,16 @@ def execute_agent(role: str, prompt: str, docs: list[str]) -> str:
|
|||||||
if role in ['tier3', 'tier3-worker']:
|
if role in ['tier3', 'tier3-worker']:
|
||||||
system_directive = "STRICT SYSTEM DIRECTIVE: You are a stateless Tier 3 Worker (Contributor). " \
|
system_directive = "STRICT SYSTEM DIRECTIVE: You are a stateless Tier 3 Worker (Contributor). " \
|
||||||
"Your goal is to implement specific code changes or tests based on the provided task. " \
|
"Your goal is to implement specific code changes or tests based on the provided task. " \
|
||||||
"You have access to tools for reading and writing files (e.g., read_file, write_file, replace). " \
|
"You have access to tools for reading and writing files (e.g., read_file, write_file, replace), " \
|
||||||
"CRITICAL: You CANNOT execute PowerShell scripts or run shell commands directly. " \
|
"codebase investigation (codebase_investigator), and web tools (google_web_search, web_fetch). " \
|
||||||
"If you need to verify code or run tests, output the full PowerShell script inside a " \
|
"You CAN execute PowerShell scripts via discovered_tool_run_powershell for verification and testing. " \
|
||||||
"markdown code block (e.g., ```powershell) and state that it needs to be executed. " \
|
|
||||||
"Follow TDD and return success status or code changes. No pleasantries, no conversational filler."
|
"Follow TDD and return success status or code changes. No pleasantries, no conversational filler."
|
||||||
elif role in ['tier4', 'tier4-qa']:
|
elif role in ['tier4', 'tier4-qa']:
|
||||||
system_directive = "STRICT SYSTEM DIRECTIVE: You are a stateless Tier 4 QA Agent. " \
|
system_directive = "STRICT SYSTEM DIRECTIVE: You are a stateless Tier 4 QA Agent. " \
|
||||||
"Your goal is to analyze errors, summarize logs, or verify tests. " \
|
"Your goal is to analyze errors, summarize logs, or verify tests. " \
|
||||||
"You have access to tools for reading files and exploring the codebase. " \
|
"You have access to tools for reading files, exploring the codebase (codebase_investigator), " \
|
||||||
"CRITICAL: You CANNOT execute PowerShell scripts or run shell commands directly. " \
|
"and web tools (google_web_search, web_fetch). " \
|
||||||
"If you need to run diagnostics, output the PowerShell script and request execution. " \
|
"You CAN execute PowerShell scripts via discovered_tool_run_powershell for diagnostics. " \
|
||||||
"ONLY output the requested analysis. No pleasantries."
|
"ONLY output the requested analysis. No pleasantries."
|
||||||
else:
|
else:
|
||||||
system_directive = f"STRICT SYSTEM DIRECTIVE: You are a stateless {role}. " \
|
system_directive = f"STRICT SYSTEM DIRECTIVE: You are a stateless {role}. " \
|
||||||
@@ -209,7 +208,8 @@ def execute_agent(role: str, prompt: str, docs: list[str]) -> str:
|
|||||||
# Use subprocess with input to pipe the prompt via stdin, avoiding WinError 206.
|
# Use subprocess with input to pipe the prompt via stdin, avoiding WinError 206.
|
||||||
# We use -p 'mma_task' to ensure non-interactive (headless) mode and valid parsing.
|
# We use -p 'mma_task' to ensure non-interactive (headless) mode and valid parsing.
|
||||||
# Whitelist tools to ensure they are available to the model in headless mode.
|
# Whitelist tools to ensure they are available to the model in headless mode.
|
||||||
allowed_tools = "read_file,write_file,replace,list_directory,glob,grep_search,search_files,get_file_summary"
|
# Using 'discovered_tool_run_powershell' as it's the confirmed name for shell access.
|
||||||
|
allowed_tools = "read_file,write_file,replace,list_directory,glob,grep_search,search_files,get_file_summary,discovered_tool_run_powershell,activate_skill,codebase_investigator,google_web_search,web_fetch"
|
||||||
ps_command = (
|
ps_command = (
|
||||||
f"if (Test-Path 'C:\\projects\\misc\\setup_gemini.ps1') {{ . 'C:\\projects\\misc\\setup_gemini.ps1' }}; "
|
f"if (Test-Path 'C:\\projects\\misc\\setup_gemini.ps1') {{ . 'C:\\projects\\misc\\setup_gemini.ps1' }}; "
|
||||||
f"gemini -p 'mma_task' --allowed-tools {allowed_tools} --output-format json --model {model}"
|
f"gemini -p 'mma_task' --allowed-tools {allowed_tools} --output-format json --model {model}"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ history = [
|
|||||||
|
|
||||||
[discussions.AutoDisc]
|
[discussions.AutoDisc]
|
||||||
git_commit = ""
|
git_commit = ""
|
||||||
last_updated = "2026-02-27T19:23:11"
|
last_updated = "2026-02-27T19:27:19"
|
||||||
history = [
|
history = [
|
||||||
"@2026-02-27T19:08:37\nSystem:\n[PERFORMANCE ALERT] Frame time high: 62.2ms. Please consider optimizing recent changes or reducing load.",
|
"@2026-02-27T19:08:37\nSystem:\n[PERFORMANCE ALERT] Frame time high: 62.2ms. Please consider optimizing recent changes or reducing load.",
|
||||||
]
|
]
|
||||||
|
|||||||
84
tests/test_dag_engine.py
Normal file
84
tests/test_dag_engine.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import pytest
|
||||||
|
from models import Ticket
|
||||||
|
from dag_engine import TrackDAG
|
||||||
|
|
||||||
|
def test_get_ready_tasks_linear():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="completed", assigned_to="worker")
|
||||||
|
t2 = Ticket(id="T2", description="Task 2", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
t3 = Ticket(id="T3", description="Task 3", status="todo", assigned_to="worker", depends_on=["T2"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2, t3])
|
||||||
|
ready = dag.get_ready_tasks()
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0].id == "T2"
|
||||||
|
|
||||||
|
def test_get_ready_tasks_branching():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="completed", assigned_to="worker")
|
||||||
|
t2 = Ticket(id="T2", description="Task 2", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
t3 = Ticket(id="T3", description="Task 3", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2, t3])
|
||||||
|
ready = dag.get_ready_tasks()
|
||||||
|
assert len(ready) == 2
|
||||||
|
ready_ids = {t.id for t in ready}
|
||||||
|
assert ready_ids == {"T2", "T3"}
|
||||||
|
|
||||||
|
def test_has_cycle_no_cycle():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="todo", assigned_to="worker")
|
||||||
|
t2 = Ticket(id="T2", description="Task 2", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2])
|
||||||
|
assert not dag.has_cycle()
|
||||||
|
|
||||||
|
def test_has_cycle_direct_cycle():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="todo", assigned_to="worker", depends_on=["T2"])
|
||||||
|
t2 = Ticket(id="T2", description="Task 2", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2])
|
||||||
|
assert dag.has_cycle()
|
||||||
|
|
||||||
|
def test_has_cycle_indirect_cycle():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="todo", assigned_to="worker", depends_on=["T2"])
|
||||||
|
t2 = Ticket(id="T2", description="Task 2", status="todo", assigned_to="worker", depends_on=["T3"])
|
||||||
|
t3 = Ticket(id="T3", description="Task 3", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2, t3])
|
||||||
|
assert dag.has_cycle()
|
||||||
|
|
||||||
|
def test_has_cycle_complex_no_cycle():
|
||||||
|
# T1 -> T2, T1 -> T3, T2 -> T4, T3 -> T4
|
||||||
|
t1 = Ticket(id="T1", description="T1", status="todo", assigned_to="worker", depends_on=["T2", "T3"])
|
||||||
|
t2 = Ticket(id="T2", description="T2", status="todo", assigned_to="worker", depends_on=["T4"])
|
||||||
|
t3 = Ticket(id="T3", description="T3", status="todo", assigned_to="worker", depends_on=["T4"])
|
||||||
|
t4 = Ticket(id="T4", description="T4", status="todo", assigned_to="worker")
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2, t3, t4])
|
||||||
|
assert not dag.has_cycle()
|
||||||
|
|
||||||
|
def test_get_ready_tasks_multiple_deps():
|
||||||
|
t1 = Ticket(id="T1", description="T1", status="completed", assigned_to="worker")
|
||||||
|
t2 = Ticket(id="T2", description="T2", status="completed", assigned_to="worker")
|
||||||
|
t3 = Ticket(id="T3", description="T3", status="todo", assigned_to="worker", depends_on=["T1", "T2"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2, t3])
|
||||||
|
assert [t.id for t in dag.get_ready_tasks()] == ["T3"]
|
||||||
|
|
||||||
|
t2.status = "todo"
|
||||||
|
assert [t.id for t in dag.get_ready_tasks()] == ["T2"]
|
||||||
|
|
||||||
|
def test_topological_sort():
|
||||||
|
t1 = Ticket(id="T1", description="T1", status="todo", assigned_to="worker")
|
||||||
|
t2 = Ticket(id="T2", description="T2", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
t3 = Ticket(id="T3", description="T3", status="todo", assigned_to="worker", depends_on=["T2"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2, t3])
|
||||||
|
sort = dag.topological_sort()
|
||||||
|
assert sort == ["T1", "T2", "T3"]
|
||||||
|
|
||||||
|
def test_topological_sort_cycle():
|
||||||
|
t1 = Ticket(id="T1", description="T1", status="todo", assigned_to="worker", depends_on=["T2"])
|
||||||
|
t2 = Ticket(id="T2", description="T2", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2])
|
||||||
|
with pytest.raises(ValueError, match="Dependency cycle detected"):
|
||||||
|
dag.topological_sort()
|
||||||
123
tests/test_execution_engine.py
Normal file
123
tests/test_execution_engine.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import pytest
|
||||||
|
from models import Ticket
|
||||||
|
from dag_engine import TrackDAG, ExecutionEngine
|
||||||
|
|
||||||
|
def test_execution_engine_basic_flow():
|
||||||
|
# Setup tickets with dependencies
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="todo", assigned_to="worker")
|
||||||
|
t2 = Ticket(id="T2", description="Task 2", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
t3 = Ticket(id="T3", description="Task 3", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
t4 = Ticket(id="T4", description="Task 4", status="todo", assigned_to="worker", depends_on=["T2", "T3"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2, t3, t4])
|
||||||
|
engine = ExecutionEngine(dag)
|
||||||
|
|
||||||
|
# Tick 1: Only T1 should be ready
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0].id == "T1"
|
||||||
|
|
||||||
|
# Complete T1
|
||||||
|
engine.update_task_status("T1", "completed")
|
||||||
|
|
||||||
|
# Tick 2: T2 and T3 should be ready
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 2
|
||||||
|
ids = {t.id for t in ready}
|
||||||
|
assert ids == {"T2", "T3"}
|
||||||
|
|
||||||
|
# Complete T2
|
||||||
|
engine.update_task_status("T2", "completed")
|
||||||
|
|
||||||
|
# Tick 3: Only T3 should be ready (T4 depends on T2 AND T3)
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0].id == "T3"
|
||||||
|
|
||||||
|
# Complete T3
|
||||||
|
engine.update_task_status("T3", "completed")
|
||||||
|
|
||||||
|
# Tick 4: T4 should be ready
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0].id == "T4"
|
||||||
|
|
||||||
|
# Complete T4
|
||||||
|
engine.update_task_status("T4", "completed")
|
||||||
|
|
||||||
|
# Tick 5: Nothing ready
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 0
|
||||||
|
|
||||||
|
def test_execution_engine_update_nonexistent_task():
|
||||||
|
dag = TrackDAG([])
|
||||||
|
engine = ExecutionEngine(dag)
|
||||||
|
# Should not raise error, or handle gracefully
|
||||||
|
engine.update_task_status("NONEXISTENT", "completed")
|
||||||
|
|
||||||
|
def test_execution_engine_status_persistence():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="todo", assigned_to="worker")
|
||||||
|
dag = TrackDAG([t1])
|
||||||
|
engine = ExecutionEngine(dag)
|
||||||
|
|
||||||
|
engine.update_task_status("T1", "in_progress")
|
||||||
|
assert t1.status == "in_progress"
|
||||||
|
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 0 # Only 'todo' tasks should be returned by tick() if they are ready
|
||||||
|
|
||||||
|
def test_execution_engine_auto_queue():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="todo", assigned_to="worker")
|
||||||
|
t2 = Ticket(id="T2", description="Task 2", status="todo", assigned_to="worker", depends_on=["T1"])
|
||||||
|
|
||||||
|
dag = TrackDAG([t1, t2])
|
||||||
|
engine = ExecutionEngine(dag, auto_queue=True)
|
||||||
|
|
||||||
|
# Tick 1: T1 is ready and should be automatically marked as 'in_progress'
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0].id == "T1"
|
||||||
|
assert t1.status == "in_progress"
|
||||||
|
|
||||||
|
# Tick 2: T1 is in_progress, so T2 is NOT ready yet (T1 must be 'completed')
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 0
|
||||||
|
assert t2.status == "todo"
|
||||||
|
|
||||||
|
# Complete T1
|
||||||
|
engine.update_task_status("T1", "completed")
|
||||||
|
|
||||||
|
# Tick 3: T2 is now ready and should be automatically marked as 'in_progress'
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0].id == "T2"
|
||||||
|
assert t2.status == "in_progress"
|
||||||
|
|
||||||
|
def test_execution_engine_step_mode():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="todo", assigned_to="worker", step_mode=True)
|
||||||
|
|
||||||
|
dag = TrackDAG([t1])
|
||||||
|
engine = ExecutionEngine(dag, auto_queue=True)
|
||||||
|
|
||||||
|
# Tick 1: T1 is ready, but step_mode=True, so it should NOT be automatically marked as 'in_progress'
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0].id == "T1"
|
||||||
|
assert t1.status == "todo"
|
||||||
|
|
||||||
|
# Manual approval
|
||||||
|
engine.approve_task("T1")
|
||||||
|
assert t1.status == "in_progress"
|
||||||
|
|
||||||
|
# Tick 2: T1 is already in_progress, should not be returned by tick() (it's not 'ready'/todo)
|
||||||
|
ready = engine.tick()
|
||||||
|
assert len(ready) == 0
|
||||||
|
|
||||||
|
def test_execution_engine_approve_task():
|
||||||
|
t1 = Ticket(id="T1", description="Task 1", status="todo", assigned_to="worker")
|
||||||
|
dag = TrackDAG([t1])
|
||||||
|
engine = ExecutionEngine(dag, auto_queue=False)
|
||||||
|
|
||||||
|
# Should be able to approve even if auto_queue is False
|
||||||
|
engine.approve_task("T1")
|
||||||
|
assert t1.status == "in_progress"
|
||||||
81
tests/test_track_state_persistence.py
Normal file
81
tests/test_track_state_persistence.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Import the real models
|
||||||
|
from models import TrackState, Metadata, Ticket
|
||||||
|
# Import the persistence functions from project_manager
|
||||||
|
from project_manager import save_track_state, load_track_state
|
||||||
|
|
||||||
|
def test_track_state_persistence(tmp_path):
|
||||||
|
"""
|
||||||
|
Tests saving and loading a TrackState object to/from a TOML file.
|
||||||
|
1. Create a TrackState object with sample metadata, discussion, and tasks.
|
||||||
|
2. Call save_track_state('test_track', state, base_dir).
|
||||||
|
3. Verify that base_dir/conductor/tracks/test_track/state.toml exists.
|
||||||
|
4. Call load_track_state('test_track', base_dir) and verify it returns an identical TrackState object.
|
||||||
|
"""
|
||||||
|
base_dir = tmp_path
|
||||||
|
track_id = "test-track-999" # Metadata internal ID
|
||||||
|
track_folder_name = "test_track" # Folder name used in persistence
|
||||||
|
|
||||||
|
# 1. Create a TrackState object with sample data
|
||||||
|
metadata = Metadata(
|
||||||
|
id=track_id,
|
||||||
|
name="Test Track",
|
||||||
|
status="in_progress",
|
||||||
|
created_at=datetime(2023, 1, 1, 12, 0, 0),
|
||||||
|
updated_at=datetime(2023, 1, 2, 13, 0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
discussion = [
|
||||||
|
{"role": "User", "content": "Hello", "ts": datetime(2023, 1, 1, 12, 0, 0)},
|
||||||
|
{"role": "AI", "content": "Hi there!", "ts": datetime(2023, 1, 1, 12, 0, 5)}
|
||||||
|
]
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
Ticket(id="task-1", description="First task", status="completed", assigned_to="worker-1"),
|
||||||
|
Ticket(id="task-2", description="Second task", status="todo", assigned_to="worker-2")
|
||||||
|
]
|
||||||
|
|
||||||
|
original_state = TrackState(
|
||||||
|
metadata=metadata,
|
||||||
|
discussion=discussion,
|
||||||
|
tasks=tasks
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Call save_track_state('test_track', state, base_dir)
|
||||||
|
save_track_state(track_folder_name, original_state, base_dir)
|
||||||
|
|
||||||
|
# 3. Verify that base_dir/conductor/tracks/test_track/state.toml exists
|
||||||
|
state_file_path = base_dir / "conductor" / "tracks" / track_folder_name / "state.toml"
|
||||||
|
assert state_file_path.exists(), f"State file should exist at {state_file_path}"
|
||||||
|
|
||||||
|
# 4. Call load_track_state('test_track', base_dir) and verify it returns an identical TrackState object
|
||||||
|
loaded_state = load_track_state(track_folder_name, base_dir)
|
||||||
|
|
||||||
|
assert loaded_state is not None, "load_track_state returned None"
|
||||||
|
|
||||||
|
# Verify equality
|
||||||
|
assert loaded_state.metadata.id == original_state.metadata.id
|
||||||
|
assert loaded_state.metadata.name == original_state.metadata.name
|
||||||
|
assert loaded_state.metadata.status == original_state.metadata.status
|
||||||
|
assert loaded_state.metadata.created_at == original_state.metadata.created_at
|
||||||
|
assert loaded_state.metadata.updated_at == original_state.metadata.updated_at
|
||||||
|
|
||||||
|
assert len(loaded_state.tasks) == len(original_state.tasks)
|
||||||
|
for i in range(len(original_state.tasks)):
|
||||||
|
assert loaded_state.tasks[i].id == original_state.tasks[i].id
|
||||||
|
assert loaded_state.tasks[i].description == original_state.tasks[i].description
|
||||||
|
assert loaded_state.tasks[i].status == original_state.tasks[i].status
|
||||||
|
assert loaded_state.tasks[i].assigned_to == original_state.tasks[i].assigned_to
|
||||||
|
|
||||||
|
assert len(loaded_state.discussion) == len(original_state.discussion)
|
||||||
|
for i in range(len(original_state.discussion)):
|
||||||
|
assert loaded_state.discussion[i]["role"] == original_state.discussion[i]["role"]
|
||||||
|
assert loaded_state.discussion[i]["content"] == original_state.discussion[i]["content"]
|
||||||
|
assert loaded_state.discussion[i]["ts"] == original_state.discussion[i]["ts"]
|
||||||
|
|
||||||
|
# Final check: deep equality of dataclasses
|
||||||
|
assert loaded_state == original_state
|
||||||
0
verify_pm_changes.py
Normal file
0
verify_pm_changes.py
Normal file
Reference in New Issue
Block a user