<!-- task=m-provider-resource-admission-ownership-alignment/05+03,04_provider_pool_policy_admission plan=0 tag=PROVIDER_POLICY_ADMISSION -->

# Provider-pool 공통 Queue Admission 계획

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

구현과 검증을 완료한 뒤 `CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 stdout/stderr로 채우고 active 파일을 그대로 둔 채 리뷰 준비 상태를 보고한다. 종결 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. 선택된 Milestone의 `구현 잠금 > 결정 필요`가 구현을 막을 때만 review stub의 `사용자 리뷰 요청`에 정확한 연결 근거를 기록하고 멈춘다. 직접 사용자 질문·선택지·`request_user_input`, `USER_REVIEW.md`, archive log, `complete.log` 작성은 금지한다. 환경/secret/service blocker, 일반 범위 조정, 후속 에이전트가 해소할 수 있는 evidence gap은 사용자 리뷰 요청이 아니다.

## 배경

현재 scheduler는 queue policy를 model group에 저장하고 `len(group.queue)`로 상한을 검사하며, provider-pool resolution은 첫 provider의 legacy queue 값을 반환한다. 04가 만든 root effective policy를 Service runtime snapshot과 queue manager에 원자적으로 전달하고, 모든 model group을 합친 pending 상한과 공통 timeout을 적용해야 SDD S06이 완성된다. 이 작업은 live candidate 재해석을 보장하는 선행 03과 schema를 보장하는 04가 PASS한 뒤 구현한다.

## 사용자 리뷰 요청 흐름

선택된 Milestone lock 결정만 active `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 템플릿에 기록한다. 구현 중 직접 사용자 prompt는 금지하며 code-review가 검증과 실제 `USER_REVIEW.md` 작성을 소유한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
- Task ids:
  - `queue-ownership`: Edge 공통 provider-pool queue policy와 전체 pending 상한
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- 규칙/라우팅: `AGENTS.md`, `agent-ops/rules/project/rules.md`, `agent-ops/rules/common/rules-roadmap.md`, `agent-ops/rules/common/rules-agent-spec.md`, `agent-ops/skills/common/router.md`, `agent-ops/skills/common/plan/SKILL.md`
- 로드맵/SDD: `agent-roadmap/current.md`, 활성 Milestone, 승인된 `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`
- 계약/스펙: `agent-contract/index.md`, `agent-contract/inner/edge-config-runtime-refresh.md`, `edge-node-runtime-wire.md`, `control-plane-edge-wire.md`, `agent-spec/index.md`, `agent-spec/runtime/provider-pool-config-refresh.md`, `edge-node-execution.md`, `control-plane-operations.md`
- queue/service 소스: `apps/edge/internal/service/model_queue_types.go`, `model_queue_admission.go`, `model_queue_release.go`, `model_queue_snapshot.go`, `provider_resolution.go`, `provider_pool.go`, `service.go`, `run_types.go`, `run_submit.go`, `provider_tunnel.go`, `queue_reservation.go`
- runtime/node 소스: `apps/edge/internal/bootstrap/runtime.go`, `apps/edge/internal/node/store.go`
- 설정/refresh 소스: `packages/go/config/edge_types.go`, `provider_types.go`, `load.go`, `apps/edge/internal/configrefresh/classify.go`
- 테스트: `apps/edge/internal/service/model_queue_test_support_test.go`, `model_queue_admission_test.go`, `provider_scheduling_test.go`, `provider_scheduling_advanced_test.go`, `queue_dispatch_test.go`, `service_internal_test.go`, `long_context_queue_test.go`, `queue_reservation_test.go`, `apps/edge/internal/bootstrap/runtime_refresh_test.go`, `runtime_refresh_node_test.go`, `runtime_test_support_test.go`
- 테스트 규칙/프로필: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/platform-common-smoke.md`, `agent-test/local/testing-smoke.md`

### SDD 기준

- SDD 상태 `[승인됨]`, Acceptance Scenario S06 / Task `queue-ownership`.
- S06은 multi-provider와 shared-provider multi-group에서 root common max/timeout, 전체 pending 상한, first-candidate/model-group non-owner, legacy conflict rejection을 요구한다.
- 04가 schema/conflict evidence를 만들고, 이 계획은 runtime handoff·global cap·common timeout deterministic tests와 Edge 전체 회귀를 만들어 Evidence Map의 S06 행을 완성한다.

### 테스트 환경 규칙

- `test_env=local`; edge/platform-common/testing profiles를 읽고 fresh targeted, race, `go test -count=1 ./apps/edge/...`, live smoke preflight를 고정한다.
- runner `/config/workspace/iop`, branch `main`, HEAD `f8703246900a`, Linux aarch64, Go 1.26.2, 기존 dirty worktree다.
- `bash`, `go`, `rg`, `scripts/e2e-long-context-admission-smoke.sh` 존재와 script help를 확인했다. 기본 config `configs/edge.yaml`, Edge base URL `http://toki-labs.com:18083/v1`, status URL `http://127.0.0.1:18001/edges/edge-toki-labs-dev/status`, model `qwen3.6:35b`다.
- 기존 preflight는 config/source check까지만 가능하고 live endpoint는 별도 환경에 의존한다. 구현 후 preflight가 OK일 때만 `mixed` scenario를 실행하고, rc=3이면 exact output/out-dir를 일반 검증 blocker로 기록한다.
- 기준선 fresh config/configrefresh와 provider-policy 관련 service targeted tests는 PASS했다.

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

