<!-- task=m-provider-catalog-device-status/02+01_edge_dispatch plan=2 tag=REVIEW_REVIEW_API -->

# Plan - REVIEW_REVIEW_API

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

구현이 끝났다고 보고하기 전에 반드시 `CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다. active plan/review 파일은 그대로 둔 상태에서 review-ready만 보고한다. 선택된 SDD 결정이나 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우고 중단한다. 구현 에이전트는 사용자에게 직접 질문하거나 `USER_REVIEW.md`, `complete.log`, archive log를 작성하지 않는다.

## 배경

이 follow-up은 두 번째 리뷰에서 발견된 provider-pool dispatch 잔여 결함만 수정한다. 현재 구현은 adapter/target rewrite와 provider slot key의 큰 방향은 잡았지만, queue timeout/cancel race cleanup이 mutex 없이 shared state를 수정하고, capacity 0/unknown provider를 여전히 후보로 살려 두며, 요구된 테스트 두 개가 실제 service path를 검증하지 않는다.

## 사용자 리뷰 요청 흐름

선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실제 구현을 차단할 때만 `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 섹션에 근거를 채운다. 이 follow-up의 현재 Required는 코드와 테스트로 해소 가능한 항목이며 사용자 리뷰 요청 대상이 아니다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md`
- Task ids:
  - `target-rewrite`: Edge가 선택 provider의 served model을 concrete target으로 rewrite해 Node에 전달
  - `selection-policy`: available provider 중 최저 `in_flight / capacity` load ratio 우선 선택, deterministic tie-break, queue timeout 적용
- Completion mode: check-on-pass

## Spec Targets

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md`
- Acceptance scenarios:
  - `S03`: task=`target-rewrite`; evidence=`Edge/Node request mapping test가 selected provider의 adapter와 concrete target 전달을 검증`
  - `S04`: task=`selection-policy`; evidence=`queue/selection policy test가 provider별 load ratio, same-node provider capacity, unavailable/invalid 후보 제외, timeout 회귀를 검증`
- Completion mode: spec-check-on-pass

## Archive Evidence Snapshot

- Current archived plan: `agent-task/m-provider-catalog-device-status/02+01_edge_dispatch/plan_local_G07_1.log`
- Current archived review: `agent-task/m-provider-catalog-device-status/02+01_edge_dispatch/code_review_local_G07_1.log`
- Previous archived plan/review: `plan_local_G07_0.log`, `code_review_local_G07_0.log`
- Verdict: FAIL
- Required summary:
  - `releaseSlotByCandidate` calls locked-only queue mutation without holding `m.mu` on timeout/cancel race cleanup.
  - `resolveProviderPoolCandidates` still treats `prov.Capacity <= 0` as dispatchable by falling back to runtime/default concurrency, contrary to SDD/plan capacity availability rule.
  - `TestResolveProviderPoolCandidatesFiltersInvalidProviders` and `TestSubmitRunProviderPoolRewritesAdapterAndTarget` do not execute the actual functions their names and plan evidence require.
- Suggested/Nit summary: 없음
- Affected files: `apps/edge/internal/service/model_queue.go`, `apps/edge/internal/service/run_dispatch.go`, `apps/edge/internal/service/model_queue_test.go`, and if needed adjacent service internal test helpers.
- Verification evidence from failed loop: reviewer reran targeted service tests, `go test ./apps/edge/internal/openai ./apps/edge/internal/service -count=1`, `go test ./apps/edge/... -count=1`, and `git diff --check`; all passed, but the passing tests do not cover the Required gaps.
- Narrow reread allowed: the archived plan/review paths listed above only; do not search `agent-task/archive/**`.

## 분석 결과

### 읽은 파일

- Rules/skills: `AGENTS.md`, project rules, roadmap rules, code-review skill, local test rules.
- Roadmap/spec: current provider-catalog-device-status milestone and approved SDD.
- Source/tests: `apps/edge/internal/service/model_queue.go`, `apps/edge/internal/service/run_dispatch.go`, `apps/edge/internal/service/model_queue_test.go`, adjacent service/openai tests as needed.

### 테스트 환경 규칙

- `test_env=local`.
- 필수 검증은 targeted service tests, service/openai package tests, full Edge Go tests, `git diff --check`다.
- Race cleanup 수정은 targeted `go test -race`가 환경에서 지원되면 함께 실행해 `검증 결과`에 남긴다. 지원되지 않으면 실제 오류를 기록하고 non-race 테스트로 보완한다.

### 분할 판단

- 새 split subtask를 만들지 않는다. 세 Required 모두 `02+01_edge_dispatch`의 provider-pool dispatch invariant에 속한다.

## 구현 체크리스트

