checkpoint: mma_orchestrator track

This commit is contained in:
2026-02-26 22:59:26 -05:00
parent f05fa3d340
commit d087a20f7b
17 changed files with 930 additions and 73 deletions

View File

@@ -1,33 +1,33 @@
# Implementation Plan: MMA Orchestrator Integration # Implementation Plan: MMA Orchestrator Integration
## Phase 1: Tier 1 Strategic PM Implementation ## Phase 1: Tier 1 Strategic PM Implementation
- [ ] Task: PM Planning Hook - [x] Task: PM Planning Hook
- [ ] Create `orchestrator_pm.py` to handle the Tier 1 Strategic prompt. - [x] Create `orchestrator_pm.py` to handle the Tier 1 Strategic prompt.
- [ ] Implement the `generate_tracks(user_request, repo_map)` function. - [x] Implement the `generate_tracks(user_request, repo_map)` function.
- [ ] Task: Project History Aggregation - [x] Task: Project History Aggregation
- [ ] Summarize past track results to provide context for new epics. - [x] Summarize past track results to provide context for new epics.
## Phase 2: Tier 2 Tactical Dispatcher Implementation ## Phase 2: Tier 2 Tactical Dispatcher Implementation
- [ ] Task: Tech Lead Dispatcher Hook - [x] Task: Tech Lead Dispatcher Hook
- [ ] Create `conductor_tech_lead.py` to handle the Tier 2 Dispatcher prompt. - [x] Create `conductor_tech_lead.py` to handle the Tier 2 Dispatcher prompt.
- [ ] Implement the `generate_tickets(track_brief, module_skeletons)` function. - [x] Implement the `generate_tickets(track_brief, module_skeletons)` function.
- [ ] Task: DAG Construction - [x] Task: DAG Construction
- [ ] Build the topological dependency graph from the Tech Lead's ticket list. - [x] Build the topological dependency graph from the Tech Lead's ticket list.
## Phase 3: Guided Planning UX & Interaction ## Phase 3: Guided Planning UX & Interaction
- [ ] Task: Strategic Planning View - [x] Task: Strategic Planning View
- [ ] Implement a "Track Proposal" modal in `gui_2.py` for reviewing Tier 1's plans. - [x] Implement a "Track Proposal" modal in `gui_2.py` for reviewing Tier 1's plans.
- [ ] Allow manual editing of track goals and acceptance criteria (Manual Curation). - [x] Allow manual editing of track goals and acceptance criteria (Manual Curation).
- [ ] Task: Tactical Dispatcher View - [x] Task: Tactical Dispatcher View
- [ ] Implement a "Ticket DAG" visualization or interactive list in the MMA Dashboard. - [x] Implement a "Ticket DAG" visualization or interactive list in the MMA Dashboard.
- [ ] Allow manual "Skip", "Retry", or "Re-assign" actions on individual tickets. - [x] Allow manual "Skip", "Retry", or "Re-assign" actions on individual tickets.
- [ ] Task: The Orchestrator Main Loop - [x] Task: The Orchestrator Main Loop
- [ ] Implement the async state machine in `gui_2.py` that moves from Planning -> Dispatching -> Execution. - [x] Implement the async state machine in `gui_2.py` that moves from Planning -> Dispatching -> Execution.
- [ ] Task: Project Metadata Serialization - [x] Task: Project Metadata Serialization
- [ ] Persist the active epic, tracks, and tickets to `manual_slop.toml`. - [x] Persist the active epic, tracks, and tickets to `manual_slop.toml`.
## Phase 4: Product Alignment & Refinement ## Phase 4: Product Alignment & Refinement
- [ ] Task: UX Differentiator Audit - [~] Task: UX Differentiator Audit
- [ ] Ensure the UX prioritizes "Expert Oversight" over "Full Autonomy" (Manual Slop vs. Gemini CLI). - [ ] Ensure the UX prioritizes "Expert Oversight" over "Full Autonomy" (Manual Slop vs. Gemini CLI).
- [ ] Add detailed token metrics and Tier-specific latency indicators to the Dashboard. - [ ] Add detailed token metrics and Tier-specific latency indicators to the Dashboard.

102
conductor_tech_lead.py Normal file
View File

@@ -0,0 +1,102 @@
import json
import ai_client
import mma_prompts
import re
def generate_tickets(track_brief: str, module_skeletons: str) -> list[dict]:
"""
Tier 2 (Tech Lead) call.
Breaks down a Track Brief and module skeletons into discrete Tier 3 Tickets.
"""
# 1. Set Tier 2 Model (Tech Lead - Flash)
ai_client.set_provider('gemini', 'gemini-1.5-flash')
ai_client.reset_session()
# 2. Construct Prompt
system_prompt = mma_prompts.PROMPTS.get("tier2_sprint_planning")
user_message = (
f"### TRACK BRIEF:\n{track_brief}\n\n"
f"### MODULE SKELETONS:\n{module_skeletons}\n\n"
"Please generate the implementation tickets for this track."
)
# Set custom system prompt for this call
old_system_prompt = ai_client._custom_system_prompt
ai_client.set_custom_system_prompt(system_prompt)
try:
# 3. Call Tier 2 Model
response = ai_client.send(
md_content="",
user_message=user_message
)
# 4. Parse JSON Output
# Extract JSON array from markdown code blocks if present
json_match = response.strip()
if "```json" in json_match:
json_match = json_match.split("```json")[1].split("```")[0].strip()
elif "```" in json_match:
json_match = json_match.split("```")[1].split("```")[0].strip()
# If it's still not valid JSON, try to find a [ ... ] block
if not (json_match.startswith('[') and json_match.endswith(']')):
match = re.search(r'\[\s*\{.*\}\s*\]', json_match, re.DOTALL)
if match:
json_match = match.group(0)
tickets = json.loads(json_match)
return tickets
except Exception as e:
print(f"Error parsing Tier 2 response: {e}")
# print(f"Raw response: {response}")
return []
finally:
# Restore old system prompt
ai_client.set_custom_system_prompt(old_system_prompt)
def topological_sort(tickets: list[dict]) -> list[dict]:
"""
Sorts a list of tickets based on their 'depends_on' field.
Raises ValueError if a circular dependency or missing internal dependency is detected.
"""
# 1. Map ID to ticket and build graph
ticket_map = {t['id']: t for t in tickets}
adj = {t['id']: [] for t in tickets}
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__":
# Quick test if run directly
test_brief = "Implement a new feature."
test_skeletons = "class NewFeature: pass"
tickets = generate_tickets(test_brief, test_skeletons)
print(json.dumps(tickets, indent=2))

