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

# Plan - REVIEW_API

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

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

## 배경

이 follow-up은 이전 리뷰에서 FAIL 처리된 provider-pool dispatch 결함만 수정한다. 현재 구현은 catalog alias를 provider served target으로 rewrite하지만 Node에 보낼 adapter를 채우지 않고, queue admission은 provider가 아니라 node 단위로 capacity/in-flight를 계산한다. 또한 런타임 후보 생성이 SDD의 served-model membership과 unavailable 후보 제외 조건을 방어적으로 보존하지 않는다.

## 사용자 리뷰 요청 흐름

선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실제 구현을 차단할 때만 `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 근거를 채운다. 구현 중 직접 사용자에게 묻거나 채팅 선택지를 만들지 않는다. code-review 단계가 사용자 리뷰 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## 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_0.log`
- Current archived review: `agent-task/m-provider-catalog-device-status/02+01_edge_dispatch/code_review_local_G07_0.log`
- Verdict: FAIL
- Required summary:
  - Provider-pool candidate does not preserve dispatch adapter; selected path rewrites only target, so Node receives empty adapter and router rejects it.
  - Queue in-flight/capacity is keyed by nodeID, not provider identity, so same-node multiple providers cannot be selected independently and provider load ratio is wrong.
  - Runtime provider candidate generation does not defensively filter invalid served-model mappings or unavailable/unknown capacity candidates required by the SDD.
- Suggested/Nit summary: 없음
- Affected files: `apps/edge/internal/service/run_dispatch.go`, `apps/edge/internal/service/model_queue.go`, `apps/edge/internal/service/model_queue_test.go`, `apps/edge/internal/service/service_internal_test.go` or adjacent service tests, optional `apps/edge/internal/service/status_provider_test.go` only if field names change.
- Verification evidence from failed loop: `go test ./apps/edge/internal/openai ./apps/edge/internal/service -count=1` PASS, `go test ./apps/edge/...` PASS, `git diff --check` 출력 없음. These commands did not cover the failing provider-pool service path.
- Roadmap/spec carryover: Roadmap Targets `target-rewrite`, `selection-policy`; Spec Targets `S03`, `S04`.
- Narrow reread allowed: the archived plan/review paths listed above only; do not search `agent-task/archive/**`.

## 분석 결과

### 읽은 파일

- Rules/skills: `AGENTS.md`, `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`.
- Roadmap/spec/contract: `agent-roadmap/current.md`, `agent-roadmap/phase/operational-observability-provider-management/PHASE.md`, `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-catalog-device-status.md`, `agent-roadmap/sdd/operational-observability-provider-management/provider-catalog-device-status/SDD.md`, `agent-contract/index.md`, `agent-contract/provided/openai-compatible-api.md`.
- Domain/test rules: `agent-ops/rules/project/domain/edge/rules.md`, `agent-ops/rules/project/domain/platform-common/rules.md`, `agent-ops/rules/project/domain/testing/rules.md`, `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`.
- Source: `apps/edge/internal/service/run_dispatch.go`, `apps/edge/internal/service/model_queue.go`, `apps/edge/internal/service/service.go`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/responses_handler.go`, `apps/edge/internal/openai/routes.go`, `apps/edge/internal/openai/server.go`, `apps/edge/internal/bootstrap/runtime.go`, `apps/edge/internal/input/manager.go`, `apps/edge/internal/node/registry.go`, `apps/edge/internal/node/store.go`, `apps/node/internal/router/router.go`, `apps/node/internal/node/node.go`, `apps/node/internal/runtime/types.go`, `packages/go/config/config.go`.
- Tests: `apps/edge/internal/openai/server_test.go`, `apps/edge/internal/service/model_queue_test.go`, `apps/edge/internal/service/status_provider_test.go`, `apps/edge/internal/service/service_test.go`, `apps/edge/internal/service/service_internal_test.go`, `packages/go/config/config_test.go`.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`를 읽었다.
- matched profile: `agent-test/local/edge-smoke.md`.
- 적용 명령: 대상 service/openai tests, `go test ./apps/edge/... -count=1`, `git diff --check`.
- Edge smoke rule은 service/input surface 변경 후 full-cycle 또는 OpenAI smoke 필요성을 말하지만, 이 follow-up의 결함은 net.Pipe 기반 service integration test로 deterministic하게 검증한다. 실제 external provider field smoke는 범위 밖이며 secret/field host 상태에 의존하므로 이 plan의 필수 검증으로 두지 않는다.

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