- `model_queue_admission.go:389`의 상한은 group-local이며 두 group 합산 상한 회귀가 없다.
- provider candidate 순서를 바꿔도 root policy가 동일하다는 service test가 없다.
- Service runtime config snapshot은 store/catalog만 원자적으로 묶고 root provider-pool policy가 없다.
- NewRuntime/apply refresh가 queue manager에 policy를 전달하는 end-to-end test가 없다.
- 기존 `TestRefreshProviderQueuePolicyUpdatesExistingGroup`, `TestSubmitRunModelQueueUsesProviderInstancePolicy`, `...UsesRouteQueueTimeout`은 provider/group owner를 전제로 하므로 새 owner 의미로 교체해야 한다.

### 심볼 참조

- `Service.SetRuntimeConfig` 호출부: `apps/edge/internal/bootstrap/runtime.go`와 `apps/edge/internal/service/service_internal_test.go`.
- `runtimeConfigSnapshot` 호출부: `provider_resolution.go`, `service_internal_test.go`, `provider_scheduling_test.go`, `provider_scheduling_advanced_test.go`.
- `resolveProviderPoolCandidates` 호출부는 위 tests와 `provider_resolution.go`다.
- `groupPolicyFromRequestOrStore`/`groupPolicyFromStore`는 provider resolution과 provider scheduling tests에서 참조된다. legacy non-provider path는 유지하고 provider-pool path만 root policy로 분리한다.
- `modelQueueGroup.policy` 직접 test 참조는 `provider_scheduling_test.go`; manager admission signature는 많은 queue tests가 사용하므로 불필요한 rename 없이 global owner로 내부 의미만 이동한다.

### 분할 판단

- split decision policy를 먼저 평가해 config owner(04), admission/runtime(05), existing waiter refresh/timer/race(06)를 분리했다.
- 선행 03은 `agent-task/m-provider-resource-admission-ownership-alignment/03+01,02_cross_group_wakeup/`에 active `PLAN-local-G06.md`/`CODE_REVIEW-local-G06.md`가 있고 `complete.log`가 없어 미충족이다.
- 선행 04는 `agent-task/m-provider-resource-admission-ownership-alignment/04_provider_pool_policy_config/`에 active plan/review가 생기며 `complete.log` 전에는 미충족이다.
- 두 predecessor가 모두 PASS/archive된 뒤 이 plan을 구현한다.
- sibling `06+05_refresh_consistency`는 이 05를 기다리고, `07+03_snapshot_source`는 03만 기다려 snapshot 관측 경계에서 독립 진행한다.

