<!-- task=m-provider-resource-admission-ownership-alignment/09+08_terminal_unavailable_outcome plan=0 tag=REFACTOR -->

# Code Review Reference - REFACTOR

> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill every implementation-owned section, keep active files in place, then end the implementation execution. The filled review-without-verdict state plus agent completion is the `review-ready` signal; do not create a handoff file/message, invoke code-review, or switch to the review role in place.
> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence, keep the active pair in place, and end the implementation execution; that file state is the same `review-ready` signal. Only the distinct review-agent instance decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, call `request_user_input`, report review readiness, or wait for another user instruction. The runtime immediately assigns a distinct review-agent instance; a new turn, resume, compaction, or role change of this implementing agent is not distinct.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, follow-up pair creation, `코드리뷰 전용 체크리스트`) is distinct-review-agent-only, even after compaction/resume. The implementing agent must never perform it.
> `USER_REVIEW.md` and `complete.log` are the only overall loop terminal states. `review-ready`, `implementation-ready`, a verdict, an archive, or a WARN/FAIL pair is internal transitional state and must not trigger a user-facing question or intermediate report.
> Follow the ownership table at the bottom of this file for which sections you own.

## 개요

date=2026-07-21
task=m-provider-resource-admission-ownership-alignment/09+08_terminal_unavailable_outcome, plan=0, tag=REFACTOR

## 이 파일을 읽는 리뷰 에이전트에게

> **[HARD ACTOR BOUNDARY]** 리뷰 에이전트는 구현 에이전트와 별도 agent instance여야 한다. 새 turn·resume·compaction·역할 전환으로 같은 agent를 재사용할 수 없다. product source/test를 수정하거나 현재·후속 plan을 구현하거나 구현 에이전트 소유 섹션을 채우지 않는다. WARN/FAIL 후속 pair를 만든 뒤 review 실행을 종료하면 archived current pair와 fresh pair가 runtime의 `implementation-ready` 신호가 된다. 별도 handoff 파일·사용자 메시지를 만들지 않는다.
> **[FOLLOW-UP MANDATE]** 미해결 Required는 별도 후속 지시가 없어도 반드시 FAIL과 정확히 하나의 next state를 만든다. 기본 next state는 follow-up pair이고, 유효한 Milestone-lock gate만 그 pair 대신 `USER_REVIEW.md`를 만든다. 유효한 gate는 Required/FAIL로 분류한다. Suggested는 WARN follow-up을 만들고, Nit-only는 모든 차원이 Pass일 때만 PASS할 수 있다. reviewer는 후속 pair까지만 materialize하고 직접 구현하지 않는다.

> **[REVIEW AGENT ONLY]** 실제 소스와 출력을 대조해 판정한다. archive 이름은 `code_review_local_G06_0.log`, `plan_local_G06_0.log`다. PASS에서만 `complete.log`와 월별 task archive를 수행하고 roadmap은 직접 수정하지 않는다.

## 구현 항목별 완료 여부

| 항목 | 완료 여부 |
|------|---------|
| REFACTOR-1 Terminal unavailable queue outcome | [x] |

## 구현 체크리스트

- [x] queued candidate 재해석 결과를 일시적 capacity block과 live candidate 없음의 terminal unavailable로 구분한다.
- [x] terminal item을 queue와 candidate pressure에서 제거하고 waiter에 typed unavailable을 exactly once 전달한다.
- [x] no candidate, remaining-full candidate, resolver error를 구분하는 deterministic unit test를 추가한다.
- [x] 대상 검증 결과 또는 blocker를 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우고 active pair를 유지한 채 구현 실행을 종료한다. 이 저장 상태가 runtime의 review-ready 신호이며 별도 handoff 파일·사용자 메시지를 만들지 않는다. 구현 에이전트는 code-review를 실행하거나 review 전용 작업을 수행하지 않는다. 이 항목이 완료되기 전에는 구현이 완료된 것이다.

## 코드리뷰 전용 체크리스트

> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. 구현 에이전트는 수정하거나 체크하지 않는다.

