<!-- task=m-usb-install/01_install_lifecycle_cleanup plan=1 tag=REVIEW_UILC -->

# Plan - REVIEW_UILC Install Lifecycle Follow-up

## 이 파일을 읽는 구현 에이전트에게

`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 필수 단계다. 구현 후 검증을 실행하고 실제 변경 내용, 설계 결정, stdout/stderr를 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. 구현 중 사용자만 결정할 수 있는 범위 충돌, 사용자 소유 외부 환경, secret 또는 서비스 준비가 없으면 사용자에게 직접 질문하지 말고, 필요한 경우 review stub의 `사용자 리뷰 요청`에 근거를 적고 멈춘다. 후속 에이전트가 명령 재실행이나 증거 수집으로 닫을 수 있는 검증 공백은 사용자 리뷰 요청이 아니다.

## 배경

이 follow-up은 `code_review_local_G06_0.log`의 Required 두 건을 닫는다. 현재 구현은 install future 완료 후 `DevicesPage` state가 살아 있다고 가정해 cleanup callback과 result UI를 처리한다. 설치 중 페이지 이동, dispose, installer exception에서도 staged APK cleanup과 failure/result 표시가 보장되도록 install lifecycle owner를 shell로 정리한다.

## 사용자 리뷰 요청 흐름

구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 채팅에서 직접 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. code-review가 사용자 리뷰 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/usable-mvp/milestones/usb-install.md`
- Task ids:
  - `install-run`: 선택한 APK와 device serial로 `adb install -r`를 실행한다.
  - `install-output`: stdout/stderr와 exit code를 UI에 표시한다.
  - `install-guard`: APK가 선택되지 않았거나 device가 ready가 아니면 설치 버튼을 비활성화한다.
  - `install-result`: 성공/실패 결과를 이후 이력 milestone에서 재사용할 수 있는 구조로 남긴다.
  - `install-cleanup`: `adb install -r` 종료 후 성공/실패 결과를 보존한 뒤 staged APK를 cleanup한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/app-shell/rules.md`
- `agent-ops/rules/project/domain/artifact-flow/rules.md`
- `agent-ops/rules/project/domain/device-console/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/app-shell-smoke.md`
- `agent-test/local/artifact-flow-smoke.md`
- `agent-test/local/device-console-smoke.md`
- `agent-roadmap/current.md`
- `agent-task/m-usb-install/01_install_lifecycle_cleanup/plan_local_G06_0.log`
- `agent-task/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G06_0.log`
- `lib/src/app.dart`
- `lib/src/features/app_shell.dart`
- `lib/src/features/builds/builds_page.dart`
- `lib/src/features/devices/devices_page.dart`
- `lib/src/models/pending_install.dart`
- `lib/src/services/adb_service.dart`
- `lib/src/services/artifact_staging_service.dart`
- `test/widget_test.dart`
- `test/builds_page_test.dart`

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md`를 읽었다. 완료 evidence는 remote Mac runner `toki@toki-labs.com`의 checkout `$HOME/docker/services/code-server/data/volume/workspace/appsok` 기준이다.
- 적용 profile: `agent-test/local/app-shell-smoke.md`, `agent-test/local/artifact-flow-smoke.md`, `agent-test/local/device-console-smoke.md`.
- 필수 명령: remote runner에서 `flutter analyze`, `flutter test`.
- 보조 preflight: 현재 checkout에서 `git diff --check`, `flutter analyze`, `flutter test`.
- `<확인 필요>` 값은 없었다.

### 테스트 커버리지 공백

- 설치 future pending 중 `DevicesPage`가 dispose된 뒤 completion이 도착하는 케이스가 없다.
- installer가 `AdbInstallResult` 대신 exception을 throw하는 케이스가 없다.
- 기존 success/failure cleanup test는 같은 page state가 살아 있는 happy path만 검증한다.

### 심볼 참조

- stale symbol: `PendingInstallSelectionHandler`.
- 참조 결과: `lib/src/features/devices/devices_page.dart:7`에 typedef만 남아 있고 call site는 없다.

### 분할 판단

- split decision policy를 검토했다.
- 이 follow-up은 G06 리뷰의 Required 두 건을 같은 install lifecycle owner 정리로 닫는다. shell state, device page callback, widget regression tests가 한 계약으로 묶여 있어 다시 split하면 cleanup/result 보존 판단이 흩어진다.
- 기존 split subtask `01_install_lifecycle_cleanup` 안에서 후속 loop로 처리한다.