### 범위 결정 근거

- 이미 queued된 item의 timeout을 refresh 시 재기준화하는 timer/channel 변경과 capacity/priority/enabled 종합 race는 06으로 남긴다.
- provider/adapter legacy fields 제거, Node protobuf/mapper 변경은 compatibility 범위 밖이다.
- contract/agent-spec 문서는 `contract-spec-sync`, live backend peak 검증은 `capacity-smoke` Task가 소유한다.

### 빌드 등급

- `local-G07`: public config 소비와 Service/queue runtime 경계를 함께 바꾸는 shared-state 작업이지만 SDD semantics와 deterministic/race tests가 명확하고 외부 판단이 필요 없다.

## 의존 관계 및 구현 순서

1. `03+01,02_cross_group_wakeup`의 matching active/archive `complete.log`가 생겨야 한다. 현재 미충족이다.
2. `04_provider_pool_policy_config`의 matching active/archive `complete.log`가 생겨야 한다. 현재 미충족이다.
3. 완료된 03의 resolver-aware admission API를 기준으로 root policy snapshot을 연결한다.
4. queue owner를 manager-global로 이동하고 합산 상한을 구현한다.
5. unit/service/bootstrap 검증 뒤 race/Edge 전체/preflight를 실행한다.

## 구현 체크리스트

- [ ] root effective provider-pool policy를 Service의 store/catalog와 같은 runtime snapshot으로 원자적으로 보유하고 NewRuntime/apply refresh가 queue manager까지 전달하도록 구현한다.
- [ ] provider-pool resolution에서 provider/request/model-group policy 승격을 제거하고 모든 provider-pool admission 경로가 root common policy만 사용하도록 구현한다.
- [ ] queue manager가 provider-pool policy scope를 명시적으로 보유한 pending만 model group 경계를 넘어 합산해 common `max_queue`를 적용하고 common timeout/explicit no-timeout을 각 신규 provider-pool request에 적용하되 legacy adapter queue policy를 보존하도록 구현한다.
- [ ] multi-provider 순서 독립성, cross-group provider-pool cap, legacy pending 격리, common timeout, initial/apply runtime handoff 회귀와 race/Edge 전체 검증을 추가·갱신한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [PROVIDER_POLICY_ADMISSION-1] Atomic runtime policy snapshot과 first-candidate 제거

#### 문제

`apps/edge/internal/service/service.go:23-105`는 store/catalog만 snapshot으로 묶는다. `provider_resolution.go:382-470`은 후보 loop에서 처음 만난 provider queue 값을 반환하고, `bootstrap/runtime.go:47-63,213-226`은 initial/apply 모두 root policy를 Service에 전달하지 않는다.

#### 해결 방법

04의 `config.EdgeProviderPoolConf`를 Service runtime snapshot에 포함한다. `SetRuntimeConfig`는 store/catalog/policy를 한 lock에서 교체하고 queue manager에는 store와 converted `groupPolicy`를 한 critical section에서 reconcile하도록 전달한다. initial NewRuntime도 분리 setter 대신 같은 atomic setter를 사용한다. provider-pool resolution은 후보만 계산하고 policy는 snapshot의 root effective value에서 만든다.

Before (`provider_resolution.go:446-460`):

```go
candidate := candidateNode{entry: entry, providerID: prov.ID, servedTarget: servedModel}
applyProviderDispatchFields(&candidate, prov)
candidates = append(candidates, candidate)
if !policySet && (prov.MaxQueue > 0 || prov.QueueTimeoutMS > 0) {
	policy = groupPolicy{ /* first provider */ }
	policySet = true
}
```

After:

