WIP: I HATE PYTHON
This commit is contained in:
@@ -17,9 +17,9 @@ import re
|
||||
import glob
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import Any, cast
|
||||
import summarize
|
||||
import project_manager
|
||||
from file_cache import ASTParser
|
||||
from src import summarize
|
||||
from src import project_manager
|
||||
from src.file_cache import ASTParser
|
||||
|
||||
def find_next_increment(output_dir: Path, namespace: str) -> int:
|
||||
pattern = re.compile(rf"^{re.escape(namespace)}_(\d+)\.md$")
|
||||
|
||||
@@ -23,14 +23,14 @@ import threading
|
||||
import requests # type: ignore[import-untyped]
|
||||
from typing import Optional, Callable, Any, List, Union, cast, Iterable
|
||||
import os
|
||||
import project_manager
|
||||
import file_cache
|
||||
import mcp_client
|
||||
from src import project_manager
|
||||
from src import file_cache
|
||||
from src import mcp_client
|
||||
import anthropic
|
||||
from gemini_cli_adapter import GeminiCliAdapter as GeminiCliAdapter
|
||||
from src.gemini_cli_adapter import GeminiCliAdapter as GeminiCliAdapter
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from events import EventEmitter
|
||||
from src.events import EventEmitter
|
||||
|
||||
_provider: str = "gemini"
|
||||
_model: str = "gemini-2.5-flash-lite"
|
||||
@@ -779,6 +779,9 @@ def _send_gemini_cli(md_content: str, user_message: str, base_dir: str,
|
||||
qa_callback: Optional[Callable[[str], str]] = None,
|
||||
stream_callback: Optional[Callable[[str], None]] = None) -> str:
|
||||
global _gemini_cli_adapter
|
||||
import sys
|
||||
sys.stderr.write(f"[DEBUG] _send_gemini_cli running in module {__name__}, adapter is {_gemini_cli_adapter}\n")
|
||||
sys.stderr.flush()
|
||||
try:
|
||||
if _gemini_cli_adapter is None:
|
||||
_gemini_cli_adapter = GeminiCliAdapter(binary_path="gemini")
|
||||
|
||||
@@ -5,7 +5,7 @@ import uuid
|
||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
from typing import Any
|
||||
import logging
|
||||
import session_logger
|
||||
from src import session_logger
|
||||
|
||||
def _get_app_attr(app: Any, name: str, default: Any = None) -> Any:
|
||||
if hasattr(app, name):
|
||||
@@ -44,7 +44,7 @@ class HookHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"status": "ok"}).encode("utf-8"))
|
||||
elif self.path == "/api/project":
|
||||
import project_manager
|
||||
from src import project_manager
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
from typing import Any, List, Dict, Optional, Tuple, Callable
|
||||
from typing import Any, List, Dict, Optional, Tuple, Callable, Union, cast
|
||||
from pathlib import Path
|
||||
import json
|
||||
import uuid
|
||||
@@ -20,24 +20,24 @@ from pydantic import BaseModel
|
||||
from src import events
|
||||
from src import session_logger
|
||||
from src import project_manager
|
||||
from src.performance_monitor import PerformanceMonitor
|
||||
from src.models import Track, Ticket, load_config, parse_history_entries, DISC_ROLES, AGENT_TOOL_NAMES, CONFIG_PATH
|
||||
from src import performance_monitor
|
||||
from src import models
|
||||
from src.log_registry import LogRegistry
|
||||
from src.log_pruner import LogPruner
|
||||
from src.file_cache import ASTParser
|
||||
import ai_client
|
||||
import shell_runner
|
||||
import mcp_client
|
||||
import aggregate
|
||||
import orchestrator_pm
|
||||
import conductor_tech_lead
|
||||
import cost_tracker
|
||||
import multi_agent_conductor
|
||||
from src import ai_client
|
||||
from src import shell_runner
|
||||
from src import mcp_client
|
||||
from src import aggregate
|
||||
from src import orchestrator_pm
|
||||
from src import conductor_tech_lead
|
||||
from src import cost_tracker
|
||||
from src import multi_agent_conductor
|
||||
from src import theme
|
||||
from ai_client import ProviderError
|
||||
from src.ai_client import ProviderError
|
||||
|
||||
def save_config(config: dict[str, Any]) -> None:
|
||||
with open(CONFIG_PATH, "wb") as f:
|
||||
with open(models.CONFIG_PATH, "wb") as f:
|
||||
tomli_w.dump(config, f)
|
||||
|
||||
def hide_tk_root() -> Tk:
|
||||
@@ -141,12 +141,11 @@ class AppController:
|
||||
self.files: List[str] = []
|
||||
self.screenshots: List[str] = []
|
||||
|
||||
self.event_queue: events.AsyncEventQueue = events.AsyncEventQueue()
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self.event_queue: events.SyncEventQueue = events.SyncEventQueue()
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
self.tracks: List[Dict[str, Any]] = []
|
||||
self.active_track: Optional[Track] = None
|
||||
self.active_track: Optional[models.Track] = None
|
||||
self.active_tickets: List[Dict[str, Any]] = []
|
||||
self.mma_streams: Dict[str, str] = {}
|
||||
self.mma_status: str = "idle"
|
||||
@@ -169,7 +168,7 @@ class AppController:
|
||||
"Tier 4": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"},
|
||||
}
|
||||
|
||||
self.perf_monitor: PerformanceMonitor = PerformanceMonitor()
|
||||
self.perf_monitor: performance_monitor.PerformanceMonitor = performance_monitor.PerformanceMonitor()
|
||||
self._pending_gui_tasks: List[Dict[str, Any]] = []
|
||||
self._api_event_queue: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -278,6 +277,40 @@ class AppController:
|
||||
self.prior_session_entries: List[Dict[str, Any]] = []
|
||||
self.test_hooks_enabled: bool = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1")
|
||||
self.ui_manual_approve: bool = False
|
||||
|
||||
self._settable_fields: Dict[str, str] = {
|
||||
'ai_input': 'ui_ai_input',
|
||||
'project_git_dir': 'ui_project_git_dir',
|
||||
'auto_add_history': 'ui_auto_add_history',
|
||||
'disc_new_name_input': 'ui_disc_new_name_input',
|
||||
'project_main_context': 'ui_project_main_context',
|
||||
'gcli_path': 'ui_gemini_cli_path',
|
||||
'output_dir': 'ui_output_dir',
|
||||
'files_base_dir': 'ui_files_base_dir',
|
||||
'ai_status': 'ai_status',
|
||||
'ai_response': 'ai_response',
|
||||
'active_discussion': 'active_discussion',
|
||||
'current_provider': 'current_provider',
|
||||
'current_model': 'current_model',
|
||||
'token_budget_pct': '_token_budget_pct',
|
||||
'token_budget_current': '_token_budget_current',
|
||||
'token_budget_label': '_token_budget_label',
|
||||
'show_confirm_modal': 'show_confirm_modal',
|
||||
'mma_epic_input': 'ui_epic_input',
|
||||
'mma_status': 'mma_status',
|
||||
'mma_active_tier': 'active_tier',
|
||||
'ui_new_track_name': 'ui_new_track_name',
|
||||
'ui_new_track_desc': 'ui_new_track_desc',
|
||||
'manual_approve': 'ui_manual_approve'
|
||||
}
|
||||
|
||||
self._gettable_fields = dict(self._settable_fields)
|
||||
self._gettable_fields.update({
|
||||
'ui_focus_agent': 'ui_focus_agent',
|
||||
'active_discussion': 'active_discussion',
|
||||
'_track_discussion_active': '_track_discussion_active'
|
||||
})
|
||||
|
||||
self._init_actions()
|
||||
|
||||
def _init_actions(self) -> None:
|
||||
@@ -302,6 +335,14 @@ class AppController:
|
||||
'_test_callback_func_write_to_file': self._test_callback_func_write_to_file
|
||||
}
|
||||
|
||||
def _update_gcli_adapter(self, path: str) -> None:
|
||||
sys.stderr.write(f"[DEBUG] _update_gcli_adapter called with: {path}\n")
|
||||
sys.stderr.flush()
|
||||
if not ai_client._gemini_cli_adapter:
|
||||
ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=str(path))
|
||||
else:
|
||||
ai_client._gemini_cli_adapter.binary_path = str(path)
|
||||
|
||||
def _process_pending_gui_tasks(self) -> None:
|
||||
if not self._pending_gui_tasks:
|
||||
return
|
||||
@@ -336,6 +377,8 @@ class AppController:
|
||||
else:
|
||||
self.ai_response = text
|
||||
self.ai_status = payload.get("status", "done")
|
||||
sys.stderr.write(f"[DEBUG] Updated ai_status to: {self.ai_status}\n")
|
||||
sys.stderr.flush()
|
||||
self._trigger_blink = True
|
||||
if not stream_id:
|
||||
self._token_stats_dirty = True
|
||||
@@ -370,8 +413,8 @@ class AppController:
|
||||
if track_data:
|
||||
tickets = []
|
||||
for t_data in self.active_tickets:
|
||||
tickets.append(Ticket(**t_data))
|
||||
self.active_track = Track(
|
||||
tickets.append(models.Ticket(**t_data))
|
||||
self.active_track = models.Track(
|
||||
id=track_data.get("id"),
|
||||
description=track_data.get("title", ""),
|
||||
tickets=tickets
|
||||
@@ -379,17 +422,20 @@ class AppController:
|
||||
elif action == "set_value":
|
||||
item = task.get("item")
|
||||
value = task.get("value")
|
||||
sys.stderr.write(f"[DEBUG] Processing set_value: {item}={value}\n")
|
||||
sys.stderr.flush()
|
||||
if item in self._settable_fields:
|
||||
attr_name = self._settable_fields[item]
|
||||
setattr(self, attr_name, value)
|
||||
sys.stderr.write(f"[DEBUG] Set {attr_name} to {value}\n")
|
||||
sys.stderr.flush()
|
||||
if item == "gcli_path":
|
||||
if not ai_client._gemini_cli_adapter:
|
||||
ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=str(value))
|
||||
else:
|
||||
ai_client._gemini_cli_adapter.binary_path = str(value)
|
||||
self._update_gcli_adapter(str(value))
|
||||
elif action == "click":
|
||||
item = task.get("item")
|
||||
user_data = task.get("user_data")
|
||||
sys.stderr.write(f"[DEBUG] Processing click: {item} (user_data={user_data})\n")
|
||||
sys.stderr.flush()
|
||||
if item == "btn_project_new_automated":
|
||||
self._cb_new_project_automated(user_data)
|
||||
elif item == "btn_mma_load_track":
|
||||
@@ -449,6 +495,9 @@ class AppController:
|
||||
if "dialog_container" in task:
|
||||
task["dialog_container"][0] = spawn_dlg
|
||||
except Exception as e:
|
||||
import traceback
|
||||
sys.stderr.write(f"[DEBUG] Error executing GUI task: {e}\n{traceback.format_exc()}\n")
|
||||
sys.stderr.flush()
|
||||
print(f"Error executing GUI task: {e}")
|
||||
|
||||
def _process_pending_history_adds(self) -> None:
|
||||
@@ -505,7 +554,7 @@ class AppController:
|
||||
|
||||
def init_state(self):
|
||||
"""Initializes the application state from configurations."""
|
||||
self.config = load_config()
|
||||
self.config = models.load_config()
|
||||
ai_cfg = self.config.get("ai", {})
|
||||
self._current_provider = ai_cfg.get("provider", "gemini")
|
||||
self._current_model = ai_cfg.get("model", "gemini-2.5-flash-lite")
|
||||
@@ -523,11 +572,11 @@ class AppController:
|
||||
self.screenshots = list(self.project.get("screenshots", {}).get("paths", []))
|
||||
|
||||
disc_sec = self.project.get("discussion", {})
|
||||
self.disc_roles = list(disc_sec.get("roles", list(DISC_ROLES)))
|
||||
self.disc_roles = list(disc_sec.get("roles", list(models.DISC_ROLES)))
|
||||
self.active_discussion = disc_sec.get("active", "main")
|
||||
disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
|
||||
with self._disc_entries_lock:
|
||||
self.disc_entries = parse_history_entries(disc_data.get("history", []), self.disc_roles)
|
||||
self.disc_entries = models.parse_history_entries(disc_data.get("history", []), self.disc_roles)
|
||||
|
||||
# UI state
|
||||
self.ui_output_dir = self.project.get("output", {}).get("output_dir", "./md_gen")
|
||||
@@ -538,6 +587,7 @@ class AppController:
|
||||
self.ui_project_main_context = proj_meta.get("main_context", "")
|
||||
self.ui_project_system_prompt = proj_meta.get("system_prompt", "")
|
||||
self.ui_gemini_cli_path = self.project.get("gemini_cli", {}).get("binary_path", "gemini")
|
||||
self._update_gcli_adapter(self.ui_gemini_cli_path)
|
||||
self.ui_word_wrap = proj_meta.get("word_wrap", True)
|
||||
self.ui_summary_only = proj_meta.get("summary_only", False)
|
||||
self.ui_auto_add_history = disc_sec.get("auto_add", False)
|
||||
@@ -562,7 +612,7 @@ class AppController:
|
||||
self.show_windows = {k: saved.get(k, v) for k, v in _default_windows.items()}
|
||||
|
||||
agent_tools_cfg = self.project.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 models.AGENT_TOOL_NAMES}
|
||||
|
||||
label = self.project.get("project", {}).get("name", "")
|
||||
session_logger.open_session(label=label)
|
||||
@@ -622,8 +672,10 @@ class AppController:
|
||||
"""Asynchronously prunes old insignificant logs on startup."""
|
||||
def run_prune() -> None:
|
||||
try:
|
||||
registry = LogRegistry("logs/log_registry.toml")
|
||||
pruner = LogPruner(registry, "logs")
|
||||
from src import log_registry
|
||||
from src import log_pruner
|
||||
registry = log_registry.LogRegistry("logs/log_registry.toml")
|
||||
pruner = log_pruner.LogPruner(registry, "logs")
|
||||
pruner.prune()
|
||||
except Exception as e:
|
||||
print(f"Error during log pruning: {e}")
|
||||
@@ -646,26 +698,28 @@ class AppController:
|
||||
self.models_thread.start()
|
||||
|
||||
def start_services(self, app: Any = None):
|
||||
"""Starts background threads and async event loop."""
|
||||
"""Starts background threads."""
|
||||
sys.stderr.write("[DEBUG] AppController.start_services called\n")
|
||||
sys.stderr.flush()
|
||||
self._prune_old_logs()
|
||||
self._init_ai_and_hooks(app)
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
||||
self._loop_thread.start()
|
||||
sys.stderr.write(f"[DEBUG] _loop_thread started: {self._loop_thread.ident}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stops background threads and cleans up resources."""
|
||||
import ai_client
|
||||
from src import ai_client
|
||||
ai_client.cleanup()
|
||||
if hasattr(self, 'hook_server') and self.hook_server:
|
||||
self.hook_server.stop()
|
||||
if self._loop and self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
self.event_queue.put("shutdown", None)
|
||||
if self._loop_thread and self._loop_thread.is_alive():
|
||||
self._loop_thread.join(timeout=2.0)
|
||||
|
||||
def _init_ai_and_hooks(self, app: Any = None) -> None:
|
||||
import api_hooks
|
||||
from src import api_hooks
|
||||
ai_client.set_provider(self._current_provider, self._current_model)
|
||||
if self._current_provider == "gemini_cli":
|
||||
if not ai_client._gemini_cli_adapter:
|
||||
@@ -681,77 +735,37 @@ class AppController:
|
||||
ai_client.events.on("response_received", lambda **kw: self._on_api_event("response_received", **kw))
|
||||
ai_client.events.on("tool_execution", lambda **kw: self._on_api_event("tool_execution", **kw))
|
||||
|
||||
self._settable_fields: Dict[str, str] = {
|
||||
'ai_input': 'ui_ai_input',
|
||||
'project_git_dir': 'ui_project_git_dir',
|
||||
'auto_add_history': 'ui_auto_add_history',
|
||||
'disc_new_name_input': 'ui_disc_new_name_input',
|
||||
'project_main_context': 'ui_project_main_context',
|
||||
'gcli_path': 'ui_gemini_cli_path',
|
||||
'output_dir': 'ui_output_dir',
|
||||
'files_base_dir': 'ui_files_base_dir',
|
||||
'ai_status': 'ai_status',
|
||||
'ai_response': 'ai_response',
|
||||
'active_discussion': 'active_discussion',
|
||||
'current_provider': 'current_provider',
|
||||
'current_model': 'current_model',
|
||||
'token_budget_pct': '_token_budget_pct',
|
||||
'token_budget_current': '_token_budget_current',
|
||||
'token_budget_label': '_token_budget_label',
|
||||
'show_confirm_modal': 'show_confirm_modal',
|
||||
'mma_epic_input': 'ui_epic_input',
|
||||
'mma_status': 'mma_status',
|
||||
'mma_active_tier': 'active_tier',
|
||||
'ui_new_track_name': 'ui_new_track_name',
|
||||
'ui_new_track_desc': 'ui_new_track_desc',
|
||||
'manual_approve': 'ui_manual_approve'
|
||||
}
|
||||
|
||||
self._gettable_fields = dict(self._settable_fields)
|
||||
self._gettable_fields.update({
|
||||
'ui_focus_agent': 'ui_focus_agent',
|
||||
'active_discussion': 'active_discussion',
|
||||
'_track_discussion_active': '_track_discussion_active'
|
||||
})
|
||||
|
||||
self.hook_server = api_hooks.HookServer(app if app else self)
|
||||
self.hook_server.start()
|
||||
|
||||
def _run_event_loop(self):
|
||||
"""Internal loop runner."""
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.create_task(self._process_event_queue())
|
||||
|
||||
# Fallback: process queues even if GUI thread is idling/stuck (or in headless mode)
|
||||
async def queue_fallback() -> None:
|
||||
def queue_fallback() -> None:
|
||||
while True:
|
||||
try:
|
||||
# These methods are normally called by the GUI thread,
|
||||
# but we call them here as a fallback for headless/background operations.
|
||||
# The methods themselves are expected to be thread-safe or handle locks.
|
||||
# Since they are on 'self' (the controller), and App delegates to them,
|
||||
# we need to make sure we don't double-process if App is also calling them.
|
||||
# However, _pending_gui_tasks uses a lock, so it's safe.
|
||||
if hasattr(self, '_process_pending_gui_tasks'):
|
||||
self._process_pending_gui_tasks()
|
||||
if hasattr(self, '_process_pending_history_adds'):
|
||||
self._process_pending_history_adds()
|
||||
except: pass
|
||||
await asyncio.sleep(0.1)
|
||||
time.sleep(0.1)
|
||||
|
||||
fallback_thread = threading.Thread(target=queue_fallback, daemon=True)
|
||||
fallback_thread.start()
|
||||
self._process_event_queue()
|
||||
|
||||
self._loop.create_task(queue_fallback())
|
||||
self._loop.run_forever()
|
||||
|
||||
async def _process_event_queue(self) -> None:
|
||||
"""Listens for and processes events from the AsyncEventQueue."""
|
||||
sys.stderr.write("[DEBUG] _process_event_queue started\n")
|
||||
def _process_event_queue(self) -> None:
|
||||
"""Listens for and processes events from the SyncEventQueue."""
|
||||
sys.stderr.write("[DEBUG] _process_event_queue entered\n")
|
||||
sys.stderr.flush()
|
||||
while True:
|
||||
event_name, payload = await self.event_queue.get()
|
||||
event_name, payload = self.event_queue.get()
|
||||
sys.stderr.write(f"[DEBUG] _process_event_queue got event: {event_name}\n")
|
||||
sys.stderr.flush()
|
||||
if event_name == "shutdown":
|
||||
break
|
||||
if event_name == "user_request":
|
||||
self._loop.run_in_executor(None, self._handle_request_event, payload)
|
||||
threading.Thread(target=self._handle_request_event, args=(payload,), daemon=True).start()
|
||||
elif event_name == "response":
|
||||
with self._pending_gui_tasks_lock:
|
||||
self._pending_gui_tasks.append({
|
||||
@@ -792,6 +806,10 @@ class AppController:
|
||||
ai_client.set_custom_system_prompt("\n\n".join(csp))
|
||||
ai_client.set_model_params(self.temperature, self.max_tokens, self.history_trunc_limit)
|
||||
ai_client.set_agent_tools(self.ui_agent_tools)
|
||||
# Force update adapter path right before send to bypass potential duplication issues
|
||||
self._update_gcli_adapter(self.ui_gemini_cli_path)
|
||||
sys.stderr.write(f"[DEBUG] Calling ai_client.send with provider={ai_client.get_provider()}, model={self.current_model}, gcli_path={self.ui_gemini_cli_path}\n")
|
||||
sys.stderr.flush()
|
||||
try:
|
||||
resp = ai_client.send(
|
||||
event.stable_md,
|
||||
@@ -804,27 +822,20 @@ class AppController:
|
||||
pre_tool_callback=self._confirm_and_run,
|
||||
qa_callback=ai_client.run_tier4_analysis
|
||||
)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.event_queue.put("response", {"text": resp, "status": "done", "role": "AI"}),
|
||||
self._loop
|
||||
)
|
||||
except ProviderError as e:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.event_queue.put("response", {"text": e.ui_message(), "status": "error", "role": "Vendor API"}),
|
||||
self._loop
|
||||
)
|
||||
self.event_queue.put("response", {"text": resp, "status": "done", "role": "AI"})
|
||||
except ai_client.ProviderError as e:
|
||||
sys.stderr.write(f"[DEBUG] _handle_request_event ai_client.ProviderError: {e.ui_message()}\n")
|
||||
sys.stderr.flush()
|
||||
self.event_queue.put("response", {"text": e.ui_message(), "status": "error", "role": "Vendor API"})
|
||||
except Exception as e:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.event_queue.put("response", {"text": f"ERROR: {e}", "status": "error", "role": "System"}),
|
||||
self._loop
|
||||
)
|
||||
import traceback
|
||||
sys.stderr.write(f"[DEBUG] _handle_request_event ERROR: {e}\n{traceback.format_exc()}\n")
|
||||
sys.stderr.flush()
|
||||
self.event_queue.put("response", {"text": f"ERROR: {e}", "status": "error", "role": "System"})
|
||||
|
||||
def _on_ai_stream(self, text: str) -> None:
|
||||
"""Handles streaming text from the AI."""
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.event_queue.put("response", {"text": text, "status": "streaming...", "role": "AI"}),
|
||||
self._loop
|
||||
)
|
||||
self.event_queue.put("response", {"text": text, "status": "streaming...", "role": "AI"})
|
||||
|
||||
def _on_comms_entry(self, entry: Dict[str, Any]) -> None:
|
||||
session_logger.log_comms(entry)
|
||||
@@ -866,7 +877,7 @@ class AppController:
|
||||
with self._pending_tool_calls_lock:
|
||||
self._pending_tool_calls.append({"script": script, "result": result, "ts": time.time(), "source_tier": source_tier})
|
||||
|
||||
def _on_api_event(self, event_name: str, **kwargs: Any) -> None:
|
||||
def _on_api_event(self, event_name: str = "generic_event", **kwargs: Any) -> None:
|
||||
payload = kwargs.get("payload", {})
|
||||
with self._pending_gui_tasks_lock:
|
||||
self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload})
|
||||
@@ -1083,7 +1094,7 @@ class AppController:
|
||||
},
|
||||
"usage": self.session_usage
|
||||
}
|
||||
except ProviderError as e:
|
||||
except ai_client.ProviderError as e:
|
||||
raise HTTPException(status_code=502, detail=f"AI Provider Error: {e.ui_message()}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"In-flight AI request failure: {e}")
|
||||
@@ -1207,11 +1218,11 @@ class AppController:
|
||||
self.files = list(self.project.get("files", {}).get("paths", []))
|
||||
self.screenshots = list(self.project.get("screenshots", {}).get("paths", []))
|
||||
disc_sec = self.project.get("discussion", {})
|
||||
self.disc_roles = list(disc_sec.get("roles", list(DISC_ROLES)))
|
||||
self.disc_roles = list(disc_sec.get("roles", list(models.DISC_ROLES)))
|
||||
self.active_discussion = disc_sec.get("active", "main")
|
||||
disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
|
||||
with self._disc_entries_lock:
|
||||
self.disc_entries = parse_history_entries(disc_data.get("history", []), self.disc_roles)
|
||||
self.disc_entries = models.parse_history_entries(disc_data.get("history", []), self.disc_roles)
|
||||
proj = self.project
|
||||
self.ui_output_dir = proj.get("output", {}).get("output_dir", "./md_gen")
|
||||
self.ui_files_base_dir = proj.get("files", {}).get("base_dir", ".")
|
||||
@@ -1226,7 +1237,7 @@ class AppController:
|
||||
self.ui_word_wrap = proj.get("project", {}).get("word_wrap", True)
|
||||
self.ui_summary_only = proj.get("project", {}).get("summary_only", False)
|
||||
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 models.AGENT_TOOL_NAMES}
|
||||
# MMA Tracks
|
||||
self.tracks = project_manager.get_all_tracks(self.ui_files_base_dir)
|
||||
# Restore MMA state
|
||||
@@ -1237,8 +1248,8 @@ class AppController:
|
||||
try:
|
||||
tickets = []
|
||||
for t_data in at_data.get("tickets", []):
|
||||
tickets.append(Ticket(**t_data))
|
||||
self.active_track = Track(
|
||||
tickets.append(models.Ticket(**t_data))
|
||||
self.active_track = models.Track(
|
||||
id=at_data.get("id"),
|
||||
description=at_data.get("description"),
|
||||
tickets=tickets
|
||||
@@ -1255,7 +1266,7 @@ class AppController:
|
||||
track_history = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir)
|
||||
if track_history:
|
||||
with self._disc_entries_lock:
|
||||
self.disc_entries = parse_history_entries(track_history, self.disc_roles)
|
||||
self.disc_entries = models.parse_history_entries(track_history, self.disc_roles)
|
||||
|
||||
def _cb_load_track(self, track_id: str) -> None:
|
||||
state = project_manager.load_track_state(track_id, self.ui_files_base_dir)
|
||||
@@ -1265,21 +1276,21 @@ class AppController:
|
||||
tickets = []
|
||||
for t in state.tasks:
|
||||
if isinstance(t, dict):
|
||||
tickets.append(Ticket(**t))
|
||||
tickets.append(models.Ticket(**t))
|
||||
else:
|
||||
tickets.append(t)
|
||||
self.active_track = Track(
|
||||
self.active_track = models.Track(
|
||||
id=state.metadata.id,
|
||||
description=state.metadata.name,
|
||||
tickets=tickets
|
||||
)
|
||||
# Keep dicts for UI table (or convert Ticket objects back to dicts if needed)
|
||||
# Keep dicts for UI table (or convert models.Ticket objects back to dicts if needed)
|
||||
self.active_tickets = [asdict(t) if not isinstance(t, dict) else t for t in tickets]
|
||||
# Load track-scoped history
|
||||
history = project_manager.load_track_history(track_id, self.ui_files_base_dir)
|
||||
with self._disc_entries_lock:
|
||||
if history:
|
||||
self.disc_entries = parse_history_entries(history, self.disc_roles)
|
||||
self.disc_entries = models.parse_history_entries(history, self.disc_roles)
|
||||
else:
|
||||
self.disc_entries = []
|
||||
self._recalculate_session_usage()
|
||||
@@ -1312,7 +1323,7 @@ class AppController:
|
||||
disc_sec["active"] = name
|
||||
disc_data = discussions[name]
|
||||
with self._disc_entries_lock:
|
||||
self.disc_entries = parse_history_entries(disc_data.get("history", []), self.disc_roles)
|
||||
self.disc_entries = models.parse_history_entries(disc_data.get("history", []), self.disc_roles)
|
||||
self.ai_status = f"discussion: {name}"
|
||||
|
||||
def _flush_disc_entries_to_project(self) -> None:
|
||||
@@ -1491,10 +1502,7 @@ class AppController:
|
||||
base_dir=base_dir
|
||||
)
|
||||
# Push to async queue
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.event_queue.put("user_request", event_payload),
|
||||
self._loop
|
||||
)
|
||||
self.event_queue.put("user_request", event_payload)
|
||||
sys.stderr.write("[DEBUG] Enqueued user_request event\n")
|
||||
sys.stderr.flush()
|
||||
except Exception as e:
|
||||
@@ -1549,7 +1557,7 @@ class AppController:
|
||||
proj["project"]["auto_scroll_tool_calls"] = self.ui_auto_scroll_tool_calls
|
||||
proj.setdefault("gemini_cli", {})["binary_path"] = self.ui_gemini_cli_path
|
||||
proj.setdefault("agent", {}).setdefault("tools", {})
|
||||
for t_name in AGENT_TOOL_NAMES:
|
||||
for t_name in models.AGENT_TOOL_NAMES:
|
||||
proj["agent"]["tools"][t_name] = self.ui_agent_tools.get(t_name, True)
|
||||
self._flush_disc_entries_to_project()
|
||||
disc_sec = proj.setdefault("discussion", {})
|
||||
@@ -1598,9 +1606,13 @@ class AppController:
|
||||
|
||||
def _cb_plan_epic(self) -> None:
|
||||
def _bg_task() -> None:
|
||||
sys.stderr.write("[DEBUG] _cb_plan_epic _bg_task started\n")
|
||||
sys.stderr.flush()
|
||||
try:
|
||||
self.ai_status = "Planning Epic (Tier 1)..."
|
||||
history = orchestrator_pm.get_track_history_summary()
|
||||
sys.stderr.write(f"[DEBUG] History summary length: {len(history)}\n")
|
||||
sys.stderr.flush()
|
||||
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", []))
|
||||
@@ -1641,7 +1653,7 @@ class AppController:
|
||||
def _bg_task() -> None:
|
||||
# Generate skeletons once
|
||||
self.ai_status = "Phase 2: Generating skeletons for all tracks..."
|
||||
parser = ASTParser(language="python")
|
||||
parser = file_cache.ASTParser(language="python")
|
||||
generated_skeletons = ""
|
||||
try:
|
||||
for i, file_path in enumerate(self.files):
|
||||
@@ -1685,7 +1697,7 @@ class AppController:
|
||||
engine = multi_agent_conductor.ConductorEngine(self.active_track, self.event_queue, auto_queue=not self.mma_step_mode)
|
||||
flat = project_manager.flat_config(self.project, self.active_discussion, track_id=self.active_track.id)
|
||||
full_md, _, _ = aggregate.run(flat)
|
||||
asyncio.run_coroutine_threadsafe(engine.run(md_content=full_md), self._loop)
|
||||
threading.Thread(target=engine.run, kwargs={"md_content": full_md}, daemon=True).start()
|
||||
self.ai_status = f"Track '{self.active_track.description}' started."
|
||||
return
|
||||
|
||||
@@ -1709,7 +1721,7 @@ class AppController:
|
||||
skeletons = "" # Initialize skeletons variable
|
||||
if skeletons_str is None: # Only generate if not provided
|
||||
# 1. Get skeletons for context
|
||||
parser = ASTParser(language="python")
|
||||
parser = file_cache.ASTParser(language="python")
|
||||
for i, file_path in enumerate(self.files):
|
||||
try:
|
||||
self.ai_status = f"Phase 2: Scanning files ({i+1}/{len(self.files)})..."
|
||||
@@ -1745,7 +1757,7 @@ class AppController:
|
||||
# 3. Create Track and Ticket objects
|
||||
tickets = []
|
||||
for t_data in sorted_tickets_data:
|
||||
ticket = Ticket(
|
||||
ticket = models.Ticket(
|
||||
id=t_data["id"],
|
||||
description=t_data.get("description") or t_data.get("goal", "No description"),
|
||||
status=t_data.get("status", "todo"),
|
||||
@@ -1755,11 +1767,10 @@ class AppController:
|
||||
)
|
||||
tickets.append(ticket)
|
||||
track_id = f"track_{uuid.uuid5(uuid.NAMESPACE_DNS, f'{self.active_project_path}_{title}').hex[:12]}"
|
||||
track = Track(id=track_id, description=title, tickets=tickets)
|
||||
track = models.Track(id=track_id, description=title, tickets=tickets)
|
||||
# Initialize track state in the filesystem
|
||||
from src.models import TrackState, Metadata
|
||||
meta = Metadata(id=track_id, name=title, status="todo", created_at=datetime.now(), updated_at=datetime.now())
|
||||
state = TrackState(metadata=meta, discussion=[], tasks=tickets)
|
||||
meta = models.Metadata(id=track_id, name=title, status="todo", created_at=datetime.now(), updated_at=datetime.now())
|
||||
state = models.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, auto_queue=not self.mma_step_mode)
|
||||
@@ -1767,8 +1778,8 @@ class AppController:
|
||||
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
|
||||
asyncio.run_coroutine_threadsafe(engine.run(md_content=full_md), self._loop)
|
||||
# Start the engine in a separate thread
|
||||
threading.Thread(target=engine.run, kwargs={"md_content": full_md}, daemon=True).start()
|
||||
except Exception as e:
|
||||
self.ai_status = f"Track start error: {e}"
|
||||
print(f"ERROR in _start_track_logic: {e}")
|
||||
@@ -1778,20 +1789,14 @@ class AppController:
|
||||
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
|
||||
)
|
||||
self.event_queue.put("mma_retry", {"ticket_id": ticket_id})
|
||||
|
||||
def _cb_ticket_skip(self, ticket_id: str) -> None:
|
||||
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
|
||||
)
|
||||
self.event_queue.put("mma_skip", {"ticket_id": ticket_id})
|
||||
|
||||
def _cb_run_conductor_setup(self) -> None:
|
||||
base = Path("conductor")
|
||||
@@ -1848,23 +1853,21 @@ class AppController:
|
||||
def _push_mma_state_update(self) -> None:
|
||||
if not self.active_track:
|
||||
return
|
||||
# Sync active_tickets (list of dicts) back to active_track.tickets (list of Ticket objects)
|
||||
self.active_track.tickets = [Ticket.from_dict(t) for t in self.active_tickets]
|
||||
# Sync active_tickets (list of dicts) back to active_track.tickets (list of models.Ticket objects)
|
||||
self.active_track.tickets = [models.Ticket.from_dict(t) for t in self.active_tickets]
|
||||
# Save the state to disk
|
||||
from src.project_manager import save_track_state, load_track_state
|
||||
from src.models import TrackState, Metadata
|
||||
|
||||
existing = load_track_state(self.active_track.id, self.ui_files_base_dir)
|
||||
meta = Metadata(
|
||||
existing = project_manager.load_track_state(self.active_track.id, self.ui_files_base_dir)
|
||||
meta = models.Metadata(
|
||||
id=self.active_track.id,
|
||||
name=self.active_track.description,
|
||||
status=self.mma_status,
|
||||
created_at=existing.metadata.created_at if existing else datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
state = TrackState(
|
||||
state = models.TrackState(
|
||||
metadata=meta,
|
||||
discussion=existing.discussion if existing else [],
|
||||
tasks=self.active_track.tickets
|
||||
)
|
||||
save_track_state(self.active_track.id, state, self.ui_files_base_dir)
|
||||
project_manager.save_track_state(self.active_track.id, state, self.ui_files_base_dir)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import ai_client
|
||||
import mma_prompts
|
||||
from src import ai_client
|
||||
from src import mma_prompts
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
@@ -49,8 +49,8 @@ def generate_tickets(track_brief: str, module_skeletons: str) -> list[dict[str,
|
||||
ai_client.set_custom_system_prompt(old_system_prompt or "")
|
||||
ai_client.current_tier = None
|
||||
|
||||
from dag_engine import TrackDAG
|
||||
from models import Ticket
|
||||
from src.dag_engine import TrackDAG
|
||||
from src.models import Ticket
|
||||
|
||||
def topological_sort(tickets: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import List
|
||||
from models import Ticket
|
||||
from src.models import Ticket
|
||||
|
||||
class TrackDAG:
|
||||
"""
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""
|
||||
Decoupled event emission system for cross-module communication.
|
||||
"""
|
||||
import asyncio
|
||||
import queue
|
||||
from typing import Callable, Any, Dict, List, Tuple
|
||||
|
||||
class EventEmitter:
|
||||
"""
|
||||
Simple event emitter for decoupled communication between modules.
|
||||
"""
|
||||
Simple event emitter for decoupled communication between modules.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initializes the EventEmitter with an empty listener map."""
|
||||
@@ -15,25 +15,25 @@ class EventEmitter:
|
||||
|
||||
def on(self, event_name: str, callback: Callable[..., Any]) -> None:
|
||||
"""
|
||||
Registers a callback for a specific event.
|
||||
|
||||
Args:
|
||||
event_name: The name of the event to listen for.
|
||||
callback: The function to call when the event is emitted.
|
||||
"""
|
||||
Registers a callback for a specific event.
|
||||
|
||||
Args:
|
||||
event_name: The name of the event to listen for.
|
||||
callback: The function to call when the event is emitted.
|
||||
"""
|
||||
if event_name not in self._listeners:
|
||||
self._listeners[event_name] = []
|
||||
self._listeners[event_name].append(callback)
|
||||
|
||||
def emit(self, event_name: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""
|
||||
Emits an event, calling all registered callbacks.
|
||||
|
||||
Args:
|
||||
event_name: The name of the event to emit.
|
||||
*args: Positional arguments to pass to callbacks.
|
||||
**kwargs: Keyword arguments to pass to callbacks.
|
||||
"""
|
||||
Emits an event, calling all registered callbacks.
|
||||
|
||||
Args:
|
||||
event_name: The name of the event to emit.
|
||||
*args: Positional arguments to pass to callbacks.
|
||||
**kwargs: Keyword arguments to pass to callbacks.
|
||||
"""
|
||||
if event_name in self._listeners:
|
||||
for callback in self._listeners[event_name]:
|
||||
callback(*args, **kwargs)
|
||||
@@ -42,46 +42,46 @@ class EventEmitter:
|
||||
"""Clears all registered listeners."""
|
||||
self._listeners.clear()
|
||||
|
||||
class AsyncEventQueue:
|
||||
class SyncEventQueue:
|
||||
"""
|
||||
Synchronous event queue for decoupled communication using queue.Queue.
|
||||
"""
|
||||
Asynchronous event queue for decoupled communication using asyncio.Queue.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initializes the AsyncEventQueue with an internal asyncio.Queue."""
|
||||
self._queue: asyncio.Queue[Tuple[str, Any]] = asyncio.Queue()
|
||||
"""Initializes the SyncEventQueue with an internal queue.Queue."""
|
||||
self._queue: queue.Queue[Tuple[str, Any]] = queue.Queue()
|
||||
|
||||
async def put(self, event_name: str, payload: Any = None) -> None:
|
||||
def put(self, event_name: str, payload: Any = None) -> None:
|
||||
"""
|
||||
Puts an event into the queue.
|
||||
|
||||
Args:
|
||||
event_name: The name of the event.
|
||||
payload: Optional data associated with the event.
|
||||
"""
|
||||
await self._queue.put((event_name, payload))
|
||||
Puts an event into the queue.
|
||||
|
||||
Args:
|
||||
event_name: The name of the event.
|
||||
payload: Optional data associated with the event.
|
||||
"""
|
||||
self._queue.put((event_name, payload))
|
||||
|
||||
async def get(self) -> Tuple[str, Any]:
|
||||
def get(self) -> Tuple[str, Any]:
|
||||
"""
|
||||
Gets an event from the queue.
|
||||
|
||||
Returns:
|
||||
A tuple containing (event_name, payload).
|
||||
"""
|
||||
return await self._queue.get()
|
||||
Gets an event from the queue.
|
||||
|
||||
Returns:
|
||||
A tuple containing (event_name, payload).
|
||||
"""
|
||||
return self._queue.get()
|
||||
|
||||
def task_done(self) -> None:
|
||||
"""Signals that a formerly enqueued task is complete."""
|
||||
self._queue.task_done()
|
||||
|
||||
async def join(self) -> None:
|
||||
def join(self) -> None:
|
||||
"""Blocks until all items in the queue have been gotten and processed."""
|
||||
await self._queue.join()
|
||||
self._queue.join()
|
||||
|
||||
class UserRequestEvent:
|
||||
"""
|
||||
Payload for a user request event.
|
||||
"""
|
||||
Payload for a user request event.
|
||||
"""
|
||||
|
||||
def __init__(self, prompt: str, stable_md: str, file_items: List[Any], disc_text: str, base_dir: str) -> None:
|
||||
self.prompt = prompt
|
||||
|
||||
@@ -2,7 +2,8 @@ import subprocess
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import session_logger
|
||||
import sys
|
||||
from src import session_logger
|
||||
from typing import Optional, Callable, Any
|
||||
|
||||
class GeminiCliAdapter:
|
||||
@@ -61,6 +62,8 @@ class GeminiCliAdapter:
|
||||
|
||||
# Filter out empty strings and strip quotes (Popen doesn't want them in cmd_list elements)
|
||||
cmd_list = [c.strip('"') for c in cmd_list if c]
|
||||
sys.stderr.write(f"[DEBUG] GeminiCliAdapter cmd_list: {cmd_list}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd_list,
|
||||
|
||||
49
src/gui_2.py
49
src/gui_2.py
@@ -13,28 +13,27 @@ import requests # type: ignore[import-untyped]
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, Tk
|
||||
from typing import Optional, Callable, Any
|
||||
import aggregate
|
||||
import ai_client
|
||||
import cost_tracker
|
||||
from ai_client import ProviderError
|
||||
import shell_runner
|
||||
import session_logger
|
||||
import project_manager
|
||||
import theme_2 as theme
|
||||
from src import aggregate
|
||||
from src import ai_client
|
||||
from src import cost_tracker
|
||||
from src import shell_runner
|
||||
from src import session_logger
|
||||
from src import project_manager
|
||||
from src import theme_2 as theme
|
||||
import tomllib
|
||||
import events
|
||||
from src import events
|
||||
import numpy as np
|
||||
import api_hooks
|
||||
import mcp_client
|
||||
import orchestrator_pm
|
||||
from performance_monitor import PerformanceMonitor
|
||||
from log_registry import LogRegistry
|
||||
from log_pruner import LogPruner
|
||||
import conductor_tech_lead
|
||||
import multi_agent_conductor
|
||||
from models import Track, Ticket, DISC_ROLES, AGENT_TOOL_NAMES, CONFIG_PATH, load_config, parse_history_entries
|
||||
from app_controller import AppController, ConfirmDialog, MMAApprovalDialog, MMASpawnApprovalDialog
|
||||
from file_cache import ASTParser
|
||||
from src import api_hooks
|
||||
from src import mcp_client
|
||||
from src import orchestrator_pm
|
||||
from src import performance_monitor
|
||||
from src import log_registry
|
||||
from src import log_pruner
|
||||
from src import conductor_tech_lead
|
||||
from src import multi_agent_conductor
|
||||
from src import models
|
||||
from src import app_controller
|
||||
from src import file_cache
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.security.api_key import APIKeyHeader
|
||||
@@ -45,7 +44,7 @@ PROVIDERS: list[str] = ["gemini", "anthropic", "gemini_cli", "deepseek"]
|
||||
COMMS_CLAMP_CHARS: int = 300
|
||||
|
||||
def save_config(config: dict[str, Any]) -> None:
|
||||
with open(CONFIG_PATH, "wb") as f:
|
||||
with open(models.CONFIG_PATH, "wb") as f:
|
||||
tomli_w.dump(config, f)
|
||||
|
||||
def hide_tk_root() -> Tk:
|
||||
@@ -102,7 +101,7 @@ class App:
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Initialize controller and delegate state
|
||||
self.controller = AppController()
|
||||
self.controller = app_controller.AppController()
|
||||
# Restore legacy PROVIDERS to controller if needed (it already has it via delegation if set on class level, but let's be explicit)
|
||||
if not hasattr(self.controller, 'PROVIDERS'):
|
||||
self.controller.PROVIDERS = PROVIDERS
|
||||
@@ -739,7 +738,7 @@ class App:
|
||||
ch, self.ui_auto_scroll_comms = imgui.checkbox("Auto-scroll Comms History", self.ui_auto_scroll_comms)
|
||||
ch, self.ui_auto_scroll_tool_calls = imgui.checkbox("Auto-scroll Tool History", self.ui_auto_scroll_tool_calls)
|
||||
if imgui.collapsing_header("Agent Tools"):
|
||||
for t_name in AGENT_TOOL_NAMES:
|
||||
for t_name in models.AGENT_TOOL_NAMES:
|
||||
val = self.ui_agent_tools.get(t_name, True)
|
||||
ch, val = imgui.checkbox(f"Enable {t_name}", val)
|
||||
if ch:
|
||||
@@ -800,7 +799,7 @@ class App:
|
||||
if not exp:
|
||||
imgui.end()
|
||||
return
|
||||
registry = LogRegistry("logs/log_registry.toml")
|
||||
registry = log_registry.LogRegistry("logs/log_registry.toml")
|
||||
sessions = registry.data
|
||||
if imgui.begin_table("sessions_table", 7, imgui.TableFlags_.borders | imgui.TableFlags_.row_bg | imgui.TableFlags_.resizable):
|
||||
imgui.table_setup_column("Session ID")
|
||||
@@ -976,7 +975,7 @@ class App:
|
||||
self._flush_disc_entries_to_project()
|
||||
history_strings = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir)
|
||||
with self._disc_entries_lock:
|
||||
self.disc_entries = parse_history_entries(history_strings, self.disc_roles)
|
||||
self.disc_entries = models.parse_history_entries(history_strings, self.disc_roles)
|
||||
self.ai_status = f"track discussion: {self.active_track.id}"
|
||||
else:
|
||||
self._flush_disc_entries_to_project()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from log_registry import LogRegistry
|
||||
from src.log_registry import LogRegistry
|
||||
|
||||
class LogPruner:
|
||||
"""
|
||||
|
||||
@@ -35,8 +35,8 @@ from typing import Optional, Callable, Any, cast
|
||||
import os
|
||||
import ast
|
||||
import subprocess
|
||||
import summarize
|
||||
import outline_tool
|
||||
from src import summarize
|
||||
from src import outline_tool
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from html.parser import HTMLParser
|
||||
@@ -254,7 +254,7 @@ def py_get_skeleton(path: str) -> str:
|
||||
if not p.is_file() or p.suffix != ".py":
|
||||
return f"ERROR: not a python file: {path}"
|
||||
try:
|
||||
from file_cache import ASTParser
|
||||
from src.file_cache import ASTParser
|
||||
code = p.read_text(encoding="utf-8")
|
||||
parser = ASTParser("python")
|
||||
return parser.get_skeleton(code)
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import ai_client
|
||||
from src import ai_client
|
||||
import json
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import List, Optional, Tuple
|
||||
from dataclasses import asdict
|
||||
import events
|
||||
from models import Ticket, Track, WorkerContext
|
||||
from file_cache import ASTParser
|
||||
from src import events
|
||||
from src.models import Ticket, Track, WorkerContext
|
||||
from src.file_cache import ASTParser
|
||||
from pathlib import Path
|
||||
|
||||
from dag_engine import TrackDAG, ExecutionEngine
|
||||
from src.dag_engine import TrackDAG, ExecutionEngine
|
||||
|
||||
class ConductorEngine:
|
||||
"""
|
||||
Orchestrates the execution of tickets within a track.
|
||||
"""
|
||||
|
||||
def __init__(self, track: Track, event_queue: Optional[events.AsyncEventQueue] = None, auto_queue: bool = False) -> None:
|
||||
def __init__(self, track: Track, event_queue: Optional[events.SyncEventQueue] = None, auto_queue: bool = False) -> None:
|
||||
self.track = track
|
||||
self.event_queue = event_queue
|
||||
self.tier_usage = {
|
||||
@@ -29,7 +29,7 @@ class ConductorEngine:
|
||||
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) -> None:
|
||||
def _push_state(self, status: str = "running", active_tier: str = None) -> None:
|
||||
if not self.event_queue:
|
||||
return
|
||||
payload = {
|
||||
@@ -42,7 +42,7 @@ class ConductorEngine:
|
||||
},
|
||||
"tickets": [asdict(t) for t in self.track.tickets]
|
||||
}
|
||||
await self.event_queue.put("mma_state_update", payload)
|
||||
self.event_queue.put("mma_state_update", payload)
|
||||
|
||||
def parse_json_tickets(self, json_str: str) -> None:
|
||||
"""
|
||||
@@ -73,14 +73,14 @@ class ConductorEngine:
|
||||
except KeyError as e:
|
||||
print(f"Missing required field in ticket definition: {e}")
|
||||
|
||||
async def run(self, md_content: str = "") -> None:
|
||||
def run(self, md_content: str = "") -> None:
|
||||
"""
|
||||
Main execution loop using the DAG engine.
|
||||
Args:
|
||||
md_content: The full markdown context (history + files) for AI workers.
|
||||
"""
|
||||
await self._push_state(status="running", active_tier="Tier 2 (Tech Lead)")
|
||||
loop = asyncio.get_event_loop()
|
||||
self._push_state(status="running", active_tier="Tier 2 (Tech Lead)")
|
||||
|
||||
while True:
|
||||
# 1. Identify ready tasks
|
||||
ready_tasks = self.engine.tick()
|
||||
@@ -89,15 +89,15 @@ class ConductorEngine:
|
||||
all_done = all(t.status == "completed" for t in self.track.tickets)
|
||||
if all_done:
|
||||
print("Track completed successfully.")
|
||||
await self._push_state(status="done", active_tier=None)
|
||||
self._push_state(status="done", active_tier=None)
|
||||
else:
|
||||
# Check if any tasks are in-progress or could be ready
|
||||
if any(t.status == "in_progress" for t in self.track.tickets):
|
||||
# Wait for async tasks to complete
|
||||
await asyncio.sleep(1)
|
||||
# Wait for tasks to complete
|
||||
time.sleep(1)
|
||||
continue
|
||||
print("No more executable tickets. Track is blocked or finished.")
|
||||
await self._push_state(status="blocked", active_tier=None)
|
||||
self._push_state(status="blocked", active_tier=None)
|
||||
break
|
||||
# 3. Process ready tasks
|
||||
to_run = [t for t in ready_tasks if t.status == "in_progress" or (not t.step_mode and self.engine.auto_queue)]
|
||||
@@ -106,15 +106,15 @@ class ConductorEngine:
|
||||
for ticket in ready_tasks:
|
||||
if ticket not in to_run and ticket.status == "todo":
|
||||
print(f"Ticket {ticket.id} is ready and awaiting approval.")
|
||||
await self._push_state(active_tier=f"Awaiting Approval: {ticket.id}")
|
||||
await asyncio.sleep(1)
|
||||
self._push_state(active_tier=f"Awaiting Approval: {ticket.id}")
|
||||
time.sleep(1)
|
||||
|
||||
if to_run:
|
||||
tasks = []
|
||||
threads = []
|
||||
for ticket in to_run:
|
||||
ticket.status = "in_progress"
|
||||
print(f"Executing ticket {ticket.id}: {ticket.description}")
|
||||
await self._push_state(active_tier=f"Tier 3 (Worker): {ticket.id}")
|
||||
self._push_state(active_tier=f"Tier 3 (Worker): {ticket.id}")
|
||||
|
||||
# Escalation logic based on retry_count
|
||||
models = ["gemini-2.5-flash-lite", "gemini-2.5-flash", "gemini-3.1-pro-preview"]
|
||||
@@ -127,19 +127,17 @@ class ConductorEngine:
|
||||
messages=[]
|
||||
)
|
||||
context_files = ticket.context_requirements if ticket.context_requirements else None
|
||||
tasks.append(loop.run_in_executor(
|
||||
None,
|
||||
run_worker_lifecycle,
|
||||
ticket,
|
||||
context,
|
||||
context_files,
|
||||
self.event_queue,
|
||||
self,
|
||||
md_content,
|
||||
loop
|
||||
))
|
||||
|
||||
t = threading.Thread(
|
||||
target=run_worker_lifecycle,
|
||||
args=(ticket, context, context_files, self.event_queue, self, md_content),
|
||||
daemon=True
|
||||
)
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# 4. Retry and escalation logic
|
||||
for ticket in to_run:
|
||||
@@ -149,13 +147,13 @@ class ConductorEngine:
|
||||
ticket.status = 'todo'
|
||||
print(f"Ticket {ticket.id} BLOCKED. Escalating to {models[min(ticket.retry_count, len(models)-1)]} and retrying...")
|
||||
|
||||
await self._push_state(active_tier="Tier 2 (Tech Lead)")
|
||||
self._push_state(active_tier="Tier 2 (Tech Lead)")
|
||||
|
||||
def _queue_put(event_queue: events.AsyncEventQueue, loop: asyncio.AbstractEventLoop, event_name: str, payload) -> None:
|
||||
"""Thread-safe helper to push an event to the AsyncEventQueue from a worker thread."""
|
||||
asyncio.run_coroutine_threadsafe(event_queue.put(event_name, payload), loop)
|
||||
def _queue_put(event_queue: events.SyncEventQueue, event_name: str, payload) -> None:
|
||||
"""Thread-safe helper to push an event to the SyncEventQueue from a worker thread."""
|
||||
event_queue.put(event_name, payload)
|
||||
|
||||
def confirm_execution(payload: str, event_queue: events.AsyncEventQueue, ticket_id: str, loop: asyncio.AbstractEventLoop = None) -> bool:
|
||||
def confirm_execution(payload: str, event_queue: events.SyncEventQueue, ticket_id: str) -> bool:
|
||||
"""
|
||||
Pushes an approval request to the GUI and waits for response.
|
||||
"""
|
||||
@@ -166,11 +164,9 @@ def confirm_execution(payload: str, event_queue: events.AsyncEventQueue, ticket_
|
||||
"payload": payload,
|
||||
"dialog_container": dialog_container
|
||||
}
|
||||
if loop:
|
||||
_queue_put(event_queue, loop, "mma_step_approval", task)
|
||||
else:
|
||||
raise RuntimeError("loop is required for thread-safe event queue access")
|
||||
# Wait for the GUI to create the dialog and for the user to respond
|
||||
_queue_put(event_queue, "mma_step_approval", task)
|
||||
|
||||
# Wait for the GUI to create the dialog and for the user to respond
|
||||
start = time.time()
|
||||
while dialog_container[0] is None and time.time() - start < 60:
|
||||
time.sleep(0.1)
|
||||
@@ -179,7 +175,7 @@ def confirm_execution(payload: str, event_queue: events.AsyncEventQueue, ticket_
|
||||
return approved
|
||||
return False
|
||||
|
||||
def confirm_spawn(role: str, prompt: str, context_md: str, event_queue: events.AsyncEventQueue, ticket_id: str, loop: asyncio.AbstractEventLoop = None) -> Tuple[bool, str, str]:
|
||||
def confirm_spawn(role: str, prompt: str, context_md: str, event_queue: events.SyncEventQueue, ticket_id: str) -> Tuple[bool, str, str]:
|
||||
"""
|
||||
Pushes a spawn approval request to the GUI and waits for response.
|
||||
Returns (approved, modified_prompt, modified_context)
|
||||
@@ -193,11 +189,9 @@ def confirm_spawn(role: str, prompt: str, context_md: str, event_queue: events.A
|
||||
"context_md": context_md,
|
||||
"dialog_container": dialog_container
|
||||
}
|
||||
if loop:
|
||||
_queue_put(event_queue, loop, "mma_spawn_approval", task)
|
||||
else:
|
||||
raise RuntimeError("loop is required for thread-safe event queue access")
|
||||
# Wait for the GUI to create the dialog and for the user to respond
|
||||
_queue_put(event_queue, "mma_spawn_approval", task)
|
||||
|
||||
# Wait for the GUI to create the dialog and for the user to respond
|
||||
start = time.time()
|
||||
while dialog_container[0] is None and time.time() - start < 60:
|
||||
time.sleep(0.1)
|
||||
@@ -220,7 +214,7 @@ def confirm_spawn(role: str, prompt: str, context_md: str, event_queue: events.A
|
||||
return approved, modified_prompt, modified_context
|
||||
return False, prompt, context_md
|
||||
|
||||
def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files: List[str] | None = None, event_queue: events.AsyncEventQueue | None = None, engine: Optional['ConductorEngine'] = None, md_content: str = "", loop: asyncio.AbstractEventLoop = None) -> None:
|
||||
def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files: List[str] | None = None, event_queue: events.SyncEventQueue | None = None, engine: Optional['ConductorEngine'] = None, md_content: str = "") -> None:
|
||||
"""
|
||||
Simulates the lifecycle of a single agent working on a ticket.
|
||||
Calls the AI client and updates the ticket status based on the response.
|
||||
@@ -231,7 +225,6 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
|
||||
event_queue: Queue for pushing state updates and receiving approvals.
|
||||
engine: The conductor engine.
|
||||
md_content: The markdown context (history + files) for AI workers.
|
||||
loop: The main asyncio event loop (required for thread-safe queue access).
|
||||
"""
|
||||
# Enforce Context Amnesia: each ticket starts with a clean slate.
|
||||
ai_client.reset_session()
|
||||
@@ -270,8 +263,7 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
|
||||
prompt=user_message,
|
||||
context_md=md_content,
|
||||
event_queue=event_queue,
|
||||
ticket_id=ticket.id,
|
||||
loop=loop
|
||||
ticket_id=ticket.id
|
||||
)
|
||||
if not approved:
|
||||
ticket.mark_blocked("Spawn rejected by user.")
|
||||
@@ -283,15 +275,15 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
|
||||
def clutch_callback(payload: str) -> bool:
|
||||
if not event_queue:
|
||||
return True
|
||||
return confirm_execution(payload, event_queue, ticket.id, loop=loop)
|
||||
return confirm_execution(payload, event_queue, ticket.id)
|
||||
|
||||
def stream_callback(chunk: str) -> None:
|
||||
if event_queue and loop:
|
||||
_queue_put(event_queue, loop, 'mma_stream', {'stream_id': f'Tier 3 (Worker): {ticket.id}', 'text': chunk})
|
||||
if event_queue:
|
||||
_queue_put(event_queue, 'mma_stream', {'stream_id': f'Tier 3 (Worker): {ticket.id}', 'text': chunk})
|
||||
|
||||
old_comms_cb = ai_client.comms_log_callback
|
||||
def worker_comms_callback(entry: dict) -> None:
|
||||
if event_queue and loop:
|
||||
if event_queue:
|
||||
kind = entry.get("kind")
|
||||
payload = entry.get("payload", {})
|
||||
chunk = ""
|
||||
@@ -303,7 +295,7 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
|
||||
chunk = f"\n[TOOL RESULT]\n{res}\n"
|
||||
|
||||
if chunk:
|
||||
_queue_put(event_queue, loop, "response", {"text": chunk, "stream_id": f"Tier 3 (Worker): {ticket.id}", "status": "streaming..."})
|
||||
_queue_put(event_queue, "response", {"text": chunk, "stream_id": f"Tier 3 (Worker): {ticket.id}", "status": "streaming..."})
|
||||
if old_comms_cb:
|
||||
old_comms_cb(entry)
|
||||
|
||||
@@ -331,11 +323,8 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files:
|
||||
"stream_id": f"Tier 3 (Worker): {ticket.id}",
|
||||
"status": "done"
|
||||
}
|
||||
print(f"[MMA] Pushing Tier 3 response for {ticket.id}, loop={'present' if loop else 'NONE'}, stream_id={response_payload['stream_id']}")
|
||||
if loop:
|
||||
_queue_put(event_queue, loop, "response", response_payload)
|
||||
else:
|
||||
raise RuntimeError("loop is required for thread-safe event queue access")
|
||||
print(f"[MMA] Pushing Tier 3 response for {ticket.id}, stream_id={response_payload['stream_id']}")
|
||||
_queue_put(event_queue, "response", response_payload)
|
||||
except Exception as e:
|
||||
print(f"[MMA] ERROR pushing response to UI: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import json
|
||||
import ai_client
|
||||
import mma_prompts
|
||||
import aggregate
|
||||
import summarize
|
||||
from src import ai_client
|
||||
from src import mma_prompts
|
||||
from src import aggregate
|
||||
from src import summarize
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@@ -71,7 +71,8 @@ def open_session(label: Optional[str] = None) -> None:
|
||||
_cli_fh.flush()
|
||||
|
||||
try:
|
||||
from log_registry import LogRegistry
|
||||
from src.log_registry import LogRegistry
|
||||
|
||||
registry = LogRegistry(str(_LOG_DIR / "log_registry.toml"))
|
||||
registry.register_session(_session_id, str(_session_dir), datetime.datetime.now())
|
||||
except Exception as e:
|
||||
@@ -99,7 +100,8 @@ def close_session() -> None:
|
||||
_cli_fh = None
|
||||
|
||||
try:
|
||||
from log_registry import LogRegistry
|
||||
from src.log_registry import LogRegistry
|
||||
|
||||
registry = LogRegistry(str(_LOG_DIR / "log_registry.toml"))
|
||||
registry.update_auto_whitelist_status(_session_id)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user