# Plan: Result Migration — Sub-Track 3 (App Controller)

**Sub-track:** `result_migration_app_controller_20260618` (3rd of 5 sub-tracks)
**Umbrella:** `result_migration_20260616`
**Date:** 2026-06-18
**Owner:** Tier 2 Tech Lead
**Base commit:** `5107f3ca` (merge of `tier2/live_gui_test_fixes_20260618` into `tier2/result_migration_small_files_20260617`)

---

## Phase 1: Setup + Fix the regression (highest priority)

Focus: register the sub-track, then immediately fix the 2 known regressions (test_tool_ask_approval + test_execution_sim_live) so subsequent phases can run against a green tier-3-live_gui.

### Task 1.1: Create sub-track folder
- **WHERE:** `conductor/tracks/result_migration_app_controller_20260618/`
- **WHAT:** spec.md (exists), plan.md (this file), metadata.json, state.toml
- **HOW:** Write the 3 new files following the umbrella spec pattern. The spec.md is already written by Tier 1.
- **SAFETY:** None (new files only).
- **COMMIT:** `conductor(track): spec/plan/metadata/state for result_migration_app_controller_20260618`
- **GIT NOTE:** Summary of sub-track 3 scope; references the 2 known regressions.

### Task 1.2: Update `conductor/tracks.md`
- **WHERE:** `conductor/tracks.md` (after the umbrella row, before sub-track 4)
- **WHAT:** Add a row for the new sub-track
- **HOW:** Same pattern as the umbrella and the existing sub-tracks
- **SAFETY:** None (documentation only).
- **COMMIT:** `conductor: register result_migration_app_controller_20260618 in tracks.md`
- **GIT NOTE:** 1-sentence note

- [x] **Task 1.3: Fix `_offload_entry_payload` call site (Regression 1)** [26e57577]

### Task 1.3: Fix `_offload_entry_payload` call site (Regression 1)
- **WHERE:** `src/app_controller.py:3709-3725` (`_offload_entry_payload` method)
- **WHAT:** Unwrap the `Result` returned by `session_logger.log_tool_output` and `session_logger.log_tool_call`. The current code does `Path(ref_path).name` where `ref_path` is a `Result` object — `Path()` expects a string.
- **HOW:** Per FR5 in spec.md:
  ```python
  def _offload_entry_payload(self, entry: Dict[str, Any]) -> Dict[str, Any]:
      optimized = copy.deepcopy(entry)
      kind = optimized.get("kind")
      payload = optimized.get("payload", {})
      if kind == "tool_result" and "output" in payload:
          output = payload["output"]
          ref_result = session_logger.log_tool_output(output)
          if ref_result.ok and ref_result.data:
              filename = Path(ref_result.data).name
              payload["output"] = f"[REF:{filename}]"
          elif ref_result.errors:
              logging.debug("offload tool_output failed: %s", ref_result.errors[0].ui_message())
      if kind == "tool_call" and "script" in payload:
          script = payload["script"]
          ref_result = session_logger.log_tool_call(script, "LOG_ONLY", None)
          if ref_result.ok and ref_result.data:
              filename = Path(ref_result.data).name
              payload["script"] = f"[REF:{filename}]"
          elif ref_result.errors:
              logging.debug("offload tool_call failed: %s", ref_result.errors[0].ui_message())
      return optimized
  ```