- Provider-pool `SubmitRun`이 실제 Node `RunRequest.Adapter`와 `RunRequest.Target`을 채우는지 검증하는 service-level test가 없다.
- Same-node multiple provider capacity/in-flight 분리 검증이 없다.
- Unavailable provider와 invalid served model membership 후보 제외 검증이 없다.
- Responses API provider-pool path는 `resolveRouteDispatch`를 공유하지만, service-level test가 adapter/target dispatch를 덮으면 추가 HTTP test는 선택 사항이다.

### 심볼 참조

- Rename/remove 계획 없음.
- `candidateNode`, `inflightRec`, `trackInflight`, `releaseSlot`, `findAvailableNodeLocked` 구조 변경 가능성이 있으므로 `rg --sort path 'candidateNode|inflightRec|trackInflight\\(|releaseSlot\\(|findAvailableNodeLocked' apps/edge/internal/service`로 모든 call site를 갱신한다.

### 분할 판단

- split decision policy를 재평가했다. 세 Required는 모두 provider-pool dispatch invariant(선택 candidate가 adapter/target/provider slot을 함께 보존)에 걸려 있어 단일 follow-up이 더 안전하다.
- 새 subtask를 만들지 않고 기존 `m-provider-catalog-device-status/02+01_edge_dispatch` loop 안에서 plan 1로 이어간다.
- predecessor `01_pool_schema`는 이전 구현 기록상 git commit `69efa4e feat: provider catalog device status implementation`로 schema가 반영되어 있고, 이번 follow-up은 active code diff 위의 결함 수정만 다룬다.

### 범위 결정 근거

- OpenAI handler의 catalog 우선 route resolution은 유지한다.
- `packages/go/config` schema validation, YAML examples, README migration text는 이번 결함 수정을 위해 필요할 때만 테스트 보강 범위로 건드린다.
- Node router/adapter 실행 semantics는 바꾸지 않는다. Edge가 Node에 올바른 adapter/target을 보내도록 고친다.
- Control Plane/Client/field provider runtime은 범위 밖이다.

### 빌드 등급

- local-G07. Edge service dispatch와 queue accounting 핵심 결함이지만 관련 맥락이 변경 파일과 인접 tests에 한정되고, net.Pipe/unit tests로 deterministic하게 검증할 수 있다.

## 구현 체크리스트

- [ ] Provider-pool candidate가 dispatch adapter/instance key와 concrete served target을 함께 보존하고, 선택 후 `BuildRunRequest` 전에 `req.Adapter`와 `req.Target`을 모두 rewrite한다.
- [ ] Provider-pool queue admission/release/load-ratio 계산을 provider slot 단위로 분리하되 legacy adapter/target queue 동작은 유지한다.
- [ ] Provider-pool 후보 생성에서 unavailable/invalid provider를 제외하고 served model membership과 dispatch adapter 존재 조건을 방어적으로 검증한다.
- [ ] Service/model_queue tests에 provider-pool RunRequest adapter+target rewrite, same-node provider capacity 분리, unavailable/invalid 후보 제외 회귀 테스트를 추가한다.
- [ ] 중간 및 최종 검증 명령을 실행하고 결과를 `CODE_REVIEW-local-G07.md`에 붙여 넣는다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## REVIEW_API-1 - Provider-pool adapter and target dispatch

### 문제