```go
candidate := candidateNode{entry: entry, providerID: prov.ID, servedTarget: servedModel}
applyProviderDispatchFields(&candidate, prov)
candidates = append(candidates, candidate)
// policy는 atomic runtime snapshot의 provider_pool effective value만 사용한다.
```

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

- [ ] `apps/edge/internal/service/service.go`: policy를 포함하는 atomic runtime snapshot과 clone/conversion 경계를 추가한다.
- [ ] `apps/edge/internal/service/provider_resolution.go`: provider-pool first-candidate policy selection과 request override를 제거하고 root policy를 반환한다. legacy non-provider resolution은 유지한다.
- [ ] `apps/edge/internal/service/model_queue_types.go`: manager-global provider-pool policy state와 store+policy atomic reconcile entrypoint를 추가한다.
- [ ] `apps/edge/internal/bootstrap/runtime.go`: initial/apply 모두 `cfg.ProviderPool`을 같은 Service update에 전달한다.
- [ ] `apps/edge/internal/service/provider_pool.go`, `run_submit.go`, `provider_tunnel.go`: 선행 03 이후 admission call이 root policy snapshot만 소비하는지 맞춘다.

#### 테스트 작성

- `service_internal_test.go`의 runtime snapshot concurrency test를 store/catalog/policy A/B가 섞이지 않는 assertion으로 확장한다.
- `provider_scheduling_advanced_test.go`에 candidate/provider 순서를 바꿔도 root max/timeout이 같은 test를 추가한다.
- `runtime_refresh_test.go`에 initial policy와 apply 후 신규 admission policy가 candidate legacy 값과 무관하게 바뀌는 runtime-level test를 추가한다.

#### 중간 검증

```bash
gofmt -d apps/edge/internal/service/service.go apps/edge/internal/service/provider_resolution.go apps/edge/internal/service/model_queue_types.go apps/edge/internal/bootstrap/runtime.go apps/edge/internal/service/provider_pool.go apps/edge/internal/service/run_submit.go apps/edge/internal/service/provider_tunnel.go apps/edge/internal/service/service_internal_test.go apps/edge/internal/service/provider_scheduling_advanced_test.go apps/edge/internal/bootstrap/runtime_refresh_test.go
GOCACHE=/tmp/iop-go-cache-provider-policy-admission go test -count=1 ./apps/edge/internal/service -run 'Test(RuntimeConfigSnapshotConcurrentReplace|ResolveProviderPoolPolicy)'
GOCACHE=/tmp/iop-go-cache-provider-policy-admission go test -count=1 ./apps/edge/internal/bootstrap -run 'TestRefreshProviderPoolPolicy'
```

기대 결과: store/catalog/policy 혼합 snapshot 없음, 후보 순서가 policy를 바꾸지 않음, runtime refresh 신규 admission이 root policy를 사용함.

### [PROVIDER_POLICY_ADMISSION-2] Edge 전체 pending cap과 common timeout

#### 문제

`model_queue_types.go:128-137`은 policy를 group에 저장하고 `model_queue_admission.go:361-365,389-444`은 기존 group policy를 갱신한 뒤 `len(group.queue)`만 검사한다. 따라서 max 2인 두 provider-pool model group은 각각 2개씩 총 4개를 pending으로 둘 수 있다. 같은 manager는 legacy adapter admission도 처리하므로 모든 queue item을 무조건 합산하면 반대로 provider-pool policy가 legacy queue를 침범한다.

#### 해결 방법

manager가 normalized common provider-pool policy 하나를 소유하되 admission/item에 명시적 `providerPool` policy scope를 전달한다. provider-pool request는 `pendingProviderPoolCountLocked()`로 모든 group의 provider-pool item만 합산하고, legacy request는 기존 group/adapter policy와 group-local 상한을 그대로 사용한다. candidate provider id나 resolver nil 여부로 scope를 추론하지 않는다.

