diff --git a/src/app_controller.py b/src/app_controller.py index f709d3b0..61f29c5a 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -1844,17 +1844,34 @@ class AppController: tasks = self._pending_gui_tasks[:] self._pending_gui_tasks.clear() for task in tasks: - try: - action = task.get("action") or task.get("type") - if action: - session_logger.log_api_hook("PROCESS_TASK", action, str(task)) - if action in self._gui_task_handlers: - self._gui_task_handlers[action](self, task) - except (OSError, IOError, ValueError, TypeError, KeyError, AttributeError, RuntimeError) as e: - logging.getLogger(__name__).debug("GUI task execution failed: %s", e, extra={"source": "app_controller._process_pending_gui_tasks"}) - print(f"Error executing GUI task ({task.get('action') or task.get('type')}): {e}") + result = self._execute_gui_task_result(task) + if not result.ok: + err = result.errors[0] + action = task.get('action') or task.get('type') or 'unknown' + self._last_request_errors.append((f"gui_task[{action}]", err)) + print(f"Error executing GUI task ({action}): {err.message}") traceback.print_exc() + def _execute_gui_task_result(self, task: dict) -> "Result[None]": + """Phase 6 Group 6.6: per-task execution with Result propagation. + On failure: any handler exception -> ErrorInfo(original=e). + Caller (`_process_pending_gui_tasks`) appends to + `self._last_request_errors` for sub-track 4 GUI.""" + try: + action = task.get("action") or task.get("type") + if action: + session_logger.log_api_hook("PROCESS_TASK", action, str(task)) + if action in self._gui_task_handlers: + self._gui_task_handlers[action](self, task) + return OK + except (OSError, IOError, ValueError, TypeError, KeyError, AttributeError, RuntimeError) as e: + return Result(data=None, errors=[ErrorInfo( + kind=ErrorKind.INTERNAL, + message=str(e), + source=f"app_controller._execute_gui_task_result[{task.get('action') or task.get('type')}]", + original=e, + )]) + def _process_pending_history_adds(self) -> None: """ Synchronizes pending history entries to the active discussion and project state. @@ -2126,15 +2143,98 @@ class AppController: ] for p in paths_to_check: if p.exists(): - try: - with open(p, "r", encoding="utf-8") as rf: - return rf.read() - except (OSError, IOError, UnicodeDecodeError) as e: - logging.getLogger(__name__).debug("ref file read failed for %s: %s", p, e, extra={"source": "app_controller.cb_load_prior_log._resolve_log_ref"}) - return f"[ERROR READING REF: {ref_file}]" + result = self._read_ref_file_result(p) + if result.ok: + return result.data + self._last_request_errors.append((f"ref_file_read[{ref_file}]", result.errors[0])) + return f"[ERROR READING REF: {ref_file}]" return match.group(0) return re.sub(pattern, replace_ref, content) + def _read_ref_file_result(self, p: Path) -> "Result[str]": + """Phase 6 Group 6.7: read a [REF:...] file content. + On failure: OSError/IOError/UnicodeDecodeError -> ErrorInfo(original=e). + Caller (`_resolve_log_ref`) appends to `self._last_request_errors`.""" + try: + with open(p, "r", encoding="utf-8") as rf: + return Result(data=rf.read()) + except (OSError, IOError, UnicodeDecodeError) as e: + return Result(data="", errors=[ErrorInfo( + kind=ErrorKind.INTERNAL, + message=str(e), + source=f"app_controller._read_ref_file_result[{p.name}]", + original=e, + )]) + + def _flush_to_project_result(self, cleaned_proj: dict, path: str) -> "Result[None]": + """Phase 6 Group 6.7: flush to project file with Result propagation. + On failure: OSError/IOError/PermissionError/RuntimeError -> ErrorInfo(original=e). + Caller (`_flush_to_project`) appends to `self._last_request_errors`.""" + try: + project_manager.save_project(cleaned_proj, path) + return OK + except (OSError, IOError, PermissionError, RuntimeError) as e: + return Result(data=None, errors=[ErrorInfo( + kind=ErrorKind.INTERNAL, + message=str(e), + source=f"app_controller._flush_to_project_result[{path}]", + original=e, + )]) + + def _deserialize_active_track_result(self, at_data: dict) -> "Result[Any]": + """Phase 6 Group 6.7: deserialize active_track with Result propagation. + On failure: TypeError/ValueError/KeyError/AttributeError -> ErrorInfo(original=e). + Caller (`_refresh_from_project`) appends to `self._last_request_errors`.""" + try: + tickets = [] + for t_data in at_data.get("tickets", []): + tickets.append(models.Ticket(**t_data)) + track = models.Track( + id=at_data.get("id"), + description=at_data.get("description"), + tickets=tickets + ) + return Result(data=track) + except (TypeError, ValueError, KeyError, AttributeError) as e: + return Result(data=None, errors=[ErrorInfo( + kind=ErrorKind.INVALID_INPUT, + message=str(e), + source="app_controller._deserialize_active_track_result", + original=e, + )]) + + def _serialize_tool_calls_result(self, tool_calls: list) -> "Result[str]": + """Phase 6 Group 6.7: json-serialize tool_calls with Result propagation. + On failure: TypeError/ValueError -> ErrorInfo(original=e). + Caller falls back to '[TOOL CALLS PRESENT]' marker.""" + try: + tc_str = json.dumps(tool_calls, indent=1) + return Result(data=tc_str) + except (TypeError, ValueError) as e: + return Result(data="", errors=[ErrorInfo( + kind=ErrorKind.INVALID_INPUT, + message=str(e), + source="app_controller._serialize_tool_calls_result", + original=e, + )]) + + def _parse_token_history_first_ts_result(self, item: dict) -> "Result[float]": + """Phase 6 Group 6.7: parse the first token_history timestamp. + On failure: ValueError/TypeError/KeyError/IndexError -> ErrorInfo(original=e). + Caller falls back to time.time() and records the error.""" + try: + import datetime as _dt + first_ts = item['time'] + dt = _dt.datetime.strptime(first_ts, '%Y-%m-%dT%H:%M:%S') + return Result(data=dt.timestamp()) + except (ValueError, TypeError, KeyError, IndexError) as e: + return Result(data=0.0, errors=[ErrorInfo( + kind=ErrorKind.INVALID_INPUT, + message=str(e), + source="app_controller._parse_token_history_first_ts_result", + original=e, + )]) + entries = [] disc_entries = [] paired_tools = {} @@ -2223,14 +2323,15 @@ class AppController: tool_calls = payload.get("tool_calls", []) content = text if tool_calls: - try: - tc_str = json.dumps(tool_calls, indent=1) + tc_result = self._serialize_tool_calls_result(tool_calls) + if tc_result.ok: + tc_str = tc_result.data if content: content += f"\n\n[TOOL CALLS]\n{tc_str}" else: content = f"[TOOL CALLS]\n{tc_str}" - except (TypeError, ValueError) as e: - logging.getLogger(__name__).debug("tool_calls json serialization failed: %s", e, extra={"source": "app_controller.cb_load_prior_log.tool_calls"}) + else: + self._last_request_errors.append(("tool_calls_json_serialize", tc_result.errors[0])) if content: content += f"\n\n[TOOL CALLS PRESENT]" else: @@ -2266,14 +2367,12 @@ class AppController: self.mma_tier_usage = new_mma_usage self._token_history = new_token_history if new_token_history: - try: - import datetime - first_ts = new_token_history[0]['time'] - dt = datetime.datetime.strptime(first_ts, '%Y-%m-%dT%H:%M:%S') - self._session_start_time = dt.timestamp() - except (ValueError, TypeError, KeyError, IndexError) as e: - logging.getLogger(__name__).debug("token_history first_ts parse failed: %s", e, extra={"source": "app_controller.cb_load_prior_log.token_history"}) + result = self._parse_token_history_first_ts_result(new_token_history[0]) + if result.ok: + self._session_start_time = result.data + else: self._session_start_time = time.time() + self._last_request_errors.append(("token_history_first_ts_parse", result.errors[0])) self.prior_session_entries = entries self.prior_disc_entries = disc_entries self.prior_tool_calls = final_tool_calls @@ -2333,14 +2432,52 @@ class AppController: self.submit_io(run_manual_prune) + def _load_project_from_path_result(self, pp: str) -> "Result[Dict[str, Any]]": + """Phase 6 Group 6.7: load a project from a path with Result propagation. + On failure: OSError/IOError/ValueError/TypeError/KeyError/AttributeError/tomllib.TOMLDecodeError + -> ErrorInfo(original=e). Caller stores the error for sub-track 4 GUI.""" + try: + data = project_manager.load_project(pp) + return Result(data=data) + except (OSError, IOError, ValueError, TypeError, KeyError, AttributeError, tomllib.TOMLDecodeError) as e: + return Result(data={}, errors=[ErrorInfo( + kind=ErrorKind.NOT_FOUND, + message=str(e), + source=f"app_controller._load_project_from_path_result[{pp}]", + original=e, + )]) + + def _save_fallback_project_result(self, fallback_path: str) -> "Result[None]": + """Phase 6 Group 6.7: save fallback project with Result propagation. + On failure: OSError/IOError/PermissionError/RuntimeError -> ErrorInfo(original=e). + The save is best-effort; the app continues even on failure + (active_project_path stays empty; next save will use a proper path). + Per post-completion patch cb68d86f: catches RuntimeError too + (test sandbox FR1 guard raises RuntimeError).""" + try: + project_manager.save_project(self.project, fallback_path) + self.active_project_path = fallback_path + if fallback_path not in self.project_paths: + self.project_paths.append(fallback_path) + return OK + except (OSError, IOError, PermissionError, RuntimeError) as e: + return Result(data=None, errors=[ErrorInfo( + kind=ErrorKind.INTERNAL, + message=str(e), + source=f"app_controller._save_fallback_project_result[{fallback_path}]", + original=e, + )]) + def _load_active_project(self) -> Result[None]: """Loads the active project configuration, with fallbacks.""" + aggregated_errors: List[ErrorInfo] = [] if self.active_project_path and Path(self.active_project_path).exists(): - try: - self.project = project_manager.load_project(self.active_project_path) - except (OSError, IOError, ValueError, TypeError, KeyError, AttributeError, tomllib.TOMLDecodeError) as e: - logging.getLogger(__name__).debug("load_project failed: %s", e, extra={"source": "app_controller._load_active_project.primary"}) - print(f"Failed to load project {self.active_project_path}: {e}") + result = self._load_project_from_path_result(self.active_project_path) + if result.ok: + self.project = result.data + else: + aggregated_errors.append(result.errors[0]) + print(f"Failed to load project {self.active_project_path}: {result.errors[0].message}") self.project = project_manager.migrate_from_legacy_config(self.config) self.active_project_path = "" else: @@ -2349,36 +2486,28 @@ class AppController: if not self.active_project_path: for pp in self.project_paths: if Path(pp).exists(): - try: - self.project = project_manager.load_project(pp) + result = self._load_project_from_path_result(pp) + if result.ok: + self.project = result.data self.active_project_path = pp break - except (OSError, IOError, ValueError, TypeError, KeyError, AttributeError, tomllib.TOMLDecodeError) as e: - logging.getLogger(__name__).debug("load_project failed for %s: %s", pp, e, extra={"source": "app_controller._load_active_project.fallback_loop"}) - continue + aggregated_errors.append(result.errors[0]) + continue if not self.active_project_path: name = self.project.get("project", {}).get("name", "project") fallback_path = f"{name}.toml" - try: - project_manager.save_project(self.project, fallback_path) - self.active_project_path = fallback_path - if fallback_path not in self.project_paths: - self.project_paths.append(fallback_path) - except (OSError, IOError, PermissionError, RuntimeError) as e: - logging.getLogger(__name__).debug( - "Could not save fallback project to %s: %s", fallback_path, e, - extra={"source": "app_controller._load_active_project.fallback_save"}, - ) - # The save is best-effort; the app can still operate without persisting - # the empty fallback (e.g., when the test sandbox FR1 guard blocks - # writes to the project root via the sys.addaudithook RuntimeError). - # active_project_path stays empty; the next save will use a proper path. + save_result = self._save_fallback_project_result(fallback_path) + if not save_result.ok: + aggregated_errors.append(save_result.errors[0]) self.preset_manager = presets.PresetManager(Path(self.active_project_path).parent if self.active_project_path else None) self.tool_preset_manager = tool_presets.ToolPresetManager(Path(self.active_project_path).parent if self.active_project_path else None) from src.personas import PersonaManager self.persona_manager = PersonaManager(Path(self.active_project_path).parent if self.active_project_path else None) self._refresh_from_project() self._configure_mcp_for_project() + if aggregated_errors: + return Result(data=None, errors=aggregated_errors) + return OK def inject_context(self, data: dict) -> None: """ @@ -2600,17 +2729,32 @@ class AppController: def _run_event_loop(self): """Internal loop runner.""" - def queue_fallback() -> None: + def queue_fallback() -> "Result[None]": while True: - try: - 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 (OSError, IOError, ValueError, TypeError, KeyError, AttributeError, RuntimeError) as e: - logging.getLogger(__name__).debug("queue fallback task error: %s", e, extra={"source": "app_controller.queue_fallback"}) + result = self._run_pending_tasks_once_result() + if not result.ok: + self._report_worker_error("queue_fallback", result) time.sleep(0.1) self.submit_io(queue_fallback) + + def _run_pending_tasks_once_result(self) -> "Result[None]": + """Phase 6 Group 6.7 (bounded retry Pattern 5 drain): single iteration of + the queue-fallback loop. On failure: any handler exception -> ErrorInfo. + Caller (`queue_fallback`) reports via _report_worker_error; the bounded + retry loop IS the Pattern 5 drain.""" + try: + if hasattr(self, '_process_pending_gui_tasks'): + self._process_pending_gui_tasks() + if hasattr(self, '_process_pending_history_adds'): + self._process_pending_history_adds() + return OK + except (OSError, IOError, ValueError, TypeError, KeyError, AttributeError, RuntimeError) as e: + return Result(data=None, errors=[ErrorInfo( + kind=ErrorKind.INTERNAL, + message=str(e), + source="app_controller._run_pending_tasks_once_result", + original=e, + )]) self._process_event_queue() @property @@ -2824,13 +2968,9 @@ class AppController: cleaned_proj = project_manager.clean_nones(proj) if self.active_project_path: - try: - project_manager.save_project(cleaned_proj, self.active_project_path) - except (OSError, IOError, PermissionError, RuntimeError) as e: - logging.getLogger(__name__).debug( - "Could not save project to %s: %s", self.active_project_path, e, - extra={"source": "app_controller._flush_to_project"}, - ) + result = self._flush_to_project_result(cleaned_proj, self.active_project_path) + if not result.ok: + self._last_request_errors.append(("flush_to_project", result.errors[0])) else: logging.getLogger(__name__).debug( "Skipping _flush_to_project: active_project_path is empty.", @@ -3124,19 +3264,14 @@ class AppController: self.mma_tier_usage[tier]["tool_preset"] = data.get("tool_preset", self.mma_tier_usage[tier].get("tool_preset")) at_data = mma_sec.get("active_track") if at_data: - try: - tickets = [] - for t_data in at_data.get("tickets", []): - tickets.append(models.Ticket(**t_data)) - self.active_track = models.Track( - id=at_data.get("id"), - description=at_data.get("description"), - tickets=tickets - ) + result = self._deserialize_active_track_result(at_data) + if result.ok: + self.active_track = result.data self.active_tickets = at_data.get("tickets", []) # Keep dicts for UI table - except (TypeError, ValueError, KeyError, AttributeError) as e: - logging.getLogger(__name__).debug("active track deserialize failed: %s", e, extra={"source": "app_controller._refresh_from_project.active_track"}) - print(f"Failed to deserialize active track: {e}") + else: + err = result.errors[0] + self._last_request_errors.append(("active_track_deserialize", err)) + print(f"Failed to deserialize active track: {err.message}") self.active_track = None else: self.active_track = None