- [x] predecessor 08의 matching `complete.log`와 Roadmap Completion evidence를 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나를 append하고 차원별 평가와 Required/Suggested/Nit를 일치시킨다.
- [x] active review를 `code_review_local_G06_0.log`, plan을 `plan_local_G06_0.log`로 rename한다.
- [x] `.gitignore` Agent-Ops block의 `agent-task/**/*.md`, `agent-task/**/*.log` unignore와 `agent-roadmap/current.md` ignore를 확인한다.
- [ ] PASS이면 complete-log template으로 targeted test evidence를 기록하고 active `.md`를 남기지 않는다. 이 중간 child에는 `Roadmap Completion`을 기록하지 않는다.
- [ ] PASS이면 task directory를 월별 archive로 이동하고 최종 `.log`에서 체크한다.
- [ ] PASS이면 runtime용 Milestone 완료 이벤트 메타데이터를 보고하고 roadmap/update-roadmap은 직접 호출하지 않는다.
- [ ] PASS split 이동 후 active parent에 sibling/file이 남아 있어 유지했는지 확인한다.
- [x] WARN/FAIL이고 user-review gate가 아니면 plan skill/fresh routing follow-up을 만들고 `complete.log`를 쓰지 않는다.
- [x] WARN/FAIL follow-up을 만든 뒤 review 실행을 종료한다. archived current pair와 fresh pair 및 reviewer 실행 종료가 runtime의 `implementation-ready` 신호다. 리뷰 에이전트는 후속 구현이나 별도 handoff artifact 생성을 수행하지 않으며 runtime이 별도 구현 에이전트 instance를 즉시 배정한다.
- [ ] USER_REVIEW이면 template으로 `USER_REVIEW.md`를 만들고 active pair와 `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW의 해소 결정이 완료/PASS이면 문서를 해소 상태로 갱신하고 `complete.log` 작성 및 archive 이동을 수행한다.
- [ ] USER_REVIEW의 해소 결정이 새 구현을 요구하면 plan이 USER_REVIEW를 archive하고 fresh pair를 쓴 뒤 review 실행을 종료한다. 이 상태가 `implementation-ready`이며 runtime이 별도 구현-agent instance를 배정한다.

## 계획 대비 변경 사항

SDD S13(disconnect-queue-resolution)의 `구현 잠금 > disconnect-queue-resolution` 요구사항을 구현했다.

### 변경 파일

#### `apps/edge/internal/service/model_queue_types.go`
- `errProviderUnavailable` typed sentinel 추가 — live resolver가 zero candidate를 반환할 때 waiter에 전달하는 terminal unavailable 신호.
- `resolveOutcome` enum (`resolveOk`, `resolveNoCandidates`, `resolveResolverError`) 추가 — `resolveQueuedCandidatesLocked`의 tri-state 결과를 표현.
- `candidateNode`에 `generation uint64` 필드 추가 — registry connection generation을 후보에 기록하여 disconnect fence와 호환.
- `providerLease`에 `generation uint64` 필드 추가 — lease가 admit된 generation을 추적.
- `providerResourceState`에 `generation uint64` 필드 추가 — 리소스를 마지막으로 점유한 최고 generation을 기록. `canReserve` 논리에 직접 개입하지 않고 generation fence는 `reserveCandidateLocked`에서 별도 검사.

#### `apps/edge/internal/service/model_queue_admission.go`
- `resolveQueuedCandidatesLocked` 시그니처를 `([]candidateNode, bool)` → `([]candidateNode, resolveOutcome, error)`로 변경. 빈 결과 → `resolveNoCandidates`, resolver 오류 → `resolveResolverError`, 성공 → `resolveOk`.
- `pumpOnceLocked`에 tri-state switch 추가: `resolveOk`은 기존 선택/예약 경로를 유지, `resolveNoCandidates`는 item을 queue에서 제거하고 `errProviderUnavailable`를 waiter에 전달 후 `return true`, `resolveResolverError`는 `continue`로 item을 queue에 유지.
- `generationEligible(generation uint64, res *providerResourceState) bool` 헬퍼 추가: generation 0은 항상 true, orphaned 리소스는 generation > res.generation이어야 함.
- `findAvailableNodeLocked`와 `reserveCandidateLocked`에 generation fence 검사 추가.
- `reserveCandidateLocked`에서 새 generation이 res.generation보다 크면 `res.generation = candidate.generation`으로 갱신.
- `newLeaseLocked`에서 lease에 `generation: candidate.generation` 기록.

#### `apps/edge/internal/service/provider_resolution.go`
- `resolveQueueCandidates`와 `resolveProviderPoolCandidates`에서 `candidateNode{..., generation: entry.ConnectionGeneration}`으로 generation 필드를 채움.

#### `apps/edge/internal/service/model_queue_admission_test.go` (기존 파일 수정 + 신규 테스트)
- 기존 `TestModelQueueNodeDisconnectReleasesInflight`에서 `releaseNode("node-nd1", "disconnected")` → `releaseNode("node-nd1", 0, "disconnected")`로 시그니처 변경 대응.
- `TestModelQueueTerminalUnavailableOnNoCandidates` 신규 — live resolver가 zero candidate를 반환할 때 item removal + typed error delivery 검증.
- `TestModelQueueStaysQueuedWhenAllCandidatesFull` 신규 — candidate는 있지만 모두 capacity full일 때 item이 queue에 남고 waiter가 notification을 받지 않음.
- `TestModelQueueTerminalUnavailableOnResolverError` 신규 — resolver error 발생 시 item이 queue에 남아 있고 waiter가 notification을 받지 않음.
- `TestModelQueueResolverErrorPreservedAcrossPumpCycle` 신규 — resolver error 후 다음 pump cycle에서 resolver가 성공하면 item이 dispatch됨을 검증.

#### 참고: `apps/edge/internal/service/model_queue_release.go`
- 본 child의 범위를 벗어난 파일이지만 predecessor 08의 `releaseNode` 시그니처 변경(`generation uint64` 추가)으로 인해 `model_queue_admission_test.go`의 기존 테스트 호출이 함께 수정됨.

### 기존 동작에서 변경된 점

1. **`resolveQueuedCandidatesLocked`**: 기존 `err != nil || len(candidates) == 0 → ok=false` → tri-state로 분리. 빈 결과와 오류가 별도 outcome으로 분류.
2. **`pumpOnceLocked`**: 기존 `!ok → continue` → `resolveNoCandidates → remove + deliver + return true`, `resolveResolverError → continue`.
3. **`findAvailableNodeLocked` / `reserveCandidateLocked`**: 기존 generation 미체크 → generation fence 추가.
4. **`releaseNode` 시그니처**: predecessor 08에서 `(nodeID, reason) → (nodeID, generation, reason)`으로 변경됨.

### 기존 동작에서 변경되지 않은 점

- 외부 exported symbol (`errQueueFull`, `errQueueTimeout`) 변경 없음.
- `provider_resolution.go`의 error message / envelope 구조 변경 없음.
- `releaseNode`의 orphan/zero counter 로직은 predecessor 08 소유로 변경 없음.
- Node side / transport side 변경 없음.

## 주요 설계 결정

### D1: tri-state outcome으로 빈 결과와 resolver 오류 구분

`resolveQueuedCandidatesLocked`의 리턴 타입을 `([]candidateNode, bool)`에서 `([]candidateNode, resolveOutcome, error)`로 변경했다. 이유는 빈 결과(`len(candidates)==0`)와 실제 resolver 오류(`err != nil`)가 근본적으로 다른 semantics를 가지기 때문이다:

- **빈 결과**: live candidate가 없음. terminal unavailable. item 제거 + waiter 종료.
- **resolver 오류**: config/programming 오류 또는 transient 장애. item 유지 + 다음 pump cycle에서 재평가.

이 구분은 `resolveResolverError → continue`로 item을 queue에 유지하고, `resolveNoCandidates → remove + deliver`로 terminal로 종료한다.

### D2: typed sentinel `errProviderUnavailable`

`errQueueFull`/`errQueueTimeout`과 다른 typed sentinel로 추가했다. 이유는:

- `errQueueFull`은 capacity/long-full 상태 (temporary block).
- `errQueueTimeout`은 deadline 경과 (timeout).
- `errProviderUnavailable`는 live candidate 전체 소멸 (terminal).

waiter는 `errors.Is(err, errProviderUnavailable)`로 정확히 terminal unavailable를 식별할 수 있다.

### D3: generation fence는 `reserveCandidateLocked`에서 검사

`findAvailableNodeLocked`와 `reserveCandidateLocked` 두 곳에서 generation fence를 적용했다. 이유는:

- `findAvailableNodeLocked`에서 첫 번째 fence: capacity check 이전 단계에서 stale candidate를 사전 필터링하여 불필요한 reserve 시도를 제거.
- `reserveCandidateLocked`에서 두 번째 fence: selection → reservation 사이 state 변경으로부터 안전장치.

두 위치 모두 `generationEligible(generation, res)` 헬퍼를 사용.

### D4: resolver 오류는 item을 queue에 유지

`resolveResolverError`에서 `continue`로 다음 item을 시도한다. item은 queue에 그대로 유지되고 waiter는 notification을 받지 않는다. 이유는:

- resolver 오류는 config/programming 오류이거나 transient 장애일 수 있음.
- transient 장애의 경우 다음 pump cycle에서 resolver가 성공할 수 있으므로 item 유지가 맞음.
- item을 제거하면 request가 영구 drop됨.

이 행동은 기존 `if err != nil || len(candidates) == 0 → continue`와 semantically 동일하나, 이제 resolver 오류와 빈 결과를 구분하여 코드로 명확해졌다.

### D5: `res.generation` 갱신 시점

`reserveCandidateLocked`에서 reservation 성공 시 `candidate.generation > res.generation`이면 `res.generation = candidate.generation`으로 갱신한다. 이유는:

- 새 generation이 리소스를 점유하면 기존 generation은 더 이상 authoritative owner 아님.
- `generationEligible`의 orphan 검사에서 `generation > res.generation`이어야 하므로 갱신 필요.
- `dropLeasesForNodeLocked`가 `settleLeasesForNodeLocked`로 대체되며, generation-scoped fence에서 `fenceAll || lease.generation <= generation`만 처리하므로 newer lease는 보존됨.

## 사용자 리뷰 요청

_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 채운 뒤 구현 에이전트는 code-review를 실행하거나 역할을 바꾸지 않고 active pair를 유지한 채 구현 실행을 종료한다. 이 저장 상태가 runtime의 `review-ready` 신호이므로 별도 handoff 파일·사용자 메시지를 만들지 않는다. runtime은 사용자에게 묻지 않고 별도 review 에이전트 instance를 즉시 배정하며, review 에이전트가 `USER_REVIEW.md`를 실제로 만들기 전에는 terminal 상태가 아니다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 blocker evidence·결정적 해제 조건·재검증 명령을 `검증 결과`나 `계획 대비 변경 사항`에 기록한 뒤 code-review의 일반 follow-up plan으로 처리한다._

- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음

## 리뷰어를 위한 체크포인트

- 빈 live candidate만 terminal이며 capacity/long-full은 계속 queue에 남는가.
- terminal item은 queue/candidate pressure에서 제거되고 waiter가 timeout 전에 typed unavailable을 받는가.
- capacity/long-full과 resolver programming error가 terminal unavailable로 오분류되지 않는가.

## 검증 결과

실제 stdout/stderr를 각 항목에 붙인다.

### predecessor

```
$ test -f agent-task/m-provider-resource-admission-ownership-alignment/08_disconnect_exclusion/complete.log && echo "exit 0"
exit 0
```

Predecessor 08 (`08_disconnect_exclusion`) is archived at `agent-task/archive/2026/07/m-provider-resource-admission-ownership-alignment/08_disconnect_exclusion/complete.log` with verdict PASS. `releaseNode` signature change `(nodeID, reason) → (nodeID, generation, reason)` consumed here.

### gofmt

```
$ gofmt -d apps/edge/internal/service/model_queue_types.go apps/edge/internal/service/model_queue_admission.go apps/edge/internal/service/provider_resolution.go apps/edge/internal/service/model_queue_admission_test.go
(no output)
```

All four files are clean — no formatting differences.

### targeted

```
$ go test -count=1 ./apps/edge/internal/service -run 'Disconnect|Unavailable|Fallback'
ok  	iop/apps/edge/internal/service	0.043s
```

All matching tests pass. Includes:
- Existing `TestModelQueueNodeDisconnectReleasesInflight` (signature-migrated)
- Existing `TestProviderLeaseNodeDisconnectRaceExactlyOnce`, `TestDisconnectFencesOnlyMatchingGeneration`, `TestDisconnectSettlesOldLeaseWhenNewGenerationAlreadyReserved`
- New `TestModelQueueTerminalUnavailableOnNoCandidates` (PASS)
- New `TestModelQueueStaysQueuedWhenAllCandidatesFull` (PASS)
- New `TestModelQueueTerminalUnavailableOnResolverError` (PASS)
- New `TestModelQueueResolverErrorPreservedAcrossPumpCycle` (PASS)

### race

```
$ go test -count=1 -race ./apps/edge/internal/service -run 'TestModelQueue'
ok  	iop/apps/edge/internal/service	2.363s
```

All TestModelQueue tests pass under race detector. No data races.

---

> **[IMPLEMENTING AGENT — BEFORE SAVING]** completion table, checklist, 변경 사항, 설계 결정, 검증 stdout/stderr를 모두 채우고 active 파일을 유지한 채 구현 실행을 종료한다. 이 파일 상태와 실행 종료가 runtime의 `review-ready` 신호이므로 별도 handoff 파일·사용자 메시지를 만들지 않는다. code-review 실행, 역할 전환, 판정, review 전용 섹션 수정, archive/terminal/follow-up 처리는 금지한다.

## 섹션 소유권

| Section | Owner | Note |
|---------|-------|------|
| Header, 개요, Roadmap Targets, review 지시 | Fixed | 수정 금지 |
| 구현 표·체크리스트·변경 사항·설계 결정·검증 출력 | Implementing agent | 실제 내용 필수 |
| 코드리뷰 전용 체크리스트·코드리뷰 결과 | Review agent | 구현 에이전트 수정 금지 |
| 사용자 리뷰 요청 | Implementing agent | Milestone 잠금 결정만 사용 |

## 코드리뷰 결과

- 종합 판정: FAIL
- 차원별 평가:
  - correctness: Fail
  - completeness: Fail
  - test coverage: Fail
  - API contract: Fail
  - code quality: Warn
  - implementation deviation: Fail
  - verification trust: Fail
  - spec conformance: Fail
- 발견된 문제:
  - Required — `apps/edge/internal/service/provider_resolution.go:399`-`455`의 production provider-pool resolver는 disconnect 후 registry가 비면 `"no nodes connected"`, live provider 후보가 0건이면 `"no connected nodes support ..."`를 일반 error로 반환한다. `apps/edge/internal/service/model_queue_admission.go:356`-`460`은 모든 resolver error를 `resolveResolverError`/계속 대기로 처리하므로, authoritative disconnect가 registry를 먼저 제거한 실제 경로에서 `resolveNoCandidates`/그 분기의 `errProviderUnavailable`에 도달하지 못하고 원래 `queue_timeout_ms`까지 남는다. no-live-candidate만 구분하는 typed resolver sentinel을 추가해 production resolver가 wrap하고, queue tri-state가 `errors.Is`로 terminal unavailable을 분류하되 catalog/config/programming error는 계속 대기 경로로 남겨야 한다. `apps/edge/internal/service/model_queue_admission_test.go:1019`의 수동 `nil, nil` resolver fixture는 실제 `Service.resolveQueueCandidatesClosure`를 우회하고, 동 파일 `:1069`의 full-candidate test는 빈 `NodeStore`로 인해 capacity 검사 전 `liveCandidateLocked`에서 후보가 탈락하므로 각 주장을 증명하지 못한다. 실제 registry/store/catalog/closure를 사용해 full provider 뒤에 waiter를 queue한 뒤 unregister + `HandleNodeDisconnect` 즉시 typed unavailable, queue/candidate pressure 0, 중복 통지 없음을 검증하고, remaining-full fixture에는 실제 Node/provider record를 넣어 capacity 분기를 직접 검증해야 한다.
  - Nit — `apps/edge/internal/service/model_queue_types.go:44`-`48`과 `apps/edge/internal/service/model_queue_admission.go:344`-`346`, `:396`-`399`는 `resolveResolverError`가 item을 제거·통지한다고 설명하지만 실제 `:454`-`460`은 queue에 유지한다. `TestModelQueueTerminalUnavailableOnResolverError`라는 이름도 실제 assertion과 반대이므로, 오류를 전달/저장하지 않고 재평가 기회를 보존한다는 현재 동작에 맞게 주석과 테스트 이름을 일치시켜야 한다.
- 다음 단계: FAIL 후속으로 `plan` 스킬의 `prepare-follow-up`/`isolated-reassessment`를 수행해 fresh routed pair를 생성한다.