### 범위 결정 근거

- 포함: `AppSokShell`의 install lifecycle state/result/cleanup owner 정리.
- 포함: `DevicesPage`의 install trigger/result rendering props 정리.
- 포함: installer exception failure result와 cleanup 보장.
- 포함: dispose/navigation/throw regression tests.
- 제외: bundled ADB runtime, 실제 device install smoke, device list refresh, logcat, split APK/APKS/AAB.
- 제외 이유: 이번 follow-up은 review Required를 닫는 lifecycle bug fix이며 외부 ADB runtime 구축은 다른 epic 책임이다.

### 빌드 등급

- `local-G07`: async UI lifecycle, dispose race, cleanup idempotency를 고쳐야 하지만 변경 파일과 검증은 bounded이고 widget regression으로 재현 가능하다.

## 구현 체크리스트

- [ ] [REVIEW_UILC-1] 설치 실행/결과/cleanup owner를 `AppSokShell` 또는 shell-owned controller로 이동해 `DevicesPage` dispose 후에도 completion cleanup이 정확히 한 번 실행되게 한다.
- [ ] [REVIEW_UILC-2] installer exception을 실패 result로 표시하고 cleanup을 보장하며 stale `PendingInstallSelectionHandler` typedef를 제거한다.
- [ ] [REVIEW_UILC-3] widget regression test를 추가한다: install pending 중 navigation 후 complete cleanup, installer throw failure cleanup, result/output 유지, no stuck installing.
- [ ] [REVIEW_UILC-4] 현재 checkout과 remote Mac runner에서 `git diff --check`, `flutter analyze`, `flutter test` 통과를 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [REVIEW_UILC-1] Shell-Owned Install Completion

### 문제

`lib/src/features/devices/devices_page.dart:61`은 install future 완료 후 state 생존을 확인하지 않고 `setState`를 호출한다. 사용자가 설치 중 navigation rail로 다른 page를 선택하면 `DevicesPage`가 dispose되고, cleanup callback이 실행되기 전에 `setState() called after dispose`가 발생할 수 있다.

```dart
// lib/src/features/devices/devices_page.dart:61
final result = await installer(device, pending);

setState(() {
  _lastResult = result;
  _phase = result.succeeded ? _InstallPhase.success : _InstallPhase.failure;
});

widget.onInstallCompleted?.call(device, result);
```

### 해결 방법

설치 future, result, cleanup은 shell이 소유한다. `DevicesPage`는 ready guard와 버튼 event를 shell callback으로 전달하고, shell이 내려준 installing/result snapshot만 렌더링한다. cleanup은 install 시작 시 캡처한 `PendingInstall` 기준으로 `finally` 또는 equivalent completion path에서 한 번만 호출한다.

```dart
// after sketch
Future<void> _handleInstallRequestedOnDevice(
  AdbDevice device,
  PendingInstall pending,
) async {
  final installer = widget.installer;
  if (installer == null || _isInstalling) return;
  setState(() {
    _isInstalling = true;
    _lastInstallDevice = device;
  });

  final result = await installer(device, pending);
  if (!mounted) return;
  setState(() {
    _pendingInstall = null;
    _lastInstallResult = result;
    _isInstalling = false;
  });
  _cleanupPendingInstall(pending);
}
```

위 sketch는 방향만 보여준다. 실제 구현에서는 exception path도 `[REVIEW_UILC-2]`와 함께 처리하고, shell dispose 중에는 `setState`를 피하되 cleanup은 중복 없이 보장한다.

### 수정 파일 및 체크리스트

- [ ] `lib/src/features/app_shell.dart`: install phase/result/device/pending owner를 shell로 이동하고 completion cleanup을 캡처한 pending 기준으로 실행한다.
- [ ] `lib/src/features/devices/devices_page.dart`: internal install future state를 제거하거나 shell snapshot props만 렌더링하도록 정리한다.
- [ ] `test/widget_test.dart`: 설치 pending 중 다른 destination으로 이동한 뒤 completer 완료 시 cleanup 한 번, Flutter exception 없음, pending banner 정리 또는 result 보존을 검증한다.

### 테스트 작성