`apps/edge/internal/service/run_dispatch.go:500`의 candidate는 provider id와 served target만 보존하고, `apps/edge/internal/service/run_dispatch.go:132`는 target만 rewrite한다. OpenAI provider-pool path는 adapter를 빈 값으로 넘기므로 Node 라우터가 `apps/node/internal/router/router.go:25`에서 거부한다.

### 해결 방법

`candidateNode`에 provider dispatch adapter를 추가한다. `resolveProviderPoolCandidates`에서 `prov.Adapter`가 비어 있으면 runtime dispatch 후보로 쓰지 않는다. `submitRunQueued`에서 selected provider-pool candidate의 adapter와 target을 `BuildRunRequest` 전에 모두 반영한다.

Before (`apps/edge/internal/service/run_dispatch.go:132`):

```go
if selected.servedTarget != "" {
	req.Target = selected.servedTarget
}
```

After:

```go
if selected.providerID != "" {
	req.Adapter = selected.adapter
	req.Target = selected.servedTarget
}
```

Before (`apps/edge/internal/service/run_dispatch.go:500`):

```go
candidates = append(candidates, candidateNode{
	entry:        entry,
	capacity:     cap,
	providerID:   prov.ID,
	servedTarget: servedModel,
})
```

After:

```go
adapter := strings.TrimSpace(prov.Adapter)
if adapter == "" {
	continue
}
candidates = append(candidates, candidateNode{
	entry:        entry,
	capacity:     cap,
	providerID:   prov.ID,
	adapter:      adapter,
	servedTarget: servedModel,
})
```

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

- [ ] `apps/edge/internal/service/model_queue.go`: `candidateNode`에 adapter field를 추가한다.
- [ ] `apps/edge/internal/service/run_dispatch.go`: provider-pool selected candidate로 `req.Adapter`와 `req.Target`을 rewrite한다.
- [ ] `apps/edge/internal/service/service_internal_test.go` 또는 같은 package service test: net.Pipe로 `SubmitRun(ProviderPool=true)`가 Node에 `Adapter=prov.Adapter`, `Target=models[].providers[provider]`를 보내는지 검증한다.

### 테스트 작성

- 작성: `TestSubmitRunProviderPoolRewritesAdapterAndTarget`를 추가한다. fixture는 `ModelCatalogEntry{ID:"qwen3.6:35b", Providers: {"prov-vllm":"served-qwen"}}`, `NodeProviderConf{ID:"prov-vllm", Adapter:"vllm-gpu", Models:["served-qwen"], Health:"available", Capacity:1}`를 사용하고, captured `iop.RunRequest`의 adapter/target을 assert한다.

### 중간 검증

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

기대 결과: provider-pool dispatch가 non-empty adapter와 concrete target을 Node request에 싣는다.

## REVIEW_API-2 - Provider slot queue accounting

### 문제

`apps/edge/internal/service/model_queue.go:63`, `:157`, `:196`, `:346`은 in-flight를 nodeID로만 세고 증가시킨다. 같은 Node 안에 provider A/B가 각각 capacity 1로 같은 alias를 제공하면 첫 실행 이후 B도 full로 보이고, SDD의 provider별 `in_flight / capacity` 선택이 깨진다.

### 해결 방법

legacy candidate는 기존 nodeID slot key를 유지하고, provider-pool candidate는 `(nodeID, providerID)` slot key를 사용한다. admission, queued dispatch, release, node disconnect release가 같은 slot key helper를 쓰게 한다. `inflightRec`에는 release에 필요한 slot key와 providerID를 함께 보존한다.

Before (`apps/edge/internal/service/model_queue.go:157`):

```go
inflight := group.inflight[c.entry.NodeID]
```

After:

```go
slot := c.slotKey()
inflight := group.inflight[slot]
```

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