provider-pool policy shrink는 이미 accepted된 provider-pool pending을 강제 제거하지 않고 합산 count가 새 상한 아래로 내려갈 때까지 신규 provider-pool enqueue를 거부한다. 신규 provider-pool item deadline은 manager common timeout에서 만들고 timeout 0은 caller context/dispatch만 기다린다. group은 provider-pool에 대해서는 routing/FIFO storage만 소유하지만 legacy policy state는 compatibility를 위해 유지한다.

Before (`model_queue_admission.go:389-444`):

```go
if len(group.queue) >= group.policy.maxQueue {
	return nil, "", fmt.Errorf("model group %q: %w", groupKey, errQueueFull)
}
// ...
if group.policy.queueTimeout > 0 {
	deadline = time.Now().Add(group.policy.queueTimeout)
}
```

After:

```go
policy := m.providerPoolPolicy
if providerPool && m.pendingProviderPoolCountLocked() >= policy.maxQueue {
	return nil, "", fmt.Errorf("provider pool: %w", errQueueFull)
}
deadline := deadlineFrom(time.Now(), policy.queueTimeout)
```

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

- [ ] `apps/edge/internal/service/model_queue_types.go`: manager-global provider-pool policy와 explicit item policy scope를 추가하고 legacy group policy를 보존한다.
- [ ] `apps/edge/internal/service/model_queue_admission.go`: provider-pool-only global pending count, common deadline/no-timeout, shrink-preserve와 legacy group-local semantics를 구현한다.
- [ ] `apps/edge/internal/service/provider_scheduling_test.go`: group-policy 전제 tests를 global owner tests로 교체한다.
- [ ] `apps/edge/internal/service/queue_dispatch_test.go`: provider-instance/first-provider policy tests를 root common policy service tests로 갱신한다.
- [ ] `apps/edge/internal/service/model_queue_admission_test.go`: 서로 다른 group의 합산 max와 cancel/timeout 후 global count 회복을 검증한다.

#### 테스트 작성

- `TestProviderPoolMaxQueueCountsPendingAcrossGroups`: capacity를 채운 뒤 group A/B 합계가 max에 도달하면 다음 group admission이 `errQueueFull`.
- `TestProviderPoolCommonQueueTimeoutAcrossCandidateOrders`: legacy provider values와 catalog/provider order가 달라도 root timeout으로 종료.
- `TestProviderPoolZeroQueueTimeoutWaitsForContext`: root timeout 0은 internal queue timeout이 아닌 context cancellation.
- `TestProviderPoolPendingCountRecoversAfterCancelAndTimeout`: removal race 뒤 합산 count와 lease/resource counter가 0.
- `TestProviderPoolMaxQueueIgnoresLegacyPending`: legacy adapter pending은 provider-pool global count에 포함되지 않고 legacy 자체 상한도 root policy에 바뀌지 않는다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache-provider-policy-admission go test -count=1 ./apps/edge/internal/service -run 'TestProviderPool(MaxQueueCountsPendingAcrossGroups|MaxQueueIgnoresLegacyPending|CommonQueueTimeoutAcrossCandidateOrders|ZeroQueueTimeoutWaitsForContext|PendingCountRecoversAfterCancelAndTimeout)$'
GOCACHE=/tmp/iop-go-cache-provider-policy-admission go test -race -count=5 ./apps/edge/internal/service -run 'TestProviderPool(MaxQueueCountsPendingAcrossGroups|MaxQueueIgnoresLegacyPending|PendingCountRecoversAfterCancelAndTimeout)$'
```

기대 결과: global cap/common timeout tests와 race 반복 PASS, pending/lease/resource leak 없음.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/service.go` | PROVIDER_POLICY_ADMISSION-1 |
| `apps/edge/internal/service/provider_resolution.go` | PROVIDER_POLICY_ADMISSION-1 |
| `apps/edge/internal/service/model_queue_types.go` | PROVIDER_POLICY_ADMISSION-1, PROVIDER_POLICY_ADMISSION-2 |
| `apps/edge/internal/service/model_queue_admission.go` | PROVIDER_POLICY_ADMISSION-2 |
| `apps/edge/internal/bootstrap/runtime.go` | PROVIDER_POLICY_ADMISSION-1 |
| `apps/edge/internal/service/provider_pool.go` | PROVIDER_POLICY_ADMISSION-1 |
| `apps/edge/internal/service/run_submit.go` | PROVIDER_POLICY_ADMISSION-1 |
| `apps/edge/internal/service/provider_tunnel.go` | PROVIDER_POLICY_ADMISSION-1 |
| `apps/edge/internal/service/service_internal_test.go` | PROVIDER_POLICY_ADMISSION-1 |
| `apps/edge/internal/service/provider_scheduling_advanced_test.go` | PROVIDER_POLICY_ADMISSION-1 |
| `apps/edge/internal/bootstrap/runtime_refresh_test.go` | PROVIDER_POLICY_ADMISSION-1 |
| `apps/edge/internal/service/provider_scheduling_test.go` | PROVIDER_POLICY_ADMISSION-2 |
| `apps/edge/internal/service/queue_dispatch_test.go` | PROVIDER_POLICY_ADMISSION-2 |
| `apps/edge/internal/service/model_queue_admission_test.go` | PROVIDER_POLICY_ADMISSION-2 |

