From 635ca5523d242ae2e369bc70a633a3ac4a9cfa0b Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sat, 27 Jun 2026 13:35:05 -0400 Subject: [PATCH] fix(mma_concurrent_tracks): partial fix for production+mock regression This test was failing for multiple stacked reasons. Fixed the ones I could identify but the test still does not pass (the bg_task for the second track does not run, suggesting a deeper integration issue). Fixes: 1. src/app_controller.py: _start_track_logic_result and _cb_plan_epic both mutated the frozen ProjectContext dataclass returned by flat_config() via flat.setdefault('files', {})['paths'] = .... The flat_config() return type was changed from dict[str, Any] to a frozen @dataclass ProjectContext by cruft_elimination Phase 2 (in 0d2a9b5e), but the consumers were never updated. Fix: call flat.to_dict() to get a mutable dict before mutation. 2. src/app_controller.py: _start_track_logic_result iterated over sorted_tickets_data expecting dicts but conductor_tech_lead.topological_sort() returns list[Ticket]. So t_data['id'] raised 'Ticket' object is not subscriptable. Fix: use Ticket attribute access (t_data.id, etc.). 3. tests/mock_concurrent_mma.py: The mock was not handling the --resume session-id case that the gemini_cli_adapter uses for subsequent calls. The mock's first call returns the epic, but the second call (--resume mock-epic) fell to the default case. Fix: parse --resume arg from sys.argv and route to per-track sprint-ticket response based on a persistent call counter. Known remaining issue: only one sprint-ticket mock call is observed in the test log; the second track's _start_track_logic does not appear to call the mock. Could be a deeper integration issue in the test sandbox or in the _cb_accept_tracks._bg_task loop. Test still fails at line 66. --- src/app_controller.py | 20 +++++--- tests/mock_concurrent_mma.py | 96 +++++++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 25 deletions(-) diff --git a/src/app_controller.py b/src/app_controller.py index 0e3100b5..83164911 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -4588,6 +4588,8 @@ class AppController: history = orchestrator_pm.get_track_history_summary() proj = project_manager.load_project(self.active_project_path) flat = project_manager.flat_config(self.project) + # flat_config returns a frozen ProjectContext; convert to dict for mutation. + flat = flat.to_dict() if hasattr(flat, "to_dict") else dict(flat) flat.setdefault("files", {})["paths"] = self.context_files file_items = aggregate.build_file_items(Path(self.active_project_root), flat.get("files", {}).get("paths", [])) @@ -4778,16 +4780,18 @@ class AppController: self.ai_status = "Phase 2: Sorting tickets..." sort_result = self._topological_sort_tickets_result(raw_tickets, title) sorted_tickets_data = sort_result.data - # 3. Create Track and Ticket objects + # 3. Create Track and Ticket objects (sorted_tickets_data is list[Ticket]) tickets = [] for t_data in sorted_tickets_data: + # Use Ticket attribute access; topological_sort returns Ticket objects, + # not dicts. Re-wrap to ensure all expected fields are populated. ticket = Ticket( - id=t_data["id"], - description=t_data.get("description") or t_data.get("goal", "No 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) + id=t_data.id, + description=t_data.description or "No description", + status=t_data.status, + assigned_to=t_data.assigned_to, + depends_on=list(t_data.depends_on), + step_mode=t_data.step_mode, ) tickets.append(ticket) track_id = f"track_{uuid.uuid5(uuid.NAMESPACE_DNS, f'{self.active_project_path}_{title}').hex[:12]}" @@ -4810,6 +4814,8 @@ class AppController: # 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) + # flat_config returns a frozen ProjectContext; convert to dict for mutation. + flat = flat.to_dict() if hasattr(flat, "to_dict") else dict(flat) flat.setdefault("files", {})["paths"] = self.context_files sys.stderr.write(f"[DEBUG] _start_track_logic: Aggregating context for {track_id}...\n") sys.stderr.flush() diff --git a/tests/mock_concurrent_mma.py b/tests/mock_concurrent_mma.py index 2c31cba8..467268bd 100644 --- a/tests/mock_concurrent_mma.py +++ b/tests/mock_concurrent_mma.py @@ -2,13 +2,51 @@ import sys import json import os +# Persistent call counter (file-based so the mock survives across subprocess +# invocations). The mock gemini CLI is a short-lived subprocess invoked once +# per send() call; the session_id set by the adapter (--resume) tells the +# mock which response to return. Path is relative to the repo root (the test +# fixture sets subprocess cwd to tests/artifacts/live_gui_workspace_/ but +# the mock is invoked from the project root by its absolute path). +_CALL_COUNT_FILE = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "artifacts", ".mock_concurrent_mma_call_count", +) +_CALL_COUNT_FILE = os.path.abspath(_CALL_COUNT_FILE) + +def _next_call_count() -> int: + """Atomically increment and return the per-test mock call count.""" + try: + n = 0 + if os.path.exists(_CALL_COUNT_FILE): + with open(_CALL_COUNT_FILE, "r", encoding="utf-8") as f: + n = int((f.read() or "0").strip() or "0") + n += 1 + os.makedirs(os.path.dirname(_CALL_COUNT_FILE), exist_ok=True) + with open(_CALL_COUNT_FILE, "w", encoding="utf-8") as f: + f.write(str(n)) + return n + except Exception: + return 0 + def main() -> None: # Read prompt from stdin try: prompt = sys.stdin.read() except Exception: prompt = "" - + + # Detect the session we're "resuming" via --resume arg (set by the + # gemini_cli_adapter on subsequent calls). + session_id = "" + argv = sys.argv[1:] + if "--resume" in argv: + i = argv.index("--resume") + if i + 1 < len(argv): + session_id = argv[i + 1] + + call_n = _next_call_count() + # 1. Epic Initialization if 'PATH: Epic Initialization' in prompt: mock_response = [ @@ -29,30 +67,36 @@ def main() -> None: return # 2. Sprint Planning (different tickets for different tracks) + # The gemini_cli_adapter reuses the session_id from the epic call + # (mock-epic) for all subsequent calls. We use the global call counter + # to cycle through Track A (call #2) and Track B (call #3). + if session_id == "mock-epic" and call_n == 2: + _emit_sprint_ticket("A") + return + if session_id == "mock-epic" and call_n == 3: + _emit_sprint_ticket("B") + return + if "mock-sprint-A" in session_id: + _emit_sprint_ticket("A") + return + if "mock-sprint-B" in session_id: + _emit_sprint_ticket("B") + return 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) + _emit_sprint_ticket(track_label) return # 3. Worker Execution - if 'You are assigned to Ticket' in prompt: - # Extract ticket ID + if 'You are assigned to Ticket' in prompt or session_id.startswith("mock-worker-"): import re match = re.search(r'Ticket (ticket-[A-Ba-b]-1)', prompt, re.IGNORECASE) - tid = match.group(1) if match else "unknown" + if match: + tid = match.group(1) + elif session_id.startswith("mock-worker-"): + tid = session_id[len("mock-worker-"):] + else: + tid = "unknown" print(json.dumps({ "type": "message", @@ -80,5 +124,21 @@ def main() -> None: "session_id": "mock-default" }), flush=True) +def _emit_sprint_ticket(track_label: str) -> None: + 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) + if __name__ == "__main__": main()