- [ ] `apps/edge/internal/service/model_queue.go`: provider-aware slot key helper를 추가하고 admission/release paths를 갱신한다.
- [ ] `apps/edge/internal/service/run_dispatch.go`: selected candidate release/track paths가 slot key와 nodeID/providerID를 모두 잃지 않게 갱신한다.
- [ ] `apps/edge/internal/service/model_queue_test.go`: same node, two provider candidates, each capacity 1인 경우 두 요청이 모두 즉시 admit되고 각 provider ratio가 독립 계산되는 테스트를 추가한다.
- [ ] 기존 disconnect/timeout/FIFO tests를 새 slot key 구조에 맞게 유지한다.

### 테스트 작성

- 작성: `TestModelQueueProviderCapacityIsPerProviderSlot`를 추가한다. 같은 `NodeEntry`에 `providerID=prov-a`와 `providerID=prov-b` candidate를 만들고 두 번 admit했을 때 두 번째가 queue timeout 없이 다른 provider로 선택되는지 assert한다.

### 중간 검증

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

기대 결과: provider pool capacity가 provider slot 단위로 분리되고 기존 timeout/disconnect 회귀가 유지된다.

## REVIEW_API-3 - Provider candidate validity filters

### 문제

`apps/edge/internal/service/run_dispatch.go:491`은 provider id가 catalog에 있으면 후보로 추가한다. SDD는 `models[].providers[provider_id]` served target이 provider `models[]` 안에 있어야 하고, unavailable 후보와 capacity 0/unknown 후보를 available selection에서 제외해야 한다.

### 해결 방법

후보 생성 시 served target membership, dispatch adapter 존재, provider health/capacity를 방어적으로 확인한다. `health`는 SDD 기준값을 우선해 `available`만 dispatchable로 보고, 기존 fixture 호환이 필요하면 `healthy`를 `available` alias로 인정한다. `unavailable`과 `unknown`은 skip한다. 후보가 모두 제외되면 기존 no-candidate error를 반환한다.

Before (`apps/edge/internal/service/run_dispatch.go:491`):

```go
servedModel, inCatalog := catalogEntry.Providers[prov.ID]
if !inCatalog {
	continue
}
```

After:

```go
servedModel, inCatalog := catalogEntry.Providers[prov.ID]
if !inCatalog || !providerCanServe(prov, servedModel) || !providerDispatchable(prov) {
	continue
}
```

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

- [ ] `apps/edge/internal/service/run_dispatch.go`: provider membership/health/capacity helper를 추가한다.
- [ ] `apps/edge/internal/service/service_internal_test.go` 또는 `model_queue_test.go`: unavailable provider, served model mismatch, empty adapter provider가 dispatch 후보에서 제외되는 테스트를 추가한다.
- [ ] 필요한 경우 `apps/edge/internal/service/status_provider_test.go`는 read-only catalog snapshot 표시가 기존대로 남는지 확인한다.

### 테스트 작성

- 작성: `TestResolveProviderPoolCandidatesFiltersInvalidProviders`를 추가한다. provider A는 `Health:"unavailable"`, provider B는 served model mismatch, provider C는 valid로 두고 C만 candidate가 되는지 검증한다.

### 중간 검증

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

기대 결과: invalid/unavailable providers are not selected, status tests still pass.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/run_dispatch.go` | REVIEW_API-1, REVIEW_API-3 |
| `apps/edge/internal/service/model_queue.go` | REVIEW_API-1, REVIEW_API-2 |
| `apps/edge/internal/service/model_queue_test.go` | REVIEW_API-2 |
| `apps/edge/internal/service/service_internal_test.go` 또는 인접 service test | REVIEW_API-1, REVIEW_API-3 |
| `apps/edge/internal/service/status_provider_test.go` | 필요 시 REVIEW_API-2/3 회귀 보정 |

## 최종 검증

```bash
go test ./apps/edge/internal/service -run 'TestSubmitRunProviderPool|TestResolveProviderPoolCandidates|TestModelQueue.*Provider|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
```

기대 결과: provider-pool dispatch regression tests와 전체 Edge Go tests가 통과하고 whitespace error가 없다.

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