- [ ] Timeout/cancel race cleanup이 provider-aware slot을 release할 때 `m.mu` contract를 지키고 legacy/provider-pool release semantics를 모두 유지한다.
- [ ] Provider-pool 후보 생성에서 capacity 0/unknown provider를 dispatch 후보에서 제외한다.
- [ ] `TestSubmitRunProviderPoolRewritesAdapterAndTarget`가 실제 `SubmitRun(ProviderPool=true)` 경로를 타고 captured `iop.RunRequest.Adapter`와 `Target`을 검증한다.
- [ ] `TestResolveProviderPoolCandidatesFiltersInvalidProviders`가 실제 `resolveProviderPoolCandidates`를 호출해 unavailable, served-model mismatch, empty adapter, capacity zero/unknown provider 제외와 valid candidate만 남는 것을 검증한다.
- [ ] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G07.md`에 붙여 넣는다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## REVIEW_REVIEW_API-1 - Locked provider slot cleanup

### 문제

`apps/edge/internal/service/model_queue.go:246`과 `apps/edge/internal/service/model_queue.go:259`의 timeout/cancel race cleanup은 `releaseSlotByCandidate`를 호출한다. 현재 `releaseSlotByCandidate`는 lock을 잡지 않고 `releaseSlotLocked`를 호출하므로 `groups`, `inflight`, `tryDispatchLocked`를 mutex 없이 만진다.

### 해결 방법

`releaseSlotByCandidate`가 자체적으로 `m.mu`를 잡는 public helper가 되게 하거나, race cleanup에서 기존 lock-aware `releaseSlot(groupKey, nodeID, providerID)`를 호출한다. locked-only 함수는 이름과 호출 조건을 유지한다. 같은 node/provider slot 계산을 중복 문자열 분해 없이 안전하게 유지하는 방향을 우선한다.

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

- [ ] `apps/edge/internal/service/model_queue.go`: timeout/cancel race cleanup의 provider-aware release가 lock contract를 지키게 한다.
- [ ] 기존 `releaseRun`, `releaseNode`, direct dispatch failure release 동작이 바뀌지 않는지 확인한다.

### 중간 검증

```bash
go test ./apps/edge/internal/service -run 'TestModelQueue.*Provider|TestModelQueue.*Timeout|TestSubmitRunModelQueueDispatchesQueuedRunAfterDisconnectToLiveNode' -count=1 -v
go test -race ./apps/edge/internal/service -run 'TestModelQueue.*Provider|TestModelQueue.*Timeout' -count=1
```

## REVIEW_REVIEW_API-2 - Capacity availability filter

### 문제

SDD는 capacity가 0이거나 알 수 없으면 provider를 선택 대상에서 제외하거나 unavailable로 보라고 한다. 현재 `resolveProviderPoolCandidates`는 `prov.Capacity <= 0`이면 node runtime/default concurrency로 fallback해 unknown capacity provider를 dispatch 후보로 만든다.

### 해결 방법

Provider-pool candidate generation에서는 `prov.Capacity <= 0`을 skip한다. Legacy adapter/target route의 capacity fallback은 이번 변경 대상이 아니다. 후보가 모두 제외되면 기존 no-candidate error 흐름을 유지한다.

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

- [ ] `apps/edge/internal/service/run_dispatch.go`: provider-pool `prov.Capacity <= 0` skip 로직을 추가한다.
- [ ] provider snapshot 표시용 capacity fallback과 runtime dispatch 후보 필터를 혼동하지 않는다.

## REVIEW_REVIEW_API-3 - Real-path provider-pool tests

### 문제

현재 `TestSubmitRunProviderPoolRewritesAdapterAndTarget`는 `candidateNode` fields만 검사하고 `SubmitRun`이나 Node request capture를 하지 않는다. `TestResolveProviderPoolCandidatesFiltersInvalidProviders`도 `resolveProviderPoolCandidates`를 호출하지 않고 helper 함수만 검사한다.

### 해결 방법

두 테스트를 이름 그대로 실제 경로 기반 회귀 테스트로 만든다. 기존 service package test helper가 있으면 재사용하고, 없으면 최소 fixture를 추가한다.

### 테스트 작성

- [ ] `TestSubmitRunProviderPoolRewritesAdapterAndTarget`: catalog alias `qwen3.6:35b`, provider `prov-vllm-01`, served target `served-qwen`, adapter `vllm-gpu`를 구성한다. `SubmitRunRequest{ProviderPool:true, ModelGroupKey:"qwen3.6:35b", Background:true}`를 보내고 fake node/client가 받은 `iop.RunRequest`의 `Adapter=="vllm-gpu"`, `Target=="served-qwen"`을 assert한다.
- [ ] `TestResolveProviderPoolCandidatesFiltersInvalidProviders`: 실제 `Service.resolveProviderPoolCandidates`를 호출한다. invalid fixture는 unavailable, unknown, served-model mismatch, empty adapter, capacity 0을 포함한다. valid provider 하나만 candidate로 남고 `providerID`, `adapter`, `servedTarget`, `capacity`가 정확한지 assert한다.

### 중간 검증

```bash
go test ./apps/edge/internal/service -run 'TestSubmitRunProviderPoolRewritesAdapterAndTarget|TestResolveProviderPoolCandidatesFiltersInvalidProviders|TestBuildRunRequest' -count=1 -v
```

## 최종 검증

```bash
go test ./apps/edge/internal/service -run 'TestSubmitRunProviderPool|TestResolveProviderPoolCandidates|TestModelQueue.*Provider|TestModelQueue.*Timeout|TestProviderStatus|TestListNodeSnapshots' -count=1 -v
go test ./apps/edge/internal/openai ./apps/edge/internal/service -count=1
go test ./apps/edge/... -count=1
git diff --check
```

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