- **SAFETY:** The function signature is unchanged. The optimization (small payload via `[REF:filename]`) is preserved for both success and failure paths. The error path now logs at `logging.debug` (per Heuristic #19); on success the file content is referenced.
- **VERIFY:** `uv run python -m pytest tests/test_app_controller_offloading.py tests/test_tool_presets_execution.py -v` — `test_tool_ask_approval` passes; `test_on_comms_entry_tool_result_offloading` still passes.
- **COMMIT:** `fix(app_controller): _offload_entry_payload unwraps Result from session_logger (regression fix)`
- **GIT NOTE:** Closes the regression in `test_tool_ask_approval`; the `session_logger.log_tool_call` was partially migrated to return `Result` but the call site was not updated. The convention's "AND over OR" pattern handles it here.

- [x] **Task 1.4: Add test for the unwrap path** [4b07e934]

### Task 1.4: Add test for the unwrap path
- **WHERE:** `tests/test_app_controller_offloading.py` (existing file; add 2 new tests)
- **WHAT:** Add 2 tests:
  1. `test_offload_unwraps_result_success` — verify that when `log_tool_output` returns a successful `Result[data=path]`, the payload gets `[REF:filename]`.
  2. `test_offload_logs_debug_on_result_errors` — verify that when `log_tool_output` returns a `Result` with errors, a `logging.debug` is emitted and the payload is unchanged.
- **HOW:** Mock `session_logger.log_tool_output` and `log_tool_call` to return `Result` objects; assert the payload and the log call.
- **SAFETY:** Test-only changes; no production risk.
- **VERIFY:** The 2 new tests pass; existing 2 offloading tests still pass.
- **COMMIT:** `test(app_controller): offloading - verify Result unwrap in success and error paths`
- **GIT NOTE:** Tests for FR5; covers the regression from task 1.3.

- [x] **Task 1.5: Run the regression test and confirm both fixes** [7b823fd0]

### Task 1.5: Run the regression test and confirm both fixes
- **COMMAND:** `uv run python -m pytest tests/test_tool_presets_execution.py::test_tool_ask_approval tests/test_extended_sims.py::test_execution_sim_live -v`
- **EXPECT:** Both pass.
- **COMMIT:** No commit (verification only).
- **NOTE:** If `test_execution_sim_live` still fails, investigate the failure mode (may be a separate issue from Regression 1).

- [x] **Task 1.6: Phase 1 checkpoint commit** [7b823fd0]

### Task 1.6: Phase 1 checkpoint commit
- **COMMIT:** `conductor(plan): mark Phase 1 complete (regression fix)`
- **GIT NOTE:** Phase 1 = 2 known regressions fixed; verified by `test_tool_ask_approval` + `test_execution_sim_live`. Now safe to proceed with the bulk migration.

---

## Phase 2: Migrate the 32 INTERNAL_BROAD_CATCH sites (bulk)

Focus: the main migration work. 32 sites, organized into 4 sub-batches by context (callback handlers, project ops, conductor ops, GUI tasks). Each sub-batch is 6-10 sites touching the same file; one commit per sub-batch.

- [x] **Task 2.1: Create `tests/test_app_controller_result.py` (the new test file)** [142d0474]

### Task 2.1: Create `tests/test_app_controller_result.py` (the new test file)
- **WHERE:** `tests/test_app_controller_result.py` (NEW)
- **WHAT:** 5+ tests verifying Result return types for the migrated methods (placeholder tests that will be filled in as the migrations land). Initial tests can be:
  1. `test_offload_entry_payload_returns_dict` — sanity check.
  2. `test_migrated_method_returns_result_when_no_error` — pattern template.
  3. `test_migrated_method_returns_result_with_error_on_failure` — pattern template.
  4. `test_migrated_method_never_raises_exception` — verifies the broad-catch is gone.
  5. `test_offload_entry_payload_preserves_unchanged_payload` — verifies the no-op path.
- **HOW:** Import `Result`, `ErrorInfo`, `ErrorKind` from `src.result_types`. Model on `tests/test_ai_client_result.py`.
- **SAFETY:** Test-only changes; no production risk.
- **COMMIT:** `test(app_controller): scaffold tests/test_app_controller_result.py with 5 Result-pattern tests`
- **GIT NOTE:** The 5 tests use generic placeholders that become specific per migration in subsequent tasks. The scaffolding defines the pattern.

- [x] **Task 2.2: Migrate batch 1 — callback handlers (5 sites; spec says 4 + 1 nested in cb_load_prior_log)** [6333e0e6]

### Task 2.2: Migrate batch 1 — callback handlers (4 sites)
- **WHERE:** `src/app_controller.py:537 (_handle_custom_callback)`, `:579 (_handle_click)`, `:2046 (cb_load_prior_log)`, `:2068 (cb_load_prior_log)`, `:2081 (cb_load_prior_log)`
- **WHAT:** Convert `except Exception as e: pass` (or `print(...)`) to `except <SpecificException> as e: return Result(data=None, errors=[...])`. The callback may need to return a `Result`; if the caller doesn't use the return value, wrap the body in a `try/except` that returns a result and is logged.
- **HOW:** For each site:
  1. Read the snippet + 3 lines of context with `get_file_slice`.
  2. Identify the specific exception (KeyError? AttributeError? OSError?).
  3. Add `from src.result_types import Result, ErrorInfo, ErrorKind` if not imported.
  4. Replace the broad `except Exception` with the specific one.
  5. Return a `Result` with the appropriate data and errors.
- **SAFETY:** The callback's caller may not be Result-aware; the migration may need to update the caller's signature. Track this in the plan task description.
- **VERIFY:** The 4 migrated sites + the 2 new tests in `test_app_controller_result.py` pass.
- **COMMIT:** `refactor(app_controller): migrate 5 callback sites to Result (batch 1)`
- **GIT NOTE:** Specific exceptions caught per site; Result return type.

### Task 2.3: Migrate batch 2 — project ops (5 sites)
- **WHERE:** `src/app_controller.py:2129 (run_manual_prune)`, `:2140 (_load_active_project)`, `:2154 (_load_active_project)`, `:2195 (run_prune)`, `:2890 (_refresh_from_project)`, `:2944 (_save_active_project)`
- **WHAT:** Same pattern as 2.2
- **SAFETY:** Project ops have side effects (file I/O). The migration must preserve the side-effect semantics while changing the error reporting.
- **VERIFY:** Project-op tests + the 2 new Result tests pass.
- **COMMIT:** `refactor(app_controller): migrate 6 project-op sites to Result (batch 2)`
- **GIT NOTE:** Project ops side effects preserved; Result error reporting added.

### Task 2.4: Migrate batch 3 — conductor / track ops (8 sites)
- **WHERE:** `src/app_controller.py:3057 (_run)`, `:3084 (do_fetch)`, `:3094 (do_fetch)`, `:4237 (_start_track_logic)`, `:4349 (_cb_run_conductor_setup)`, `:4446 (_cb_load_track)`, `:4475 (_push_mma_state_update)`, `:4504 (_load_active_tickets)`
- **WHAT:** Same pattern as 2.2
- **SAFETY:** Conductor ops interact with the MMA state. The migration must NOT change the state-mutation order; only the error reporting.
- **VERIFY:** MMA tests + the 2 new Result tests pass.
- **COMMIT:** `refactor(app_controller): migrate 8 conductor/track sites to Result (batch 3)`
- **GIT NOTE:** Conductor ops state order preserved; Result error reporting added.

### Task 2.5: Migrate batch 4 — worker / task ops (8 sites)
- **WHERE:** `src/app_controller.py:3434 (worker)`, `:3471 (worker)`, `:3542 (worker)`, `:3635 (_handle_request_event)`, `:3648 (_handle_request_event)`, `:4070 (_bg_task)`, `:4100 (_bg_task)`, `:1669 (_process_pending_gui_tasks)`, `:1420 (_update_inject_preview)`, `:1480 (_do_rag_sync)`, `:1947 (replace_ref)`
- **WHAT:** Same pattern as 2.2
- **SAFETY:** Worker / task ops run on background threads. The migration must be thread-safe (no shared mutable state changes that aren't already locked).
- **VERIFY:** Worker tests + the 2 new Result tests pass.
- **COMMIT:** `refactor(app_controller): migrate 11 worker/task sites to Result (batch 4)`
- **GIT NOTE:** Worker ops thread safety preserved; Result error reporting added.

### Task 2.6: Phase 2 checkpoint commit
- **COMMIT:** `conductor(plan): mark Phase 2 complete (32 INTERNAL_BROAD_CATCH sites migrated)`
- **GIT NOTE:** Phase 2 = 32 broad-catch sites migrated; the audit's `INTERNAL_BROAD_CATCH` count for `app_controller.py` is now 0.

---

## Phase 3: Migrate the 8 INTERNAL_SILENT_SWALLOW sites

Focus: add `logging.debug` per Heuristic #19; convert return to `Result[T]`.

### Task 3.1: Migrate SIGINT and timeline sites (3 sites)
- **WHERE:** `src/app_controller.py:751 (_on_sigint)`, `:756 (_install_sigint_exit_handler)`, `:1294 (mark_first_frame_rendered)`, `:1376 (_on_warmup_complete_for_timeline)`
- **WHAT:** Add `logging.debug("swallowed exception: %s", e, extra={"source": "<ctx>"})`; convert return to `Result[None]` (`OK` on success, `Result(data=None, errors=[...])` on swallow).
- **VERIFY:** The 4 sites + the 2 new Result tests pass.
- **COMMIT:** `refactor(app_controller): migrate 4 SIGINT/timeline sites to Result with debug logging (silent swallow batch 1)`
- **GIT NOTE:** Heuristic #19 satisfied; Result error side-channel.

### Task 3.2: Migrate MCP and worker sites (4 sites)
- **WHERE:** `src/app_controller.py:1566 (mcp_config_json)`, `:2389 (queue_fallback)`, `:4098 (_bg_task)`, `:4192 (_start_track_logic)`
- **WHAT:** Same pattern as 3.1
- **VERIFY:** The 4 sites + the 2 new Result tests pass.
- **COMMIT:** `refactor(app_controller): migrate 4 MCP/worker sites to Result with debug logging (silent swallow batch 2)`
- **GIT NOTE:** Heuristic #19 satisfied; Result error side-channel.

### Task 3.3: Phase 3 checkpoint commit
- **COMMIT:** `conductor(plan): mark Phase 3 complete (8 INTERNAL_SILENT_SWALLOW sites migrated)`
- **GIT NOTE:** Phase 3 = 8 silent-swallow sites migrated; the audit's `INTERNAL_SILENT_SWALLOW` count for `app_controller.py` is now 0.

---

## Phase 4: Classify 4 INTERNAL_RETHROW + migrate 1 INTERNAL_OPTIONAL_RETURN

Focus: the smaller, judgment-required categories. Each is a per-site decision.

### Task 4.1: Classify the 2 `__getattr__` rethrow sites
- **WHERE:** `src/app_controller.py:1225 (__getattr__)`, `:1251 (__getattr__)`
- **WHAT:** Read the snippet + 3 lines of context. Determine pattern:
  - If catching + re-raising the SAME exception: SUSPICIOUS, migrate to Result.
  - If catching + re-raising as a different type (e.g., AttributeError → KeyError): legitimate, stay.
  - If catching + adding context (logging) + re-raising: legitimate, stay; add `logging.debug` per Heuristic #19.
- **SAFETY:** `__getattr__` is part of Python's attribute lookup protocol. Removing the try/except changes the behavior for `hasattr` and other introspection. The migration must preserve the lookup semantics.
- **VERIFY:** `tests/test_app_getattr_hasattr_bug.py` and `tests/test_app_controller_getattr_ui_bug.py` pass.
- **COMMIT:** `refactor(app_controller): classify __getattr__ rethrow sites (Pattern 1/2/3 or migrate)`
- **GIT NOTE:** Per-site rationale documented in the commit body.

### Task 4.2: Classify the 2 `load_context_preset` rethrow sites
- **WHERE:** `src/app_controller.py:2983 (load_context_preset)`, `:2986 (load_context_preset)`
- **WHAT:** Same pattern analysis as 4.1
- **VERIFY:** Context preset tests pass.
- **COMMIT:** `refactor(app_controller): classify load_context_preset rethrow sites (Pattern 1/2/3 or migrate)`
- **GIT NOTE:** Per-site rationale documented in the commit body.

### Task 4.3: Migrate the `cold_start_ts` Optional site
- **WHERE:** `src/app_controller.py:1358 (cold_start_ts)`
- **WHAT:** Read the call sites to determine the right shape (nil-sentinel vs `Result[int]`). Then implement per FR4.
- **HOW:**
  1. Grep for `cold_start_ts` call sites (expect 1-3).
  2. For each call site, determine if it uses `if x is not None:` or has separate "set" vs "missing" semantics.
  3. If "set vs missing" matters: use `Result[int]`.
  4. If "zero is a valid value": use a frozen `@dataclass ColdStartTs: value: int = 0; set: bool = False; NIL_COLD_START_TS = ColdStartTs()`.
  5. If neither: use `Optional[int]` → `Result[int]` (the convention says `Optional[T]` for "might fail" is an anti-pattern).
- **VERIFY:** Warmup tests pass.
- **COMMIT:** `refactor(app_controller): migrate cold_start_ts from Optional[int] to Result[int] (per call-site shape)`
- **GIT NOTE:** Shape chosen based on call-site semantics.

### Task 4.4: Phase 4 checkpoint commit
- **COMMIT:** `conductor(plan): mark Phase 4 complete (4 INTERNAL_RETHROW classified, 1 INTERNAL_OPTIONAL_RETURN migrated)`
- **GIT NOTE:** Phase 4 = 5 sites (4 rethrow + 1 optional) resolved; the audit's `INTERNAL_RETHROW` and `INTERNAL_OPTIONAL_RETURN` counts for `app_controller.py` are now 0.

---

## Phase 5: Verify, document, end-of-track report

Focus: confirm all 45 migration-target sites are migrated; re-run batched suite; write the end-of-track report.

### Task 5.1: Re-run audit and confirm zero migration sites
- **COMMAND:** `uv run python scripts/audit_exception_handling.py --by-size`
- **EXPECT:** `src/app_controller.py (V=15, S=0, ?=0, C=4, total=19)` — the 15 BOUNDARY_FASTAPI + 2 BOUNDARY_SDK + 4 INTERNAL_COMPLIANT + 1 INTERNAL_PROGRAMMER_RAISE = 22 stay (the audit may bucket BOUNDARY_FASTAPI and BOUNDARY_SDK differently — verify the actual count structure).
- **COMMIT:** No commit (verification only).

### Task 5.2: Run targeted tests
- **COMMAND:** `uv run python -m pytest tests/test_app_controller_result.py tests/test_app_controller_offloading.py tests/test_tool_presets_execution.py tests/test_extended_sims.py tests/test_audit_exception_handling_heuristics.py -v`
- **EXPECT:** All pass.
- **COMMIT:** No commit (verification only).

### Task 5.3: Run the full batched suite
- **COMMAND:** `uv run python scripts/run_tests_batched.py`
- **EXPECT:** 882 passed / 17 skipped / 2 xfailed (same as before this track, except the 2 previously-failing tests now pass).
- **COMMIT:** No commit (verification only).
- **NOTE:** If new failures appear, fix forward or skip with documented reason (per the "Report-Instead-of-Fix" anti-pattern rule: do not commit a fix that has only been verified in isolation).

### Task 5.4: Add audit-heuristics tests for the 2 new app_controller categories
- **WHERE:** `tests/test_audit_exception_handling_heuristics.py` (existing file)
- **WHAT:** Add 2 tests:
  1. `test_app_controller_post_migration_has_zero_broad_catch` — runs the audit and asserts that the 32 INTERNAL_BROAD_CATCH sites are gone (or re-classified to COMPLIANT).
  2. `test_app_controller_post_migration_has_zero_silent_swallow` — same for the 8 INTERNAL_SILENT_SWALLOW sites.
- **SAFETY:** The audit script may emit transient counts during the migration; these tests are run only at the end of Phase 5 (after all migrations land).
- **COMMIT:** `test(audit): add post-migration assertions for app_controller categories`
- **GIT NOTE:** Locks in the post-migration invariant.

### Task 5.5: Write the end-of-track report
- **WHERE:** `docs/reports/TRACK_COMPLETION_result_migration_app_controller_20260618.md` (NEW)
- **WHAT:** 7-section markdown report (per the 2026-06-17 convention):
  1. Header (track, branch, dates, scope, commit count)
  2. Tasks completed (per phase)
  3. Audit results (pre vs post)
  4. Last 3 failures (Regression 1 + Regression 2 details)
  5. Files modified (1 source + 2 tests + 4 metadata/plan/state)
  6. Git state (`git log` summary)
  7. Recommendation (next sub-track — sub-track 4 `gui_2`)
- **COMMIT:** `docs(reports): TRACK_COMPLETION_result_migration_app_controller_20260618`
- **GIT NOTE:** End-of-track report for the user to review.

### Task 5.6: Mark state.toml complete + update umbrella
- **WHERE:** `conductor/tracks/result_migration_app_controller_20260618/state.toml`, `conductor/tracks/result_migration_20260616/spec.md` (line 256)
- **WHAT:**
  1. `state.toml` — set `status = "completed"`, `current_phase = "complete"`.
  2. `spec.md` (umbrella) — update line 256 to reflect the actual count (45 migration + 22 stay = 67 total, NOT the estimated 22 + 34 = 56). Add a note that the audit's per-category output is the source of truth, not the T-shirt-size estimate.
- **COMMIT:** `conductor(plan): mark result_migration_app_controller_20260618 as complete; update umbrella count`
- **GIT NOTE:** Sub-track 3 complete; the umbrella's count is updated to reflect the actual scope.

---

## Phase 6 Addendum: Proper `Result[T]` migration of the 28 INTERNAL_SILENT_SWALLOW sites

Focus: replace every `except ...: logging.debug(...); <local side effect>` body with proper `Result[T]` propagation. The 8 sites that Phase 3 "migrated" with `logging.debug` did not satisfy the convention (per `error_handling.md:530` — logging is NOT a drain). Phase 6 fixes all 28 sites with real `Result` propagation + real drain points.

**Audit gate:** `uv run python scripts/audit_exception_handling.py --src src/app_controller.py --strict` exits 0.

**Pattern reference (per `error_handling.md:530`):** A `logging.*` call in an except body is `INTERNAL_SILENT_SWALLOW` (a violation). The only acceptable patterns are:
1. Return `Result(data=..., errors=[ErrorInfo(original=e)])` from the function
2. Reach a real drain point: HTTPException (Pattern 1), GUI display (Pattern 2), os._exit (Pattern 3), telemetry emission (Pattern 4), bounded retry (Pattern 5)

### Sub-phase 6.1: Signal handlers (Pattern 3 drain: os._exit) — 2 sites

**Task 6.1.1: Migrate `_on_sigint` (L772) and `_install_sigint_exit_handler` (L777)**
- **WHERE:** `src/app_controller.py:769-778`
- **WHAT:** Extract `_shutdown_io_pool_result(self) -> Result[None]` helper. The inner signal handler `_on_sigint` calls the helper and:
  ```python
  def _on_sigint(signum, frame):
      result = controller._shutdown_io_pool_result()
      if not result.ok:
          sys.stderr.write(f"FATAL: {result.errors[0].ui_message()}\n")
          sys.stderr.flush()
      os._exit(0)  # Pattern 3 drain: intentional termination
  ```
  The outer `_install_sigint_exit_handler` becomes `_install_signal_handler_result(self) -> Result[None]`; the function call site at `AppController.__init__` (L828) stores `self._signal_handler_error = result.errors[0] if not result.ok else None`.
- **SAFETY:** Signal handlers cannot return values to callers; the `os._exit(0)` is the terminal drain. The stderr write before exit is part of the termination pattern (Heuristic D match for Pattern 3).
- **VERIFY:** New tests `test_on_sigint_writes_stderr_on_io_pool_failure` + `test_install_signal_handler_stores_error_on_failure`. Run audit.
- **COMMIT:** `refactor(app_controller): migrate 2 signal handler sites to Result (Pattern 3 drain via os._exit)`
- **GIT NOTE:** Replaces Phase 3's `logging.debug` add at L772/L777 with proper Result propagation.

### Sub-phase 6.2: Event sinks / one-shot best-effort logging — 2 sites

**Task 6.2.1: Migrate `mark_first_frame_rendered` (L1315) and `_on_warmup_complete_for_timeline` (L1411)**
- **WHERE:** `src/app_controller.py:1294-1316` and `:1396-1412`
- **WHAT:** Extract `_log_startup_timeline_event_result(self, event_kind: str) -> Result[None]` helper. Both functions call the helper instead of inline `sys.stderr.write + logging.debug`. The errors are appended to `self._startup_timeline_errors: list[ErrorInfo]` for sub-track 4's GUI display; the helper itself writes to stderr (user-confirmed acceptable terminal drain until sub-track 4).
- **SAFETY:** These are event sinks (called once per app lifecycle event). The helper preserves the original stderr output for humans tailing the logs.
- **VERIFY:** New tests `test_mark_first_frame_carries_error_in_state` + `test_warmup_complete_carries_error_in_state`. Run audit.
- **COMMIT:** `refactor(app_controller): migrate 2 timeline-event sites to Result (event sink with stderr carry)`
- **GIT NOTE:** Replaces Phase 3's `logging.debug` add at L1315/L1411 with proper Result propagation + instance state carry.

### Sub-phase 6.3: GUI state setters / property setters — 3 sites

**Task 6.3.1: Migrate `_update_inject_preview` (L1456)**
- **WHERE:** `src/app_controller.py:1430-1458`
- **WHAT:** Function becomes `_update_inject_preview_result(self) -> Result[str]`. Caller (gui_2.py render fn, deferred to sub-track 4) checks `.ok`. Until then, the immediate caller in `gui_2.py` (find via `py_find_usages src.app_controller.AppController._update_inject_preview`) writes `result.errors[0].ui_message()` to stderr. In `app_controller.py`, add a thin wrapper `_update_inject_preview(self) -> None` that calls `_update_inject_preview_result` and stores `self._inject_preview_error: ErrorInfo | None`; the legacy call sites still work.
- **VERIFY:** New test `test_update_inject_preview_returns_result_with_error_on_read_failure`.
- **COMMIT:** `refactor(app_controller): _update_inject_preview returns Result[str] (silent swallow site 1)`

**Task 6.3.2: Migrate `mcp_config_json` setter (L1604)**
- **WHERE:** `src/app_controller.py:1599-1606`
- **WHAT:** Add sibling `_set_mcp_config_json_result(self, value: str) -> Result[None]`. The property setter becomes:
  ```python
  @mcp_config_json.setter
  def mcp_config_json(self, value: str) -> None:
      result = self._set_mcp_config_json_result(value)
      if not result.ok:
          self._mcp_config_parse_error = result.errors[0]
          sys.stderr.write(f"mcp_config parse failed: {result.errors[0].ui_message()}\n")
          sys.stderr.flush()
  ```
- **VERIFY:** New test `test_mcp_config_setter_stores_error_on_parse_failure`.
- **COMMIT:** `refactor(app_controller): mcp_config_json setter returns Result via sibling helper (silent swallow site 2)`

**Task 6.3.3: Migrate `_save_active_project` (L3024)**
- **WHERE:** `src/app_controller.py:3016-3027`
- **WHAT:** Function becomes `_save_active_project_result(self) -> Result[None]`. The wrapper `_save_active_project(self) -> None` calls the result variant; on failure, stores `self._save_project_error: ErrorInfo | None` and writes to stderr.
- **VERIFY:** New test `test_save_active_project_returns_result_with_error_on_io_failure`.
- **COMMIT:** `refactor(app_controller): _save_active_project returns Result[None] (silent swallow site 3)`

### Sub-phase 6.4: SDK boundary — 1 site

**Task 6.4.1: Migrate `_fetch_models.do_fetch` (L3173)**
- **WHERE:** `src/app_controller.py:3168-3190`
- **WHAT:** Add `_list_models_for_provider_result(self, p: str) -> Result[list]` helper that wraps `ai_client.list_models(p)` and converts SDK exceptions to `ErrorInfo(kind=ErrorKind.NETWORK/PERMISSION/AUTH, ...)`. The `do_fetch` function accumulates per-provider results in `self._model_fetch_errors: dict[str, ErrorInfo]` and returns `Result[None]` with the aggregated errors. Per-provider failures don't block the overall fetch (the user can still see models from providers that worked).
- **SAFETY:** SDK boundary (the `ai_client.list_models()` call) is the right place to catch and convert per `error_handling.md` §"Boundary Types".
- **VERIFY:** New test `test_fetch_models_aggregates_per_provider_errors_into_result`.
- **COMMIT:** `refactor(app_controller): _fetch_models.do_fetch accumulates per-provider Result (SDK boundary)`

### Sub-phase 6.5: Background workers / threads — 10 sites

**Task 6.5.1: Migrate `_handle_compress_discussion.worker` (L3532) and the 2 other `worker` closures (L3570, L3642)**
- **WHERE:** `src/app_controller.py:3471-3535`, `:3535-3542`, `:3542-3570` (or wherever the 3 `worker` keyword closures live)
- **WHAT:** Each `worker` closure returns `Result[None]`. The outer function that calls `self.submit_io(worker)` wraps with a completion handler that checks `result.ok`; on failure, calls `_report_worker_error(op_name, result)` which writes to stderr and appends to `self._worker_errors: list[tuple[str, ErrorInfo]]` (Pattern 4 telemetry drain — `self._worker_errors` is the in-process telemetry buffer; sub-track 4 forwards to GUI).
- **SAFETY:** Background threads; the worker closures cannot mutate shared state without locks. The `_report_worker_error` helper uses `self._worker_errors_lock` (new lock) for append.
- **VERIFY:** New tests `test_worker_reports_error_via_result_on_failure` (one per worker site, parameterized).
- **COMMIT:** `refactor(app_controller): 3 worker closures return Result and report errors via _report_worker_error (sub-batch 1)`

**Task 6.5.2: Migrate `_bg_task` (L4175, L4204, L4207)**
- **WHERE:** `src/app_controller.py:4175, 4204, 4207`
- **WHAT:** Same pattern as 6.5.1. The 3 sites in `_bg_task` each become `Result[None]`-returning sub-tasks; the wrapper calls `_report_worker_error` on each failure.
- **VERIFY:** New test `test_bg_task_reports_error_via_result_on_failure` (parameterized over the 3 sites).
- **COMMIT:** `refactor(app_controller): _bg_task 3 sites return Result (sub-batch 2)`

**Task 6.5.3: Migrate `_start_track_logic` (L4300, L4346)**
- **WHERE:** `src/app_controller.py:4300, 4346`
- **WHAT:** Same pattern. The function returns `Result[None]`; on per-step failure, the error is appended to `self._track_logic_errors` (Pattern 4 telemetry).
- **VERIFY:** New test `test_start_track_logic_returns_result_with_error_on_topological_sort_failure`.
- **COMMIT:** `refactor(app_controller): _start_track_logic returns Result (sub-batch 3)`

**Task 6.5.4: Migrate `_cb_run_conductor_setup` (L4459) and `_cb_load_track` (L4557)**
- **WHERE:** `src/app_controller.py:4459, 4557`
- **WHAT:** Same pattern. Each callback returns `Result[None]`; errors reported via `_report_worker_error`.
- **VERIFY:** New tests for both.
- **COMMIT:** `refactor(app_controller): _cb_run_conductor_setup + _cb_load_track return Result (sub-batch 4)`

### Sub-phase 6.6: Per-event handlers — 3 sites

**Task 6.6.1: Migrate `_handle_request_event` RAG + symbol resolution (L3736, L3750)**
- **WHERE:** `src/app_controller.py:3736, 3750`
- **WHAT:** Add `_rag_search_result(self, query: str) -> Result[str]` and `_symbol_resolution_result(self, user_msg: str, file_paths: list) -> Result[str]` helpers. The handler accumulates errors into `self._last_request_errors: list[ErrorInfo]` (drained at end of handler via stderr write + instance state carry for sub-track 4).
- **VERIFY:** New tests `test_handle_request_event_carries_rag_error_in_state` + `test_handle_request_event_carries_symbol_error_in_state`.
- **COMMIT:** `refactor(app_controller): _handle_request_event RAG + symbol sites return Result (event handler)`

**Task 6.6.2: Migrate `_process_pending_gui_tasks` per-task try (L1707)**
- **WHERE:** `src/app_controller.py:1695-1710`
- **WHAT:** The per-task execution becomes a `_execute_gui_task_result(self, task) -> Result[None]` helper. The loop accumulates per-task errors into `self._gui_task_errors: list[tuple[dict, ErrorInfo]]` (one entry per failed task). At end of processing, stderr summary + instance state carry.
- **VERIFY:** New test `test_process_pending_gui_tasks_carries_per_task_errors_in_state`.
- **COMMIT:** `refactor(app_controller): _process_pending_gui_tasks per-task try returns Result`

### Sub-phase 6.7: Helpers / utilities (Result propagates upward) — 6 sites

**Task 6.7.1: Migrate `replace_ref` (L1986)**
- **WHERE:** `src/app_controller.py:1986`
- **WHAT:** Function becomes `replace_ref_result(content: str, ref: str, replacement: str) -> Result[str]`. Caller (the next-level utility) checks `.ok` and propagates.
- **VERIFY:** New test `test_replace_ref_returns_result_with_error_on_string_failure`.
- **COMMIT:** `refactor(app_controller): replace_ref returns Result[str] (helper) `

**Task 6.7.2: Migrate `cb_load_prior_log.token_history` (L2128)**
- **WHERE:** `src/app_controller.py:2128`
- **WHAT:** The try block becomes `_parse_token_history_ts_result(item: dict) -> Result[float]`. The `cb_load_prior_log` wrapper (which already returns `Result[None]`) checks `.ok` and merges errors via `.with_errors([...])`.
- **VERIFY:** New test `test_cb_load_prior_log_propagates_token_history_parse_error`.
- **COMMIT:** `refactor(app_controller): cb_load_prior_log token_history site returns Result`

**Task 6.7.3: Migrate `_load_active_project` primary + fallback (L2195, L2210)**
- **WHERE:** `src/app_controller.py:2195, 2210`
- **WHAT:** The inner try blocks become `_load_project_from_path_result(path: str) -> Result[Project]`. The outer `_load_active_project` (already returns `Result[None]`) iterates, collects `Result` from each, and merges via `.with_errors([...])` so the caller knows there was a partial failure (the fallback worked, but the primary didn't).
- **VERIFY:** New tests `test_load_active_project_carries_partial_failure_error` + `test_load_active_project_fallback_loop_returns_result`.
- **COMMIT:** `refactor(app_controller): _load_active_project primary + fallback return Result (helpers)`

**Task 6.7.4: Migrate `queue_fallback` (L2454)**
- **WHERE:** `src/app_controller.py:2448-2457`
- **WHAT:** The inner try becomes `_run_pending_tasks_once_result(self) -> Result[None]`. The `queue_fallback` outer loop checks `.ok`; on failure, logs to stderr and continues the loop (the fallback IS the bounded retry Pattern 5 drain).
- **VERIFY:** New test `test_queue_fallback_returns_result_on_per_iteration_failure`.
- **COMMIT:** `refactor(app_controller): queue_fallback per-iteration try returns Result (bounded retry drain)`

**Task 6.7.5: Migrate `_refresh_from_project.active_track` (L2969)**
- **WHERE:** `src/app_controller.py:2969`
- **WHAT:** The try block becomes `_deserialize_active_track_result(at_data: dict) -> Result[Track]`. The outer `_refresh_from_project` (already returns `Result[None]`) merges errors via `.with_errors([...])`.
- **VERIFY:** New test `test_refresh_from_project_propagates_active_track_deserialize_error`.
- **COMMIT:** `refactor(app_controller): _refresh_from_project active_track deserialize returns Result`

### Sub-phase 6.8: Tests + verification — 1 audit gate

**Task 6.8.1: Run audit and verify zero INTERNAL_SILENT_SWALLOW sites**
- **COMMAND:** `uv run python scripts/audit_exception_handling.py --src src/app_controller.py --strict`
- **EXPECT:** Exit 0; output shows 0 sites in INTERNAL_SILENT_SWALLOW category.
- **COMMIT:** No commit (verification only).
- **NOTE:** If exit 1, identify the leftover sites and add tasks to Phase 6 (do not declare Phase 6 complete).

**Task 6.8.2: Run full batched suite**
- **COMMAND:** `uv run python scripts/run_tests_batched.py`
- **EXPECT:** Same 890 passed / 17 skipped / 2 xfailed as Tier 2's pre-Phase-6 baseline.
- **COMMIT:** No commit (verification only).
- **NOTE:** If new failures appear, fix forward (do not loop; read code, predict, fix once, report).

**Task 6.8.3: Add audit-heuristic test for the strict gate**
- **WHERE:** `tests/test_audit_exception_handling_heuristics.py` (extend existing)
- **WHAT:** Add `test_app_controller_post_phase6_has_zero_silent_swallow` — asserts the audit's per-category count for `src/app_controller.py` is 0 for INTERNAL_SILENT_SWALLOW.
- **VERIFY:** The new test passes.
- **COMMIT:** `test(audit): add post-Phase-6 silent-swallow assertion for app_controller`
- **GIT NOTE:** Locks in the Phase 6 invariant.

**Task 6.8.4: Phase 6 checkpoint commit**
- **COMMIT:** `conductor(plan): mark Phase 6 complete (28 silent swallow sites properly migrated)`
- **GIT NOTE:** Phase 6 = 28 silent swallow sites migrated with proper Result[T] propagation; audit shows 0 INTERNAL_SILENT_SWALLOW for `src/app_controller.py`.

### Task 6.8.5: Rewrite the end-of-track report
- **WHERE:** `docs/reports/TRACK_COMPLETION_result_migration_app_controller_20260618.md` (overwrite — the old report was misleading)
- **WHAT:** Full rewrite covering ALL 6 phases (1-6), the audit deltas (45 → 0 migration sites; 28 silent swallows now properly propagated), the 2 regressions fixed (Phase 1), the 4 INTERNAL_RETHROW classified (Phase 4), the cold_start_ts migration (Phase 4), and the 28 silent swallow rewrites (Phase 6). 7 sections (Header, Tasks completed, Audit results, Last 3 failures, Files modified, Git state, Recommendation).
- **COMMIT:** `docs(reports): TRACK_COMPLETION_result_migration_app_controller_20260618 (full rewrite; covers Phase 6)`
- **GIT NOTE:** End-of-track report rewritten to reflect Phase 6's corrections; the previous report's claims about "8 silent swallows migrated" are superseded.

---

## End-of-Track Report (added 2026-06-17 convention; rewritten per Phase 6)

On Phase 6 completion, rewrite `docs/reports/TRACK_COMPLETION_result_migration_app_controller_20260618.md` to cover all 6 phases. Update `conductor/tracks/result_migration_app_controller_20260618/state.toml` to `status = "completed"`, `current_phase = 6`.
