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.
This commit is contained in:
+13
-7
@@ -4588,6 +4588,8 @@ class AppController:
|
|||||||
history = orchestrator_pm.get_track_history_summary()
|
history = orchestrator_pm.get_track_history_summary()
|
||||||
proj = project_manager.load_project(self.active_project_path)
|
proj = project_manager.load_project(self.active_project_path)
|
||||||
flat = project_manager.flat_config(self.project)
|
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
|
flat.setdefault("files", {})["paths"] = self.context_files
|
||||||
file_items = aggregate.build_file_items(Path(self.active_project_root), flat.get("files", {}).get("paths", []))
|
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..."
|
self.ai_status = "Phase 2: Sorting tickets..."
|
||||||
sort_result = self._topological_sort_tickets_result(raw_tickets, title)
|
sort_result = self._topological_sort_tickets_result(raw_tickets, title)
|
||||||
sorted_tickets_data = sort_result.data
|
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 = []
|
tickets = []
|
||||||
for t_data in sorted_tickets_data:
|
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(
|
ticket = Ticket(
|
||||||
id=t_data["id"],
|
id=t_data.id,
|
||||||
description=t_data.get("description") or t_data.get("goal", "No description"),
|
description=t_data.description or "No description",
|
||||||
status=t_data.get("status", "todo"),
|
status=t_data.status,
|
||||||
assigned_to=t_data.get("assigned_to", "unassigned"),
|
assigned_to=t_data.assigned_to,
|
||||||
depends_on=t_data.get("depends_on", []),
|
depends_on=list(t_data.depends_on),
|
||||||
step_mode=t_data.get("step_mode", False)
|
step_mode=t_data.step_mode,
|
||||||
)
|
)
|
||||||
tickets.append(ticket)
|
tickets.append(ticket)
|
||||||
track_id = f"track_{uuid.uuid5(uuid.NAMESPACE_DNS, f'{self.active_project_path}_{title}').hex[:12]}"
|
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
|
# Use current full markdown context for the track execution
|
||||||
track_id_param = track.id
|
track_id_param = track.id
|
||||||
flat = project_manager.flat_config(self.project, self.active_discussion, track_id=track_id_param)
|
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
|
flat.setdefault("files", {})["paths"] = self.context_files
|
||||||
sys.stderr.write(f"[DEBUG] _start_track_logic: Aggregating context for {track_id}...\n")
|
sys.stderr.write(f"[DEBUG] _start_track_logic: Aggregating context for {track_id}...\n")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
|
|||||||
@@ -2,13 +2,51 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import os
|
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_<ts>/ 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:
|
def main() -> None:
|
||||||
# Read prompt from stdin
|
# Read prompt from stdin
|
||||||
try:
|
try:
|
||||||
prompt = sys.stdin.read()
|
prompt = sys.stdin.read()
|
||||||
except Exception:
|
except Exception:
|
||||||
prompt = ""
|
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
|
# 1. Epic Initialization
|
||||||
if 'PATH: Epic Initialization' in prompt:
|
if 'PATH: Epic Initialization' in prompt:
|
||||||
mock_response = [
|
mock_response = [
|
||||||
@@ -29,30 +67,36 @@ def main() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 2. Sprint Planning (different tickets for different tracks)
|
# 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:
|
if 'generate the implementation tickets' in prompt:
|
||||||
track_label = "A" if "Track A" in prompt else "B"
|
track_label = "A" if "Track A" in prompt else "B"
|
||||||
mock_response = [
|
_emit_sprint_ticket(track_label)
|
||||||
{"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
|
return
|
||||||
|
|
||||||
# 3. Worker Execution
|
# 3. Worker Execution
|
||||||
if 'You are assigned to Ticket' in prompt:
|
if 'You are assigned to Ticket' in prompt or session_id.startswith("mock-worker-"):
|
||||||
# Extract ticket ID
|
|
||||||
import re
|
import re
|
||||||
match = re.search(r'Ticket (ticket-[A-Ba-b]-1)', prompt, re.IGNORECASE)
|
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({
|
print(json.dumps({
|
||||||
"type": "message",
|
"type": "message",
|
||||||
@@ -80,5 +124,21 @@ def main() -> None:
|
|||||||
"session_id": "mock-default"
|
"session_id": "mock-default"
|
||||||
}), flush=True)
|
}), 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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user