refactor(app_controller): migrate 4 remaining helper sites to Result (Phase 6 Group 6.7 final)

Migrates the final 4 silent-swallow sites:
- tool_calls json serialization (cb_load_prior_log) via _serialize_tool_calls_result
- queue_fallback bounded retry (Pattern 5 drain) via _run_pending_tasks_once_result
- _refresh_from_project.active_track deserialize via _deserialize_active_track_result
- _flush_to_project (FR1 guard) via _flush_to_project_result

Audit gate: INTERNAL_SILENT_SWALLOW for src/app_controller.py: 4 -> 0.
Per-site count = 0 (Phase 6 hard gate satisfied).
This commit is contained in:
ed
2026-06-19 16:05:36 -04:00
parent 90b20879d2
commit fab1a28a6e
+212 -77
View File
@@ -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