diff --git a/project_history.toml b/project_history.toml index fd647ec..b2928d9 100644 --- a/project_history.toml +++ b/project_history.toml @@ -9,5 +9,5 @@ active = "main" [discussions.main] git_commit = "" -last_updated = "2026-05-02T12:45:18" +last_updated = "2026-05-02T14:52:30" history = [] diff --git a/src/ai_client.py b/src/ai_client.py index 36caf8e..3bef88f 100644 --- a/src/ai_client.py +++ b/src/ai_client.py @@ -182,6 +182,9 @@ def _get_combined_system_prompt(preset: Optional[ToolPreset] = None, bias: Optio base += f"\n\n{strategy}" return base +def get_combined_system_prompt(preset: Optional[ToolPreset] = None, bias: Optional[BiasProfile] = None) -> str: + return _get_combined_system_prompt(preset, bias) + from collections import deque _comms_log: deque[dict[str, Any]] = deque(maxlen=1000) diff --git a/tests/mock_concurrent_mma.py b/tests/mock_concurrent_mma.py new file mode 100644 index 0000000..03742fb --- /dev/null +++ b/tests/mock_concurrent_mma.py @@ -0,0 +1,84 @@ +import sys +import json +import os + +def main() -> None: + # Read prompt from stdin + try: + prompt = sys.stdin.read() + except Exception: + prompt = "" + + # 1. Epic Initialization + if 'PATH: Epic Initialization' in prompt: + mock_response = [ + {"id": "track-a", "goal": "Track A Goal", "title": "Track A"}, + {"id": "track-b", "goal": "Track B Goal", "title": "Track B"} + ] + print(json.dumps({ + "type": "message", + "role": "assistant", + "content": json.dumps(mock_response) + }), flush=True) + print(json.dumps({ + "type": "result", + "status": "success", + "stats": {"total_tokens": 100, "input_tokens": 50, "output_tokens": 50}, + "session_id": "mock-epic" + }), flush=True) + return + + # 2. Sprint Planning (different tickets for different tracks) + if 'generate the implementation tickets' in prompt: + track_label = "A" if "Track A" in prompt else "B" + mock_response = [ + {"id": f"ticket-{track_label}-1", "description": f"Ticket {track_label} 1", "status": "todo", "assigned_to": "worker", "depends_on": []} + ] + print(json.dumps({ + "type": "message", + "role": "assistant", + "content": json.dumps(mock_response) + }), flush=True) + print(json.dumps({ + "type": "result", + "status": "success", + "stats": {"total_tokens": 100, "input_tokens": 50, "output_tokens": 50}, + "session_id": f"mock-sprint-{track_label}" + }), flush=True) + return + + # 3. Worker Execution + if 'You are assigned to Ticket' in prompt: + # Extract ticket ID + import re + match = re.search(r'Ticket (ticket-[A-B]-1)', prompt) + tid = match.group(1) if match else "unknown" + + print(json.dumps({ + "type": "message", + "role": "assistant", + "content": f"Working on {tid}. Done." + }), flush=True) + print(json.dumps({ + "type": "result", + "status": "success", + "stats": {"total_tokens": 50, "input_tokens": 25, "output_tokens": 25}, + "session_id": f"mock-worker-{tid}" + }), flush=True) + return + + # Default + print(json.dumps({ + "type": "message", + "role": "assistant", + "content": "Mock response" + }), flush=True) + print(json.dumps({ + "type": "result", + "status": "success", + "stats": {"total_tokens": 10, "input_tokens": 5, "output_tokens": 5}, + "session_id": "mock-default" + }), flush=True) + +if __name__ == "__main__": + main() diff --git a/tests/test_mma_concurrent_tracks_sim.py b/tests/test_mma_concurrent_tracks_sim.py new file mode 100644 index 0000000..819ee6d --- /dev/null +++ b/tests/test_mma_concurrent_tracks_sim.py @@ -0,0 +1,135 @@ +import pytest +import time +import sys +import os + +# Ensure project root is in path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) + +from src import api_hook_client + +def _poll_mma_status(client, timeout, condition, label): + """Poll get_mma_status() until condition(status) is True or timeout.""" + last_status = {} + for i in range(timeout): + status = client.get_mma_status() or {} + if condition(status): + return True, status + last_status = status + time.sleep(1) + return False, last_status + +@pytest.mark.integration +@pytest.mark.timeout(300) +def test_mma_concurrent_tracks_execution(live_gui) -> None: + """ + Stress test for concurrent MMA track execution. + Verifies that starting multiple tracks simultaneously doesn't cause crashes + and that workers from both tracks are processed. + """ + client = api_hook_client.ApiHookClient() + assert client.wait_for_server(timeout=15), "Hook server did not start" + + # 1. Setup provider to custom mock + mock_path = os.path.abspath("tests/mock_concurrent_mma.py") + client.set_value('current_provider', 'gemini_cli') + client.set_value('gcli_path', f'"{sys.executable}" "{mock_path}"') + client.click('btn_project_save') + time.sleep(1.0) + + # 2. Plan Epic to generate tracks + print("[SIM] Planning Epic...") + client.set_value('mma_epic_input', 'PATH: Epic Initialization') + client.click('btn_mma_plan_epic') + + ok, status = _poll_mma_status(client, 60, + lambda s: len(s.get('proposed_tracks', [])) >= 2, "plan-epic") + assert ok, f"Proposed tracks not found: {status.get('proposed_tracks')}" + print(f"[SIM] Found {len(status['proposed_tracks'])} proposed tracks.") + + # 3. Accept tracks and wait for them to be created + print("[SIM] Accepting tracks...") + client.click('btn_mma_accept_tracks') + ok, status = _poll_mma_status(client, 30, + lambda s: len(s.get('tracks', [])) >= 2, "accept-tracks") + assert ok, "Tracks not created in project" + + tracks = status.get('tracks', []) + track_a_id = next(t['id'] for t in tracks if 'track-a' in t['id'] or 'Track A' in t['title']) + track_b_id = next(t['id'] for t in tracks if 'track-b' in t['id'] or 'Track B' in t['title']) + print(f"[SIM] Track IDs: A={track_a_id}, B={track_b_id}") + + # 4. Start BOTH tracks concurrently + print(f"[SIM] Starting Track A ({track_a_id})...") + client.click('btn_mma_start_track', user_data=track_a_id) + + # Tiny sleep to allow the first one to initialize its engine + time.sleep(0.5) + + print(f"[SIM] Starting Track B ({track_b_id})...") + client.click('btn_mma_start_track', user_data=track_b_id) + + # 5. Verify workers from BOTH tracks appear in mma_streams + print("[SIM] Verifying concurrent worker activity...") + seen_a = False + seen_b = False + + for i in range(40): + res = client.get_mma_workers() or {} + workers = res.get('workers', {}) + stream_ids = workers.keys() + + if any("ticket-A-1" in sid for sid in stream_ids): + seen_a = True + if any("ticket-B-1" in sid for sid in stream_ids): + seen_b = True + + if seen_a and seen_b: + print(f"[SIM] SUCCESS: Observed workers from both tracks at t={i}s") + break + + time.sleep(1) + if i % 5 == 0: + print(f"[SIM] t={i}s: seen_a={seen_a}, seen_b={seen_b}, active_streams={list(stream_ids)}") + + assert seen_a, "Worker from Track A never appeared in mma_streams" + assert seen_b, "Worker from Track B never appeared in mma_streams" + + # 6. Wait for both tracks to complete + print("[SIM] Waiting for tracks to complete...") + # Each track has 1 ticket in our mock. + completed_a = False + completed_b = False + + for i in range(60): + res = client.get_mma_workers() or {} + workers = res.get('workers', {}) + + # Check stream content for completion marker from our mock + for sid, text in workers.items(): + if "ticket-A-1" in sid: + if "Done." in text: + completed_a = True + else: + if i % 10 == 0: print(f"[SIM] t={i}s: Stream A: {text[:50]}...") + if "ticket-B-1" in sid: + if "Done." in text: + completed_b = True + else: + if i % 10 == 0: print(f"[SIM] t={i}s: Stream B: {text[:50]}...") + + if completed_a and completed_b: + print(f"[SIM] Both tracks completed at t={i}s") + break + time.sleep(1) + + assert completed_a, "Track A did not complete" + assert completed_b, "Track B did not complete" + + # 7. Final stability check - ensure no crashes occurred in Hook Server + status = client.get_mma_status() + assert status is not None + assert status.get('mma_status') in ['done', 'idle', 'running'] + + print("[SIM] Concurrent MMA tracks stress test PASSED.") diff --git a/tests/test_mma_step_mode_sim.py b/tests/test_mma_step_mode_sim.py index 3f52f4f..f91854c 100644 --- a/tests/test_mma_step_mode_sim.py +++ b/tests/test_mma_step_mode_sim.py @@ -56,12 +56,10 @@ def test_mma_step_mode_approval_flow(live_gui) -> None: tickets = status.get('active_tickets', []) tid = tickets[0]['id'] - # 4. Attempt to approve (THIS SHOULD FAIL OR DO NOTHING CURRENTLY) + # 4. Attempt to approve print(f"[SIM] Attempting to approve ticket {tid}...") - # We'll try to use mutate_mma_dag to set status to in_progress - # (Note: this uses the bugged '_mutate_dag' endpoint internally if not fixed) - res = client.mutate_mma_dag({"ticket_id": tid, "status": "in_progress"}) - print(f"[SIM] Mutate result: {res}") + res = client.approve_mma_ticket(tid) + print(f"[SIM] Approve result: {res}") # 5. Verify it moved to in_progress ok, status = _poll_mma_status(client, timeout=10, label="verify-in-progress",