<!-- task=m-node-resource-model-unification/03+01_edge_dispatch_status plan=0 tag=REFACTOR -->

# Edge Dispatch And Status Resource Plan

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

이 계획은 `01_contract_schema` 완료 후 구현한다. 구현 뒤에는 반드시 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 채운다. Milestone lock 결정 외 이유로 사용자에게 직접 질문하지 않는다.

## 배경

Edge는 OpenAI-compatible 요청에 `ModelGroupKey`를 항상 담고, service는 `ModelGroupKey != ""`이면 queue를 탄다. 사용자의 방향에서는 provider pool만 concurrency/queue를 관리해야 하며 CLI route는 queue/global gate에 묶이지 않아야 한다. 또한 Control Plane status는 node 아래 실제 정의된 resource/provider를 중복 없이 보여야 한다.

## 사용자 리뷰 요청 흐름

선택된 Milestone의 구현 잠금은 해제되어 있으므로 기본값은 `없음`이다. 외부 provider 접근 실패나 dev smoke 증거 공백은 04번 검증 또는 후속 계획으로 기록한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-resource-model-unification.md`
- Task ids:
  - `edge-routing-status`: Edge provider-only queue routing and resource-first status
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/status_provider.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/routes.go`
- `apps/edge/internal/edgevalidate/validate.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/transport/server.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/service/service_internal_test.go`
- `apps/edge/internal/service/status_provider_test.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/node/mapper_test.go`
- `apps/edge/internal/node/store_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/transport/server_test.go`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-contract/inner/control-plane-edge-wire.md`
- `agent-contract/inner/edge-config-runtime-refresh.md`
- `agent-roadmap/phase/operational-observability-provider-management/milestones/node-resource-model-unification.md`
- `agent-roadmap/sdd/operational-observability-provider-management/node-resource-model-unification/SDD.md`

### SDD 기준

선택된 Milestone은 `SDD: 필요`이며 `agent-roadmap/sdd/operational-observability-provider-management/node-resource-model-unification/SDD.md`가 `[승인됨]`, SDD 잠금 `해제` 상태다. 이 subtask는 Acceptance Scenario `S05`, `S06`과 Milestone Task `edge-routing-status`를 대상으로 한다.

Evidence Map은 provider-only queue test, CLI/direct route direct dispatch evidence, resource catalog status snapshot evidence를 요구한다. 따라서 구현 체크리스트는 `ProviderPool && ModelGroupKey != "" && queue != nil` queue 조건, provider adapter validation, resource-first status snapshot, provider별 accounting regression을 포함한다.

### 테스트 환경 규칙

test_env는 `local`이다. Edge service/OpenAI/status 변경이므로 `agent-test/local/edge-smoke.md`와 `platform-common-smoke.md`를 적용한다. 필수 unit은 `go test -count=1 ./apps/edge/...`이며, OpenAI-compatible boundary는 `/v1/models`, `/v1/responses`, `/v1/chat/completions` unit/e2e coverage를 확인한다.

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

- CLI route가 `ModelGroupKey`를 가져도 provider queue를 타지 않는 regression이 필요하다.
- provider adapter가 missing/disabled/ambiguous일 때 config validation 또는 candidate resolution에서 제외되는 test가 필요하다.
- `getSnapshotForNode`가 adapter-level snapshot과 provider catalog snapshot을 중복으로 내는 현상은 status provider test 갱신이 필요하다.

### 심볼 참조

의미 변경 대상:

- `SubmitRun`: `apps/edge/internal/service/run_dispatch.go:107`
- `resolveAdapterForNode`: `apps/edge/internal/service/run_dispatch.go:297`
- `providerDispatchable`: `apps/edge/internal/service/run_dispatch.go:476`
- `getSnapshotForNode`: `apps/edge/internal/service/model_queue.go:529`
- `ModelGroupKey` writes: `apps/edge/internal/openai/chat_handler.go:75`, `apps/edge/internal/openai/responses_handler.go:89`

### 분할 판단

이 subtask는 `03+01_edge_dispatch_status`이므로 predecessor `01_contract_schema` 완료가 필요하다. 현재 predecessor는 active plan 상태이며 `complete.log`가 없다. `02+01_node_admission`과 병렬 가능하나 04번은 둘 다 완료 후 진행한다.

### 범위 결정 근거

Node-local gate 제거는 02번 범위다. dev-runtime remote deploy와 actual `build/dev-runtime/edge.yaml` 반영은 04번 범위다. 이 subtask는 Edge runtime code와 tests만 변경한다.

### 빌드 등급

`cloud-G07`: OpenAI outer API, service dispatch, provider queue, Control Plane status가 같이 걸린 cross-domain 변경이다.

## 의존 관계 및 구현 순서

`03+01_edge_dispatch_status`는 sibling `01_contract_schema` 완료 후 구현한다. `02`와 직접 dependency는 없지만 04가 두 결과를 통합 검증한다.

## 구현 체크리스트

- [ ] service queue 진입 조건을 `ProviderPool && ModelGroupKey != "" && queue != nil`로 제한한다.
- [ ] CLI/openai legacy route는 `ModelGroupKey`가 있어도 direct dispatch로 흐르고 provider pool queue/inflight를 사용하지 않게 한다.
- [ ] provider-pool candidate resolution에서 provider adapter가 같은 node의 enabled adapter instance인지 확인한다.
- [ ] status snapshot은 정의된 resource/provider catalog를 우선하고, legacy adapter snapshot은 catalog가 없을 때만 compat로 낸다.
- [ ] provider별 in_flight/queued accounting이 같은 node의 여러 provider에서 독립적으로 유지되는지 test를 보강한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REFACTOR-1] Provider-only Queue Admission

**문제:** `apps/edge/internal/service/run_dispatch.go:107`은 `ModelGroupKey != ""`이면 모든 route를 queue로 보낸다. OpenAI handlers는 legacy CLI route에도 `ModelGroupKey`를 채운다(`chat_handler.go:75`, `responses_handler.go:89`).

**해결 방법:** queue path는 `req.ProviderPool` true일 때만 사용한다. OpenAI handler는 그대로 `ProviderPool`을 전달하므로 handler 변경은 최소화한다.

Before:

```go
// apps/edge/internal/service/run_dispatch.go:107
if req.ModelGroupKey != "" && s.queue != nil {
    return s.submitRunQueued(ctx, req)
}
```

After:

```go
if req.ProviderPool && req.ModelGroupKey != "" && s.queue != nil {
    return s.submitRunQueued(ctx, req)
}
return s.submitRunDirect(req)
```

**수정 파일 및 체크리스트:**

- [ ] `apps/edge/internal/service/run_dispatch.go`
- [ ] `apps/edge/internal/openai/server_test.go`
- [ ] `apps/edge/internal/service/service_test.go`

**테스트 작성:** legacy CLI model route request with non-empty `ModelGroupKey`가 queue groups/inflight를 만들지 않고 direct dispatch되는 test를 추가한다. provider-pool route는 기존처럼 queue를 탄다는 test를 유지한다.

**중간 검증:**

```bash
go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai
```

### [REFACTOR-2] Provider Adapter Dispatch Validation

**문제:** `providerDispatchable`은 `apps/edge/internal/service/run_dispatch.go:476`에서 non-empty adapter만 확인한다. `nodes[].providers[].adapter`가 실제 node adapter와 어긋나도 provider-pool candidate가 될 수 있다.

**해결 방법:** 01번 validation helper를 재사용하거나 service-local resolver를 추가해 provider adapter가 enabled exact instance 또는 unambiguous type route인지 확인한다. Config validation에서 막되, service candidate resolution도 defensive check를 둔다.

**수정 파일 및 체크리스트:**

- [ ] `apps/edge/internal/service/run_dispatch.go`
- [ ] `apps/edge/internal/service/model_queue_test.go`
- [ ] `apps/edge/internal/edgevalidate/validate.go`
- [ ] `apps/edge/internal/edgevalidate/*_test.go`

**테스트 작성:** missing adapter, disabled adapter, ambiguous `openai_compat` type route, valid named adapter 각각 provider-pool candidate 결과를 검증한다.

**중간 검증:**

```bash
go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/edgevalidate
```

### [REFACTOR-3] Resource-first Status Snapshot

**문제:** `getSnapshotForNode`는 `apps/edge/internal/service/model_queue.go:540`부터 CLI/Ollama/vLLM/OpenAICompat adapter snapshots를 만들고, 다시 `:626`부터 provider catalog snapshots를 추가한다. 같은 OpenAICompat provider가 adapter snapshot과 provider snapshot으로 중복 노출된다.

**해결 방법:** `rec.Providers`가 있으면 그 catalog를 source of truth로 삼아 snapshots를 만들고 providerID stats만 붙인다. `rec.Providers`가 비어 있는 old config에서만 adapter-level compat snapshots를 낸다. CLI resource를 status에 보이게 하려면 config에 category `cli` provider/resource entry가 있어야 한다.

**수정 파일 및 체크리스트:**

- [ ] `apps/edge/internal/service/model_queue.go`
- [ ] `apps/edge/internal/service/status_provider.go`
- [ ] `apps/edge/internal/service/status_provider_test.go`
- [ ] `apps/edge/internal/service/model_queue_test.go`
- [ ] `proto/iop/control.proto` comments only if needed

**테스트 작성:** node with CLI + OpenAICompat + providers catalog가 catalog snapshots만 반환하고, provider pool stats가 provider id별로 붙는지 확인한다. providers empty legacy node는 old adapter snapshot fallback을 확인한다.

**중간 검증:**

```bash
go test -count=1 ./apps/edge/internal/service
```

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/edge/internal/service/run_dispatch.go` | REFACTOR-1, REFACTOR-2 |
| `apps/edge/internal/service/model_queue.go` | REFACTOR-2, REFACTOR-3 |
| `apps/edge/internal/service/status_provider.go` | REFACTOR-3 |
| `apps/edge/internal/openai/server_test.go` | REFACTOR-1 |
| `apps/edge/internal/service/model_queue_test.go` | REFACTOR-2, REFACTOR-3 |
| `apps/edge/internal/service/service_test.go` | REFACTOR-1 |
| `apps/edge/internal/service/status_provider_test.go` | REFACTOR-3 |
| `apps/edge/internal/edgevalidate/validate.go` | REFACTOR-2 |

## 최종 검증

```bash
go test -count=1 ./apps/edge/...
```

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