## 최종 검증

```bash
gofmt -d apps/edge/internal/service/service.go apps/edge/internal/service/provider_resolution.go apps/edge/internal/service/model_queue_types.go apps/edge/internal/service/model_queue_admission.go apps/edge/internal/bootstrap/runtime.go apps/edge/internal/service/provider_pool.go apps/edge/internal/service/run_submit.go apps/edge/internal/service/provider_tunnel.go apps/edge/internal/service/service_internal_test.go apps/edge/internal/service/provider_scheduling_advanced_test.go apps/edge/internal/bootstrap/runtime_refresh_test.go apps/edge/internal/service/provider_scheduling_test.go apps/edge/internal/service/queue_dispatch_test.go apps/edge/internal/service/model_queue_admission_test.go
git diff --check
GOCACHE=/tmp/iop-go-cache-provider-policy-admission go test -count=1 ./apps/edge/internal/service -run 'TestProviderPool(MaxQueueCountsPendingAcrossGroups|MaxQueueIgnoresLegacyPending|CommonQueueTimeoutAcrossCandidateOrders|ZeroQueueTimeoutWaitsForContext|PendingCountRecoversAfterCancelAndTimeout)$'
GOCACHE=/tmp/iop-go-cache-provider-policy-admission go test -race -count=5 ./apps/edge/internal/service -run 'TestProviderPool(MaxQueueCountsPendingAcrossGroups|MaxQueueIgnoresLegacyPending|PendingCountRecoversAfterCancelAndTimeout)$'
GOCACHE=/tmp/iop-go-cache-provider-policy-admission go test -count=1 ./apps/edge/internal/bootstrap -run 'TestRefreshProviderPoolPolicy'
GOCACHE=/tmp/iop-go-cache-provider-policy-admission go test -count=1 ./apps/edge/...
GOCACHE=/tmp/iop-go-cache-provider-policy-admission scripts/e2e-long-context-admission-smoke.sh --preflight --out-dir /tmp/iop-long-admission-smoke-provider-policy-admission
```

preflight가 OK면 다음 명령까지 실행한다.

```bash
GOCACHE=/tmp/iop-go-cache-provider-policy-admission scripts/e2e-long-context-admission-smoke.sh --preflight --scenario mixed --out-dir /tmp/iop-long-admission-smoke-provider-policy-admission-mixed
```

preflight가 endpoint blocker로 rc=3이면 scenario를 실행하지 않고 stdout/stderr와 `/tmp/iop-long-admission-smoke-provider-policy-admission` evidence path를 기록한다. 모든 Go test는 fresh execution만 허용한다.

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