- `test/widget_test.dart`: `cleans staged apk when install completes after leaving devices page`.
- Assertion: install future가 pending인 상태에서 `빌드` nav로 이동하고 completer를 완료한다. `tester.takeException()`은 null이고 `cleanedPaths`는 staged path 한 번만 포함한다. 다시 `디바이스`로 이동했을 때 stuck installing 상태가 아니어야 한다.

### 중간 검증

```bash
flutter test test/widget_test.dart
```

기대 결과: navigation/dispose lifecycle regression test 포함 전체 widget test 통과.

## [REVIEW_UILC-2] Installer Exception Failure Result

### 문제

`lib/src/features/devices/devices_page.dart:61`에서 `installer(device, pending)`이 throw하면 `_phase`가 `installing`에 남고 result UI, cleanup callback이 모두 실행되지 않는다. `AdbService.installApk`는 exit code를 result로 반환하지만, `adb` binary missing 같은 `Process.run` 실패는 exception이 될 수 있다.

### 해결 방법

installer exception을 failure result로 변환한다. stderr에는 exception 문자열을 담고, exit code는 sentinel 값(예: `-1`) 또는 로컬에서 정한 failure code를 사용한다. cleanup은 success, nonzero exit, exception 모두 같은 completion path에서 실행한다. stale typedef는 같은 파일 수정 중 제거한다.

```dart
// after sketch
AdbInstallResult result;
try {
  result = await installer(device, pending);
} catch (e) {
  result = AdbInstallResult(exitCode: -1, stdout: '', stderr: e.toString());
}
```

### 수정 파일 및 체크리스트

- [ ] `lib/src/features/app_shell.dart`: install exception을 failure result로 저장하고 cleanup을 보장한다.
- [ ] `lib/src/features/devices/devices_page.dart`: failure result 표시가 shell-owned result를 사용하도록 정리하고 `PendingInstallSelectionHandler` typedef를 제거한다.
- [ ] `test/widget_test.dart`: installer throw case에서 failure output, cleanup once, no stuck installing을 검증한다.

### 테스트 작성

- `test/widget_test.dart`: `shows failure and cleans staged apk when installer throws`.
- Assertion: installer가 `Exception('adb missing')`을 throw한다. failure banner는 exception text를 포함하고, cleanup은 staged path 한 번만 호출되며, install buttons가 계속 hourglass/stuck 상태로 남지 않는다.

### 중간 검증

```bash
flutter test test/widget_test.dart
```

기대 결과: installer throw regression test 포함 전체 widget test 통과.

## [REVIEW_UILC-3] Regression Coverage Consolidation

### 문제

G06 tests는 success/failure cleanup happy path를 검증하지만 lifecycle edge를 놓쳤다. 이 때문에 `flutter test`가 통과해도 install cleanup contract가 완전히 보호되지 않는다.

### 해결 방법

새 tests는 기존 helper를 재사용하되, `Completer<AdbInstallResult>`와 throwing installer를 사용한다. result가 shell-owned로 바뀌면 helper assertion도 shell state 기준으로 조정한다.

### 수정 파일 및 체크리스트

- [ ] `test/widget_test.dart`: navigation/dispose completion test 추가.
- [ ] `test/widget_test.dart`: installer throw failure test 추가.
- [ ] 기존 install lifecycle tests가 새 shell-owned API와 맞게 통과하도록 갱신한다.

### 테스트 작성

- 작성한다. bug fix이며 cleanup/lifecycle 회귀 위험이 있으므로 regression test가 필수다.

### 중간 검증

```bash
flutter test test/widget_test.dart
```

기대 결과: 기존 + 신규 install lifecycle widget tests 통과.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `lib/src/features/app_shell.dart` | REVIEW_UILC-1, REVIEW_UILC-2 |
| `lib/src/features/devices/devices_page.dart` | REVIEW_UILC-1, REVIEW_UILC-2 |
| `test/widget_test.dart` | REVIEW_UILC-1, REVIEW_UILC-2, REVIEW_UILC-3 |

## 최종 검증

```bash
git diff --check
flutter analyze
flutter test
ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && git diff --check"'
ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && flutter analyze"'
ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && flutter test"'
```

기대 결과: diff whitespace 오류 없음, analyzer issue 없음, 모든 Flutter test 통과.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