205
gui_2.py
View File

@@ -24,9 +24,14 @@ import events
import numpy as np import numpy as np
import api_hooks import api_hooks
import mcp_client import mcp_client
import orchestrator_pm
from performance_monitor import PerformanceMonitor from performance_monitor import PerformanceMonitor
from log_registry import LogRegistry from log_registry import LogRegistry
from log_pruner import LogPruner from log_pruner import LogPruner
import conductor_tech_lead
import multi_agent_conductor
from models import Track, Ticket
from file_cache import ASTParser
from fastapi import FastAPI, Depends, HTTPException, Security from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security.api_key import APIKeyHeader from fastapi.security.api_key import APIKeyHeader
@@ -181,6 +186,9 @@ class App:
self.ui_ai_input = "" self.ui_ai_input = ""
self.ui_disc_new_name_input = "" self.ui_disc_new_name_input = ""
self.ui_disc_new_role_input = "" self.ui_disc_new_role_input = ""
self.ui_epic_input = ""
self.proposed_tracks = []
self._show_track_proposal_modal = False
# Last Script popup variables # Last Script popup variables
self.ui_last_script_text = "" self.ui_last_script_text = ""
@@ -238,6 +246,11 @@ class App:
self._mma_approval_edit_mode = False self._mma_approval_edit_mode = False
self._mma_approval_payload = "" self._mma_approval_payload = ""
# Orchestration State
self.ui_epic_input = ""
self.proposed_tracks: list[dict] = []
self._show_track_proposal_modal = False
self._tool_log: list[tuple[str, str]] = [] self._tool_log: list[tuple[str, str]] = []
self._comms_log: list[dict] = [] self._comms_log: list[dict] = []
@@ -706,6 +719,28 @@ class App:
agent_tools_cfg = proj.get("agent", {}).get("tools", {}) agent_tools_cfg = proj.get("agent", {}).get("tools", {})
self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES} self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES}
# Restore MMA state
mma_sec = proj.get("mma", {})
self.ui_epic_input = mma_sec.get("epic", "")
at_data = mma_sec.get("active_track")
if at_data:
try:
tickets = []
for t_data in at_data.get("tickets", []):
tickets.append(Ticket(**t_data))
self.active_track = Track(
id=at_data.get("id"),
description=at_data.get("description"),
tickets=tickets
)
self.active_tickets = at_data.get("tickets", []) # Keep dicts for UI table
except Exception as e:
print(f"Failed to deserialize active track: {e}")
self.active_track = None
else:
self.active_track = None
self.active_tickets = []
def _save_active_project(self): def _save_active_project(self):
if self.active_project_path: if self.active_project_path:
try: try:
@@ -856,6 +891,10 @@ class App:
"ts": project_manager.now_ts() "ts": project_manager.now_ts()
}) })
elif action == "show_track_proposal":
self.proposed_tracks = task.get("payload", [])
self._show_track_proposal_modal = True
elif action == "mma_state_update": elif action == "mma_state_update":
payload = task.get("payload", {}) payload = task.get("payload", {})
self.mma_status = payload.get("status", "idle") self.mma_status = payload.get("status", "idle")
@@ -1291,6 +1330,17 @@ class App:
disc_sec["active"] = self.active_discussion disc_sec["active"] = self.active_discussion
disc_sec["auto_add"] = self.ui_auto_add_history disc_sec["auto_add"] = self.ui_auto_add_history
# Save MMA State
mma_sec = proj.setdefault("mma", {})
mma_sec["epic"] = self.ui_epic_input
if self.active_track:
# We only persist the basic metadata if full serialization is too complex
# For now, let's try full serialization via asdict
from dataclasses import asdict
mma_sec["active_track"] = asdict(self.active_track)
else:
mma_sec["active_track"] = None
def _flush_to_config(self): def _flush_to_config(self):
self.config["ai"] = { self.config["ai"] = {
"provider": self.current_provider, "provider": self.current_provider,
@@ -1408,6 +1458,8 @@ class App:
# Process GUI task queue # Process GUI task queue
self._process_pending_gui_tasks() self._process_pending_gui_tasks()
self._render_track_proposal_modal()
# Auto-save (every 60s) # Auto-save (every 60s)
now = time.time() now = time.time()
if now - self._last_autosave >= self._autosave_interval: if now - self._last_autosave >= self._autosave_interval:
@@ -1887,6 +1939,126 @@ class App:
if ch: if ch:
self.ui_agent_tools[t_name] = val self.ui_agent_tools[t_name] = val
imgui.separator()
imgui.text_colored(C_LBL, 'MMA Orchestration')
_, self.ui_epic_input = imgui.input_text_multiline('##epic_input', self.ui_epic_input, imgui.ImVec2(-1, 80))
if imgui.button('Plan Epic (Tier 1)', imgui.ImVec2(-1, 0)):
self._cb_plan_epic()
def _cb_plan_epic(self):
def _bg_task():
try:
self.ai_status = "Planning Epic (Tier 1)..."
history = orchestrator_pm.get_track_history_summary()
proj = project_manager.load_project(self.active_project_path)
flat = project_manager.flat_config(proj)
file_items = aggregate.build_file_items(Path("."), flat.get("files", {}).get("paths", []))
tracks = orchestrator_pm.generate_tracks(self.ui_epic_input, flat, file_items, history_summary=history)
with self._pending_gui_tasks_lock:
self._pending_gui_tasks.append({
"action": "show_track_proposal",
"payload": tracks
})
self.ai_status = "Epic tracks generated."
except Exception as e:
self.ai_status = f"Epic plan error: {e}"
print(f"ERROR in _cb_plan_epic background task: {e}")
threading.Thread(target=_bg_task, daemon=True).start()
def _cb_accept_tracks(self):
def _bg_task():
try:
self.ai_status = "Generating tickets (Tier 2)..."
# 1. Get skeletons for context
parser = ASTParser(language="python")
skeletons = ""
for file_path in self.files:
try:
abs_path = Path(self.ui_files_base_dir) / file_path
if abs_path.exists() and abs_path.suffix == ".py":
with open(abs_path, "r", encoding="utf-8") as f:
code = f.read()
skeletons += f"\nFile: {file_path}\n{parser.get_skeleton(code)}\n"
except Exception as e:
print(f"Error parsing skeleton for {file_path}: {e}")
# 2. For each proposed track, generate and sort tickets
for track_data in self.proposed_tracks:
goal = track_data.get("goal", "")
title = track_data.get("title", "Untitled Track")
raw_tickets = conductor_tech_lead.generate_tickets(goal, skeletons)
if not raw_tickets:
print(f"Warning: No tickets generated for track: {title}")
continue
try:
sorted_tickets_data = conductor_tech_lead.topological_sort(raw_tickets)
except ValueError as e:
print(f"Dependency error in track '{title}': {e}")
# Fallback to unsorted if sort fails? Or skip?
sorted_tickets_data = raw_tickets
# 3. Create Track and Ticket objects
tickets = []
for t_data in sorted_tickets_data:
ticket = Ticket(
id=t_data["id"],
description=t_data["description"],
status=t_data.get("status", "todo"),
assigned_to=t_data.get("assigned_to", "unassigned"),
depends_on=t_data.get("depends_on", []),
step_mode=t_data.get("step_mode", False)
)
tickets.append(ticket)
track_id = f"track_{uuid.uuid4().hex[:8]}"
track = Track(id=track_id, description=title, tickets=tickets)
# 4. Initialize ConductorEngine and run_linear loop
engine = multi_agent_conductor.ConductorEngine(track, self.event_queue)
# Schedule the coroutine on the internal event loop
asyncio.run_coroutine_threadsafe(engine.run_linear(), self._loop)
self.ai_status = "Tracks accepted and execution started."
except Exception as e:
self.ai_status = f"Track acceptance error: {e}"
print(f"ERROR in _cb_accept_tracks background task: {e}")
threading.Thread(target=_bg_task, daemon=True).start()
def _render_track_proposal_modal(self):
if self._show_track_proposal_modal:
imgui.open_popup("Track Proposal")
if imgui.begin_popup_modal("Track Proposal", True, imgui.WindowFlags_.always_auto_resize)[0]:
imgui.text_colored(C_IN, "Proposed Implementation Tracks")
imgui.separator()
if not self.proposed_tracks:
imgui.text("No tracks generated.")
else:
for idx, track in enumerate(self.proposed_tracks):
imgui.text_colored(C_LBL, f"Track {idx+1}: {track.get('title', 'Untitled')}")
imgui.text_wrapped(f"Goal: {track.get('goal', 'N/A')}")
imgui.separator()
if imgui.button("Accept", imgui.ImVec2(120, 0)):
self._cb_accept_tracks()
self._show_track_proposal_modal = False
imgui.close_current_popup()
imgui.same_line()
if imgui.button("Cancel", imgui.ImVec2(120, 0)):
self._show_track_proposal_modal = False
imgui.close_current_popup()
imgui.end_popup()
def _render_log_management(self): def _render_log_management(self):
exp, self.show_windows["Log Management"] = imgui.begin("Log Management", self.show_windows["Log Management"]) exp, self.show_windows["Log Management"] = imgui.begin("Log Management", self.show_windows["Log Management"])
if not exp: if not exp:
@@ -2371,6 +2543,26 @@ class App:
if is_blinking: if is_blinking:
imgui.pop_style_color(2) imgui.pop_style_color(2)
def _cb_ticket_retry(self, ticket_id):
for t in self.active_tickets:
if t.get('id') == ticket_id:
t['status'] = 'todo'
break
asyncio.run_coroutine_threadsafe(
self.event_queue.put("mma_retry", {"ticket_id": ticket_id}),
self._loop
)
def _cb_ticket_skip(self, ticket_id):
for t in self.active_tickets:
if t.get('id') == ticket_id:
t['status'] = 'skipped'
break
asyncio.run_coroutine_threadsafe(
self.event_queue.put("mma_skip", {"ticket_id": ticket_id}),
self._loop
)
def _render_mma_dashboard(self): def _render_mma_dashboard(self):
# 1. Global Controls # 1. Global Controls
changed, self.mma_step_mode = imgui.checkbox("Step Mode (HITL)", self.mma_step_mode) changed, self.mma_step_mode = imgui.checkbox("Step Mode (HITL)", self.mma_step_mode)
@@ -2404,16 +2596,18 @@ class App:
# 3. Ticket Queue # 3. Ticket Queue
imgui.text("Ticket Queue") imgui.text("Ticket Queue")
if imgui.begin_table("mma_tickets", 3, imgui.TableFlags_.borders_inner_h | imgui.TableFlags_.resizable): if imgui.begin_table("mma_tickets", 4, imgui.TableFlags_.borders_inner_h | imgui.TableFlags_.resizable):
imgui.table_setup_column("ID", imgui.TableColumnFlags_.width_fixed, 80) imgui.table_setup_column("ID", imgui.TableColumnFlags_.width_fixed, 80)
imgui.table_setup_column("Target", imgui.TableColumnFlags_.width_stretch) imgui.table_setup_column("Target", imgui.TableColumnFlags_.width_stretch)
imgui.table_setup_column("Status", imgui.TableColumnFlags_.width_fixed, 100) imgui.table_setup_column("Status", imgui.TableColumnFlags_.width_fixed, 100)
imgui.table_setup_column("Actions", imgui.TableColumnFlags_.width_fixed, 120)
imgui.table_headers_row() imgui.table_headers_row()
for t in self.active_tickets: for t in self.active_tickets:
tid = t.get('id', '??')
imgui.table_next_row() imgui.table_next_row()
imgui.table_next_column() imgui.table_next_column()
imgui.text(str(t.get('id', '??'))) imgui.text(str(tid))
imgui.table_next_column() imgui.table_next_column()
imgui.text(str(t.get('target_file', 'general'))) imgui.text(str(t.get('target_file', 'general')))
@@ -2435,6 +2629,13 @@ class App:
if status in ['RUNNING', 'COMPLETE', 'BLOCKED', 'ERROR', 'PAUSED']: if status in ['RUNNING', 'COMPLETE', 'BLOCKED', 'ERROR', 'PAUSED']:
imgui.pop_style_color() imgui.pop_style_color()
imgui.table_next_column()
if imgui.button(f"Retry##{tid}"):
self._cb_ticket_retry(tid)
imgui.same_line()
if imgui.button(f"Skip##{tid}"):
self._cb_ticket_skip(tid)
imgui.end_table() imgui.end_table()
def _render_tool_calls_panel(self): def _render_tool_calls_panel(self):

View File

@@ -79,7 +79,7 @@ DockId=0x0000000F,2
[Window][Theme] [Window][Theme]
Pos=0,17 Pos=0,17
Size=348,545 Size=588,545
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=350,17 Pos=590,17
Size=530,1183 Size=530,1228
Collapsed=0 Collapsed=0
DockId=0x0000000E,0 DockId=0x0000000E,0
[Window][Context Hub] [Window][Context Hub]
Pos=0,17 Pos=0,17
Size=348,545 Size=588,545
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=882,17 Pos=1122,17
Size=558,1183 Size=558,1228
Collapsed=0 Collapsed=0
DockId=0x00000004,0 DockId=0x00000004,0
[Window][Operations Hub] [Window][Operations Hub]
Pos=350,17 Pos=590,17
Size=530,1183 Size=530,1228
Collapsed=0 Collapsed=0
DockId=0x0000000E,1 DockId=0x0000000E,1
[Window][Files & Media] [Window][Files & Media]
Pos=0,564 Pos=0,564
Size=348,636 Size=588,681
Collapsed=0 Collapsed=0
DockId=0x00000006,1 DockId=0x00000006,1
[Window][AI Settings] [Window][AI Settings]
Pos=0,564 Pos=0,564
Size=348,636 Size=588,681
Collapsed=0 Collapsed=0
DockId=0x00000006,0 DockId=0x00000006,0
@@ -135,11 +135,22 @@ Pos=512,437
Size=416,325 Size=416,325
Collapsed=0 Collapsed=0
[Window][MMA Dashboard]
Pos=157,466
Size=676,653
Collapsed=0
[Table][0xFB6E3870,3]
RefScale=13
Column 0 Width=80
Column 1 Weight=1.0000
Column 2 Width=100
[Docking][Data] [Docking][Data]
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,17 Size=1440,1183 Split=Y DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=0,17 Size=1680,1228 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=1120,1183 Split=X DockNode ID=0x00000003 Parent=0x0000000C SizeRef=1120,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

View File

@@ -10,6 +10,8 @@ class Ticket:
description: str description: str
status: str status: str
assigned_to: str assigned_to: str
target_file: Optional[str] = None
context_requirements: List[str] = field(default_factory=list)
depends_on: List[str] = field(default_factory=list) depends_on: List[str] = field(default_factory=list)
blocked_reason: Optional[str] = None blocked_reason: Optional[str] = None
step_mode: bool = False step_mode: bool = False

View File

@@ -14,6 +14,12 @@ class ConductorEngine:
def __init__(self, track: Track, event_queue: Optional[events.AsyncEventQueue] = None): def __init__(self, track: Track, event_queue: Optional[events.AsyncEventQueue] = None):
self.track = track self.track = track
self.event_queue = event_queue self.event_queue = event_queue
self.tier_usage = {
"Tier 1": {"input": 0, "output": 0},
"Tier 2": {"input": 0, "output": 0},
"Tier 3": {"input": 0, "output": 0},
"Tier 4": {"input": 0, "output": 0},
}
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:
@@ -22,6 +28,7 @@ class ConductorEngine:
payload = { payload = {
"status": status, "status": status,
"active_tier": active_tier, "active_tier": active_tier,
"tier_usage": self.tier_usage,
"track": { "track": {
"id": self.track.id, "id": self.track.id,
"title": self.track.description, "title": self.track.description,

View File

@@ -6,7 +6,64 @@ import aggregate
import summarize import summarize
from pathlib import Path from pathlib import Path
def generate_tracks(user_request: str, project_config: dict, file_items: list[dict]) -> list[dict]: CONDUCTOR_PATH = Path("conductor")
def get_track_history_summary() -> str:
"""
Scans conductor/archive/ and conductor/tracks/ to build a summary of past work.
"""
summary_parts = []
archive_path = CONDUCTOR_PATH / "archive"
tracks_path = CONDUCTOR_PATH / "tracks"
paths_to_scan = []
if archive_path.exists():
paths_to_scan.extend(list(archive_path.iterdir()))
if tracks_path.exists():
paths_to_scan.extend(list(tracks_path.iterdir()))
for track_dir in paths_to_scan:
if not track_dir.is_dir():
continue
metadata_file = track_dir / "metadata.json"
spec_file = track_dir / "spec.md"
title = track_dir.name
status = "unknown"
overview = "No overview available."
if metadata_file.exists():
try:
with open(metadata_file, "r", encoding="utf-8") as f:
meta = json.load(f)
title = meta.get("title", title)
status = meta.get("status", status)
except Exception:
pass
if spec_file.exists():
try:
with open(spec_file, "r", encoding="utf-8") as f:
content = f.read()
# Basic extraction of Overview section if it exists
if "## Overview" in content:
overview = content.split("## Overview")[1].split("##")[0].strip()
else:
# Just take a snippet of the beginning
overview = content[:200] + "..."
except Exception:
pass
summary_parts.append(f"Track: {title}\nStatus: {status}\nOverview: {overview}\n---")
if not summary_parts:
return "No previous tracks found."
return "\n".join(summary_parts)
def generate_tracks(user_request: str, project_config: dict, file_items: list[dict], history_summary: str = None) -> list[dict]:
""" """
Tier 1 (Strategic PM) call. Tier 1 (Strategic PM) call.
Analyzes the project state and user request to generate a list of Tracks. Analyzes the project state and user request to generate a list of Tracks.
@@ -16,42 +73,49 @@ def generate_tracks(user_request: str, project_config: dict, file_items: list[di
# 2. Construct Prompt # 2. Construct Prompt
system_prompt = mma_prompts.PROMPTS.get("tier1_epic_init") system_prompt = mma_prompts.PROMPTS.get("tier1_epic_init")
user_message = (
f"### USER REQUEST:
{user_request}
"
f"### REPOSITORY MAP:
{repo_map}
"
"Please generate the implementation tracks for this request."
)
# 3. Call Tier 1 Model (Strategic - Pro) user_message_parts = [
# Note: We use gemini-1.5-pro or similar high-reasoning model for Tier 1 f"### USER REQUEST:\n{user_request}\n",
response = ai_client.send( f"### REPOSITORY MAP:\n{repo_map}\n"
md_content="", # We pass everything in user_message for clarity ]
user_message=user_message,
system_prompt=system_prompt, if history_summary:
model_name="gemini-1.5-pro" # Strategic Tier user_message_parts.append(f"### TRACK HISTORY:\n{history_summary}\n")
)
user_message_parts.append("Please generate the implementation tracks for this request.")
user_message = "\n".join(user_message_parts)
# Set custom system prompt for this call
old_system_prompt = ai_client._custom_system_prompt
ai_client.set_custom_system_prompt(system_prompt)
# 4. Parse JSON Output
try: try:
# The prompt asks for a JSON array. We need to extract it if the AI added markdown blocks. # 3. Call Tier 1 Model (Strategic - Pro)
json_match = response.strip() # Note: We use gemini-1.5-pro or similar high-reasoning model for Tier 1
if "```json" in json_match: response = ai_client.send(
json_match = json_match.split("```json")[1].split("```")[0].strip() md_content="", # We pass everything in user_message for clarity
elif "```" in json_match: user_message=user_message
json_match = json_match.split("```")[1].split("```")[0].strip() )
tracks = json.loads(json_match) # 4. Parse JSON Output
return tracks try:
except Exception as e: # The prompt asks for a JSON array. We need to extract it if the AI added markdown blocks.
print(f"Error parsing Tier 1 response: {e}") json_match = response.strip()
print(f"Raw response: {response}") if "```json" in json_match:
return [] json_match = json_match.split("```json")[1].split("```")[0].strip()
elif "```" in json_match:
json_match = json_match.split("```")[1].split("```")[0].strip()
tracks = json.loads(json_match)
return tracks
except Exception as e:
print(f"Error parsing Tier 1 response: {e}")
print(f"Raw response: {response}")
return []
finally:
# Restore old system prompt
ai_client.set_custom_system_prompt(old_system_prompt)
if __name__ == "__main__": if __name__ == "__main__":
# Quick CLI test # Quick CLI test
@@ -61,5 +125,6 @@ if __name__ == "__main__":
file_items = aggregate.build_file_items(Path("."), flat.get("files", {}).get("paths", [])) file_items = aggregate.build_file_items(Path("."), flat.get("files", {}).get("paths", []))
print("Testing Tier 1 Track Generation...") print("Testing Tier 1 Track Generation...")
tracks = generate_tracks("Implement a basic unit test for the ai_client.py module.", flat, file_items) history = get_track_history_summary()
tracks = generate_tracks("Implement a basic unit test for the ai_client.py module.", flat, file_items, history_summary=history)
print(json.dumps(tracks, indent=2)) print(json.dumps(tracks, indent=2))

View File

@@ -118,6 +118,11 @@ def default_project(name: str = "unnamed") -> dict:
"active": "main", "active": "main",
"discussions": {"main": default_discussion()}, "discussions": {"main": default_discussion()},
}, },
"mma": {
"epic": "",
"active_track_id": "",
"tracks": []
}
} }

View File

@@ -176,12 +176,12 @@ 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. " \ "You have access to tools for reading and writing files, and run_shell_command for TDD verification. " \
"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, and run_shell_command 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}. " \
@@ -205,7 +205,7 @@ def execute_agent(role: str, prompt: str, docs: list[str]) -> str:
# 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.
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' --output-format json --model {model}" f"gemini -p 'mma_task' --allow-shell --output-format json --model {model}"
) )
cmd = ['powershell.exe', '-NoProfile', '-Command', ps_command] cmd = ['powershell.exe', '-NoProfile', '-Command', ps_command]

33
test_mma_persistence.py Normal file
View File

@@ -0,0 +1,33 @@
import os
import unittest
from pathlib import Path
import project_manager
from models import Track, Ticket
class TestMMAPersistence(unittest.TestCase):
def test_default_project_has_mma(self):
proj = project_manager.default_project("test")
self.assertIn("mma", proj)
self.assertEqual(proj["mma"], {"epic": "", "tracks": []})
def test_save_load_mma(self):
proj = project_manager.default_project("test")
proj["mma"] = {"epic": "Test Epic", "tracks": [{"id": "track_1"}]}
test_file = Path("test_mma_proj.toml")
try:
project_manager.save_project(proj, test_file)
loaded = project_manager.load_project(test_file)
self.assertIn("mma", loaded)
self.assertEqual(loaded["mma"]["epic"], "Test Epic")
self.assertEqual(len(loaded["mma"]["tracks"]), 1)
finally:
if test_file.exists():
test_file.unlink()
hist_file = Path("test_mma_proj_history.toml")
if hist_file.exists():
hist_file.unlink()
if __name__ == "__main__":
unittest.main()

25
tests/diag_subagent.py Normal file
View File

@@ -0,0 +1,25 @@
import subprocess
import sys
import os
def run_diag(role, prompt):
print(f"--- Running Diag for {role} ---")
cmd = [sys.executable, "scripts/mma_exec.py", "--role", role, prompt]
try:
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
print("STDOUT:")
print(result.stdout)
print("STDERR:")
print(result.stderr)
return result.stdout
except Exception as e:
print(f"FAILED: {e}")
return str(e)
if __name__ == "__main__":
# Test 1: Simple read
print("TEST 1: read_file")
run_diag("tier3-worker", "Read the file 'pyproject.toml' and tell me the version of the project. ONLY the version string.")
print("\nTEST 2: run_shell_command")
run_diag("tier3-worker", "Use run_shell_command to execute 'echo HELLO_SUBAGENT' and return the output. ONLY the output.")

View File

@@ -17,5 +17,5 @@ history = [
[discussions."mma_human veriffication"] [discussions."mma_human veriffication"]
git_commit = "" git_commit = ""
last_updated = "2026-02-26T22:06:01" last_updated = "2026-02-26T22:07:06"
history = [] history = []

View File

@@ -0,0 +1,116 @@
import unittest
from unittest.mock import patch, MagicMock
import json
import conductor_tech_lead
class TestConductorTechLead(unittest.TestCase):
@patch('ai_client.send')
@patch('ai_client.set_provider')
@patch('ai_client.reset_session')
def test_generate_tickets_success(self, mock_reset_session, mock_set_provider, mock_send):
# Setup mock response
mock_tickets = [
{
"id": "ticket_1",
"type": "Ticket",
"goal": "Test goal",
"target_file": "test.py",
"depends_on": [],
"context_requirements": []
}
]
mock_send.return_value = "```json\n" + json.dumps(mock_tickets) + "\n```"
track_brief = "Test track brief"
module_skeletons = "Test skeletons"
# Call the function
tickets = conductor_tech_lead.generate_tickets(track_brief, module_skeletons)
# Verify set_provider was called
mock_set_provider.assert_called_with('gemini', 'gemini-1.5-flash')
mock_reset_session.assert_called_once()
# Verify send was called
mock_send.assert_called_once()
args, kwargs = mock_send.call_args
self.assertEqual(kwargs['md_content'], "")
self.assertIn(track_brief, kwargs['user_message'])
self.assertIn(module_skeletons, kwargs['user_message'])
# Verify tickets were parsed correctly
self.assertEqual(tickets, mock_tickets)
@patch('ai_client.send')
@patch('ai_client.set_provider')
@patch('ai_client.reset_session')
def test_generate_tickets_parse_error(self, mock_reset_session, mock_set_provider, mock_send):
# Setup mock invalid response
mock_send.return_value = "Invalid JSON"
# Call the function
tickets = conductor_tech_lead.generate_tickets("brief", "skeletons")
# Verify it returns an empty list on parse error
self.assertEqual(tickets, [])
class TestTopologicalSort(unittest.TestCase):
def test_topological_sort_empty(self):
tickets = []
sorted_tickets = conductor_tech_lead.topological_sort(tickets)
self.assertEqual(sorted_tickets, [])
def test_topological_sort_linear(self):
tickets = [
{"id": "t2", "depends_on": ["t1"]},
{"id": "t1", "depends_on": []},
{"id": "t3", "depends_on": ["t2"]},
]
sorted_tickets = conductor_tech_lead.topological_sort(tickets)
ids = [t["id"] for t in sorted_tickets]
self.assertEqual(ids, ["t1", "t2", "t3"])
def test_topological_sort_complex(self):
# t1
# | \
# t2 t3
# | /
# t4
tickets = [
{"id": "t4", "depends_on": ["t2", "t3"]},
{"id": "t3", "depends_on": ["t1"]},
{"id": "t2", "depends_on": ["t1"]},
{"id": "t1", "depends_on": []},
]
sorted_tickets = conductor_tech_lead.topological_sort(tickets)
ids = [t["id"] for t in sorted_tickets]
# Possible valid orders: [t1, t2, t3, t4] or [t1, t3, t2, t4]
self.assertEqual(ids[0], "t1")
self.assertEqual(ids[-1], "t4")
self.assertSetEqual(set(ids[1:3]), {"t2", "t3"})
def test_topological_sort_cycle(self):
tickets = [
{"id": "t1", "depends_on": ["t2"]},
{"id": "t2", "depends_on": ["t1"]},
]
with self.assertRaises(ValueError) as cm:
conductor_tech_lead.topological_sort(tickets)
self.assertIn("Circular dependency detected", str(cm.exception))
def test_topological_sort_missing_dependency(self):
# If a ticket depends on something not in the list, we should probably handle it or let it fail.
# Usually in our context, we only care about dependencies within the same track.
tickets = [
{"id": "t1", "depends_on": ["missing"]},
]
# For now, let's assume it should raise an error if a dependency is missing within the set we are sorting,
# OR it should just treat it as "ready" if it's external?
# Actually, let's just test that it doesn't crash if it's not a cycle.
# But if 'missing' is not in tickets, it will never be satisfied.
# Let's say it raises ValueError for missing internal dependencies.
with self.assertRaises(ValueError):
conductor_tech_lead.topological_sort(tickets)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,80 @@
import pytest
from unittest.mock import patch, MagicMock
import threading
import time
from gui_2 import App
@pytest.fixture
def app_instance():
with (
patch('gui_2.load_config', return_value={'ai': {}, 'projects': {}}),
patch('gui_2.save_config'),
patch('gui_2.project_manager'),
patch('gui_2.session_logger'),
patch('gui_2.immapp.run'),
patch.object(App, '_load_active_project'),
patch.object(App, '_fetch_models'),
patch.object(App, '_load_fonts'),
patch.object(App, '_post_init')
):
app = App()
# Initialize the new state variables if they aren't there yet (they won't be until we implement them)
if not hasattr(app, 'ui_epic_input'): app.ui_epic_input = ""
if not hasattr(app, 'proposed_tracks'): app.proposed_tracks = []
if not hasattr(app, '_show_track_proposal_modal'): app._show_track_proposal_modal = False
yield app
def test_mma_ui_state_initialization(app_instance):
"""Verifies that the new MMA UI state variables are initialized correctly."""
assert hasattr(app_instance, 'ui_epic_input')
assert hasattr(app_instance, 'proposed_tracks')
assert hasattr(app_instance, '_show_track_proposal_modal')
assert app_instance.ui_epic_input == ""
assert app_instance.proposed_tracks == []
assert app_instance._show_track_proposal_modal is False
def test_process_pending_gui_tasks_show_track_proposal(app_instance):
"""Verifies that the 'show_track_proposal' action correctly updates the UI state."""
mock_tracks = [{"id": "track_1", "title": "Test Track"}]
task = {
"action": "show_track_proposal",
"payload": mock_tracks
}
app_instance._pending_gui_tasks.append(task)
app_instance._process_pending_gui_tasks()
assert app_instance.proposed_tracks == mock_tracks
assert app_instance._show_track_proposal_modal is True
def test_cb_plan_epic_launches_thread(app_instance):
"""Verifies that _cb_plan_epic launches a thread and eventually queues a task."""
app_instance.ui_epic_input = "Develop a new feature"
app_instance.active_project_path = "test_project.toml"
mock_tracks = [{"id": "track_1", "title": "Test Track"}]
with patch('orchestrator_pm.get_track_history_summary', return_value="History summary") as mock_get_history,
patch('orchestrator_pm.generate_tracks', return_value=mock_tracks) as mock_gen_tracks,
patch('aggregate.build_file_items', return_value=[]) as mock_build_files:
# We need to mock project_manager.flat_config and project_manager.load_project
with patch('project_manager.load_project', return_value={}),
patch('project_manager.flat_config', return_value={}):
app_instance._cb_plan_epic()
# Wait for the background thread to finish (it should be quick with mocks)
# In a real test, we might need a more robust way to wait, but for now:
max_wait = 5
start_time = time.time()
while len(app_instance._pending_gui_tasks) == 0 and time.time() - start_time < max_wait:
time.sleep(0.1)
assert len(app_instance._pending_gui_tasks) > 0
task = app_instance._pending_gui_tasks[0]
assert task['action'] == 'show_track_proposal'
assert task['payload'] == mock_tracks
mock_get_history.assert_called_once()
mock_gen_tracks.assert_called_once()

View File

@@ -0,0 +1,53 @@
import pytest
from unittest.mock import patch, MagicMock
import asyncio
from gui_2 import App
@pytest.fixture
def app_instance():
with (
patch('gui_2.load_config', return_value={'ai': {}, 'projects': {}}),
patch('gui_2.save_config'),
patch('gui_2.project_manager'),
patch('gui_2.session_logger'),
patch('gui_2.immapp.run'),
patch.object(App, '_load_active_project'),
patch.object(App, '_fetch_models'),
patch.object(App, '_load_fonts'),
patch.object(App, '_post_init')
):
app = App()
app.active_tickets = []
app._loop = MagicMock()
yield app
def test_cb_ticket_retry(app_instance):
ticket_id = "test_ticket_1"
app_instance.active_tickets = [{"id": ticket_id, "status": "failed"}]
with patch('asyncio.run_coroutine_threadsafe') as mock_run_safe:
app_instance._cb_ticket_retry(ticket_id)
# Verify status update
assert app_instance.active_tickets[0]['status'] == 'todo'
# Verify event pushed
mock_run_safe.assert_called_once()
# First arg is the coroutine (event_queue.put), second is self._loop
args, _ = mock_run_safe.call_args
assert args[1] == app_instance._loop
def test_cb_ticket_skip(app_instance):
ticket_id = "test_ticket_1"
app_instance.active_tickets = [{"id": ticket_id, "status": "todo"}]
with patch('asyncio.run_coroutine_threadsafe') as mock_run_safe:
app_instance._cb_ticket_skip(ticket_id)
# Verify status update
assert app_instance.active_tickets[0]['status'] == 'skipped'
# Verify event pushed
mock_run_safe.assert_called_once()
args, _ = mock_run_safe.call_args
assert args[1] == app_instance._loop

View File

@@ -0,0 +1,81 @@
import unittest
from unittest.mock import patch, MagicMock
import json
import orchestrator_pm
import mma_prompts
class TestOrchestratorPM(unittest.TestCase):
@patch('summarize.build_summary_markdown')
@patch('ai_client.send')
def test_generate_tracks_success(self, mock_send, mock_summarize):
# Setup mocks
mock_summarize.return_value = "REPO_MAP_CONTENT"
mock_response_data = [
{
"id": "track_1",
"type": "Track",
"module": "test_module",
"persona": "Tech Lead",
"severity": "Medium",
"goal": "Test goal",
"acceptance_criteria": ["criteria 1"]
}
]
mock_send.return_value = json.dumps(mock_response_data)
user_request = "Implement unit tests"
project_config = {"files": {"paths": ["src"]}}
file_items = [{"path": "src/main.py", "content": "print('hello')"}]
# Execute
result = orchestrator_pm.generate_tracks(user_request, project_config, file_items)
# Verify summarize call
mock_summarize.assert_called_once_with(file_items)
# Verify ai_client.send call
expected_system_prompt = mma_prompts.PROMPTS['tier1_epic_init']
mock_send.assert_called_once()
args, kwargs = mock_send.call_args
self.assertEqual(kwargs['md_content'], "")
self.assertEqual(kwargs['system_prompt'], expected_system_prompt)
self.assertIn(user_request, kwargs['user_message'])
self.assertIn("REPO_MAP_CONTENT", kwargs['user_message'])
self.assertEqual(kwargs['model_name'], "gemini-1.5-pro")
# Verify result
self.assertEqual(result, mock_response_data)
@patch('summarize.build_summary_markdown')
@patch('ai_client.send')
def test_generate_tracks_markdown_wrapped(self, mock_send, mock_summarize):
mock_summarize.return_value = "REPO_MAP"
mock_response_data = [{"id": "track_1"}]
# Wrapped in ```json ... ```
mock_send.return_value = f"Here is the plan:\n```json\n{json.dumps(mock_response_data)}\n```\nHope this helps."
result = orchestrator_pm.generate_tracks("req", {}, [])
self.assertEqual(result, mock_response_data)
# Wrapped in ``` ... ```
mock_send.return_value = f"```\n{json.dumps(mock_response_data)}\n```"
result = orchestrator_pm.generate_tracks("req", {}, [])
self.assertEqual(result, mock_response_data)
@patch('summarize.build_summary_markdown')
@patch('ai_client.send')
def test_generate_tracks_malformed_json(self, mock_send, mock_summarize):
mock_summarize.return_value = "REPO_MAP"
mock_send.return_value = "NOT A JSON"
# Should return empty list and print error (we can mock print if we want to be thorough)
with patch('builtins.print') as mock_print:
result = orchestrator_pm.generate_tracks("req", {}, [])
self.assertEqual(result, [])
mock_print.assert_any_call("Error parsing Tier 1 response: Expecting value: line 1 column 1 (char 0)")
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,76 @@
import unittest
from unittest.mock import patch, MagicMock
import os
import shutil
import json
from pathlib import Path
import orchestrator_pm
class TestOrchestratorPMHistory(unittest.TestCase):
def setUp(self):
self.test_dir = Path("test_conductor")
self.test_dir.mkdir(exist_ok=True)
self.archive_dir = self.test_dir / "archive"
self.tracks_dir = self.test_dir / "tracks"
self.archive_dir.mkdir(exist_ok=True)
self.tracks_dir.mkdir(exist_ok=True)
def tearDown(self):
if self.test_dir.exists():
shutil.rmtree(self.test_dir)
def create_track(self, parent_dir, track_id, title, status, overview):
track_path = parent_dir / track_id
track_path.mkdir(exist_ok=True)
metadata = {"title": title, "status": status}
with open(track_path / "metadata.json", "w") as f:
json.dump(metadata, f)
spec_content = f"# Specification\n\n## Overview\n{overview}"
with open(track_path / "spec.md", "w") as f:
f.write(spec_content)
@patch('orchestrator_pm.CONDUCTOR_PATH', Path("test_conductor"))
def test_get_track_history_summary(self):
# Setup mock tracks
self.create_track(self.archive_dir, "track_001", "Initial Setup", "completed", "Setting up the project structure.")
self.create_track(self.tracks_dir, "track_002", "Feature A", "in_progress", "Implementing Feature A.")
summary = orchestrator_pm.get_track_history_summary()
self.assertIn("Initial Setup", summary)
self.assertIn("completed", summary)
self.assertIn("Setting up the project structure.", summary)
self.assertIn("Feature A", summary)
self.assertIn("in_progress", summary)
self.assertIn("Implementing Feature A.", summary)
@patch('orchestrator_pm.CONDUCTOR_PATH', Path("test_conductor"))
def test_get_track_history_summary_missing_files(self):
# Track with missing spec.md
track_path = self.tracks_dir / "track_003"
track_path.mkdir(exist_ok=True)
with open(track_path / "metadata.json", "w") as f:
json.dump({"title": "Missing Spec", "status": "pending"}, f)
summary = orchestrator_pm.get_track_history_summary()
self.assertIn("Missing Spec", summary)
self.assertIn("pending", summary)
self.assertIn("No overview available", summary)
@patch('orchestrator_pm.summarize.build_summary_markdown')
@patch('ai_client.send')
def test_generate_tracks_with_history(self, mock_send, mock_summarize):
mock_summarize.return_value = "REPO_MAP"
mock_send.return_value = "[]"
history_summary = "PAST_HISTORY_SUMMARY"
orchestrator_pm.generate_tracks("req", {}, [], history_summary=history_summary)
args, kwargs = mock_send.call_args
self.assertIn(history_summary, kwargs['user_message'])
self.assertIn("### TRACK HISTORY:", kwargs['user_message'])
if __name__ == '__main__':
unittest.main()