<!-- task=m-provider-resource-admission-ownership-alignment/07+03_snapshot_source plan=0 tag=PROVIDER_SNAPSHOT_SOURCE -->

# Provider Snapshot Live Candidate Pressure 계획

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

구현과 검증을 완료한 뒤 `CODE_REVIEW-local-G05.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은 사용자 리뷰 요청이 아니다.

## 배경

provider snapshot의 in-flight는 이미 provider resource lease state를 읽고 multi-candidate queued 값의 비가산 의미도 회귀로 고정했지만, queued 계산은 아직 `queueItem.candidates`의 enqueue 시점 snapshot을 순회한다. 선행 03이 live candidate resolver를 완성하면 provider enable/capacity refresh 뒤의 현재 후보군과 snapshot pressure가 달라질 수 있으므로, `snapshot-source` 완료 표시는 아직 정당하지 않다. 이 작업은 03 PASS 뒤 snapshot이 같은 live candidate universe를 읽도록 정렬하고 cross-model 실행·대기·반환 전 과정을 검증한다.

## 사용자 리뷰 요청 흐름

선택된 Milestone lock 결정만 active `CODE_REVIEW-local-G05.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:
  - `snapshot-source`: provider lease state와 live candidate pressure 기반 snapshot
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- 규칙/라우팅: `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/plan/SKILL.md`, `agent-ops/skills/common/code-review/SKILL.md`
- 로드맵/SDD: `agent-roadmap/current.md`, `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`, `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`
- 선행 task: `agent-task/m-provider-resource-admission-ownership-alignment/03+01,02_cross_group_wakeup/PLAN-local-G06.md`, `CODE_REVIEW-local-G06.md`
- 소스: `apps/edge/internal/service/status_provider.go`, `model_queue_snapshot.go`, `model_queue_types.go`, `model_queue_admission.go`, `provider_resolution.go`, `service.go`
- 테스트: `apps/edge/internal/service/status_provider_test.go`
- 테스트 규칙/프로필: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/testing-smoke.md`

### SDD 기준

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`, 상태 `[승인됨]`.
- Acceptance Scenario S07 / Task `snapshot-source`는 provider snapshot의 일반·long in-flight를 lease-backed resource state에서 읽고, queued/long_queued를 현재 provider candidate pressure로 계산하며 multi-candidate 값이 Edge 전체 pending 합계와 비가산임을 요구한다.
- Evidence Map의 provider snapshot row에 따라 cross-model actual lease lifecycle, refresh 전후 live candidate membership, multi-candidate 비가산 규칙을 구현 체크리스트와 final targeted/race 검증에 함께 고정한다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`, `edge-smoke.md`, `testing-smoke.md`를 읽었다.
- fresh Go test(`-count=1`, 별도 `/tmp` GOCACHE), 동시 snapshot/refresh race, Edge 전체 회귀를 적용한다. Go test cache 결과는 허용하지 않는다.
- runner는 `/config/workspace/iop`, branch `main`, HEAD `f8703246900a`, Linux aarch64, Go 1.26.2이며 기존 dirty worktree를 보존한다.
- 현재 targeted snapshot tests는 PASS하지만 active 03 변경이 있는 전체 service test는 `TestProviderLeaseNodeDisconnectRaceExactlyOnce`의 re-admit timeout으로 실패한다. 이 작업은 03 `complete.log`가 생긴 뒤 시작하고 그 시점의 clean predecessor baseline에서 다시 검증한다.

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

- `TestProviderSnapshotQueuedCountsCandidatePressureAcrossModelGroups`는 enqueue snapshot을 직접 주입해 비가산 규칙만 확인하고 live resolver refresh를 통과하지 않는다.
- `TestProviderSnapshotReadsResourceState`는 resource counter를 직접 주입하므로 실제 cross-model lease 획득·대기·반환과 snapshot 수렴을 증명하지 않는다.
- disabled 상태에서 enqueue 후보에 없던 provider가 re-enable되었지만 기존 lease 때문에 여전히 full인 동안 queued pressure에 새로 포함되는 회귀가 없다.
- snapshot과 runtime refresh/pump가 경쟁할 때 resolver error/empty에 stale candidate가 다시 집계되지 않고 data race가 없는지 검증이 없다.

### 심볼 참조

- 제거/rename 확정 심볼은 없다.
- `getStatsForProviderLocked` 호출부는 `model_queue_snapshot.go`, `model_queue_admission_test.go`, `provider_scheduling_test.go`다.
- `getLongStatsForProviderLocked`의 production 호출부는 `model_queue_snapshot.go`와 `longInFlightForProvider`이며, 후자는 long-context/queue reservation tests가 사용한다.
- 선행 03이 제공할 `resolveQueuedCandidatesLocked`는 `model_queue_admission.go`, `service_internal_test.go`에서 사용 중이며 이 작업이 snapshot 집계의 current-candidate source로 재사용한다.

### 분할 판단

- split decision policy를 먼저 평가했다. snapshot aggregation과 lifecycle regression은 같은 관측 invariant이고 `model_queue_snapshot.go`/`status_provider_test.go` 한 ownership 경계에서 함께 검토해야 하므로 추가 분리는 인위적이다.
- 공유 task group은 `m-provider-resource-admission-ownership-alignment`다.
- `07+03_snapshot_source`는 `03+01,02_cross_group_wakeup`의 live candidate resolver 계약에만 직접 의존한다. 현재 predecessor active 경로에는 `complete.log`가 없으므로 구현을 시작하지 않는다.
- 04 config 작업은 독립이고, 05 queue ownership 및 06 refresh consistency는 snapshot source 변경의 직접 선행이 아니므로 directory dependency에 추가하지 않는다.

### 범위 결정 근거

- 선행 03의 resolver/admission 구현은 이 작업에서 수정하지 않고 PASS 결과를 소비한다.
- root queue policy schema/admission/timer refresh는 04/05/06 범위다.
- protobuf snapshot schema, agent-contract, agent-spec, roadmap/SDD 본문은 바꾸지 않는다. 기존 필드의 source-of-truth 구현과 회귀만 보완한다.
- legacy adapter snapshot의 queued 계산은 provider-pool live candidate contract가 없으므로 기존 group/adapter semantics를 유지한다.

### 빌드 등급

- `local-G05`: 선행 resolver API를 재사용하는 두 파일의 bounded aggregation/test 변경이며 fresh targeted/race/Edge tests로 결정론적으로 검증 가능하다.

## 의존 관계 및 구현 순서

1. `agent-task/m-provider-resource-admission-ownership-alignment/03+01,02_cross_group_wakeup/complete.log`가 생겨 predecessor PASS가 확인될 때까지 구현하지 않는다.
2. 선행 resolver의 final signature와 stale-fallback 규칙을 확인한 뒤 provider pressure 집계를 한 번의 current-candidate scan으로 전환한다.
3. cross-model actual lease와 disabled→enabled-but-full refresh fixture로 current pressure를 검증한다.
4. targeted/race/Edge 전체 fresh 검증을 통과시킨다.

## 구현 체크리스트

- [ ] 한 번의 node provider snapshot 집계가 pending item마다 선행 03의 live candidate resolver를 정확히 한 번 사용하고 resolver error/empty에는 stale enqueue snapshot으로 fallback하지 않으며 provider identity를 request별 한 번만 집계하도록 구현한다.
- [ ] actual cross-model lease 획득·대기·refresh·dispatch·반환에서 일반·long in-flight와 queued/long_queued가 resource state/current candidate universe에 수렴하고 multi-candidate provider pressure가 Edge pending 총합과 비가산임을 deterministic regression으로 고정한다.
- [ ] fresh targeted/race/Edge 전체 검증으로 snapshot-refresh 경쟁의 data race, duplicate pressure, stale candidate, final lease/resource counter 누수가 없음을 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [PROVIDER_SNAPSHOT_SOURCE-1] Live candidate 기반 provider pressure aggregation

#### 문제

`apps/edge/internal/service/model_queue_snapshot.go:183-215`의 두 helper는 모든 group queue에서 `item.candidates`를 직접 순회한다. 선행 03의 `queueItem.resolveCandidates`가 enqueue 뒤 새로 eligible한 provider를 발견해도 snapshot은 stale slice를 계속 집계하고, provider마다 일반/long helper를 따로 호출해 resolver를 단순 삽입하면 같은 pending item을 반복 해석하게 된다.

#### 해결 방법

`providerQueuePressureLocked`를 추가해 모든 pending item을 한 번 순회하고 `resolveQueuedCandidatesLocked(item)`으로 current candidate universe를 얻는다. resolver error/empty는 pump와 동일하게 해당 item을 pressure 집계에서 제외하고 stale fallback을 금지한다. 한 item의 candidate slice에 같은 `(nodeID, providerID)`가 중복되어도 per-item seen set으로 `queued`와 `long_queued`를 한 번만 증가시킨다.

`getSnapshotForNode`는 catalog provider loop 전에 pressure map을 한 번 만들고 resource state의 일반·long in-flight와 결합한다. 기존 direct test helper 호출을 깨지 않도록 `getStatsForProviderLocked`/`getLongStatsForProviderLocked`는 같은 aggregate helper에 위임하고 production snapshot path는 precomputed map을 재사용한다.

Before (`model_queue_snapshot.go:183-190`):

```go
for _, group := range m.groups {
	for _, item := range group.queue {
		for _, c := range item.candidates {
			if c.entry.NodeID == nodeID && c.providerID == providerID {
				queued++
				break
			}
		}
	}
}
```

After:

```go
pressure := m.providerQueuePressureLocked()
stats := m.providerSnapshotStatsLocked(nodeID, providerID, pressure)
```

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

- [ ] `apps/edge/internal/service/model_queue_snapshot.go`: current-candidate pressure type/map helper와 per-item provider identity dedupe를 추가한다.
- [ ] `apps/edge/internal/service/model_queue_snapshot.go`: catalog snapshot이 aggregate를 한 번 계산해 in-flight/long-in-flight resource counts와 결합하게 한다.
- [ ] `apps/edge/internal/service/model_queue_snapshot.go`: 기존 stats/long helper 호출부와 tests의 의미를 보존한다.

#### 테스트 작성

- `apps/edge/internal/service/status_provider_test.go`의 기존 multi-group pressure test를 live resolver가 있는 item과 duplicate candidate 방어 assertion까지 확장한다.
- `TestProviderSnapshotResolverErrorDoesNotUseStaleCandidate`는 resolver error/empty에서 stale `item.candidates`에 provider가 있어도 queued/long_queued가 증가하지 않음을 검증한다.

#### 중간 검증

```bash
gofmt -d apps/edge/internal/service/model_queue_snapshot.go apps/edge/internal/service/status_provider_test.go
GOCACHE=/tmp/iop-go-cache-provider-snapshot-source go test -count=1 ./apps/edge/internal/service -run 'TestProviderSnapshot(QueuedCountsCandidatePressureAcrossModelGroups|ResolverErrorDoesNotUseStaleCandidate)$'
```

기대 결과: gofmt diff 없음, live resolver/dedupe/non-additive/error-no-fallback assertions PASS.

### [PROVIDER_SNAPSHOT_SOURCE-2] Cross-model lease lifecycle와 refresh pressure 회귀

#### 문제

`apps/edge/internal/service/status_provider_test.go:563-768`의 provider snapshot tests는 queue/resource 내부 상태를 직접 주입한다. Milestone 검증이 요구하는 cross-model 실행·대기·반환과 enqueue 시 후보에 없던 provider의 refresh 뒤 current pressure 전이를 증명하지 못한다.

#### 해결 방법

실제 `Service`, registry, NodeStore, model catalog와 resolver-aware admission을 사용한다. capacity 1인 shared/fallback provider에 서로 다른 model group의 lease를 각각 점유한 뒤 fallback을 disable하고 두 provider를 후보로 갖는 세 번째 model group request를 enqueue한다. 최초 snapshot은 shared만 queued pressure에 포함해야 한다. 기존 fallback lease가 유지된 상태로 fallback을 re-enable하면 waiter는 계속 pending이지만 live candidate pressure에는 fallback이 추가되어야 한다. fallback lease 반환 뒤 waiter dispatch, 모든 lease 반환 뒤 snapshot/counter zero까지 확인한다.

Before (`status_provider_test.go:615-639`):

```go
svc.queue.groups["model-a"] = &modelQueueGroup{
	key: "model-a",
	queue: []*queueItem{{candidates: []candidateNode{
		{entry: entry, providerID: "provider-shared"},
		{entry: entry, providerID: "provider-fallback"},
	}}},
}
```

After:

```go
sharedLease, fallbackLease := admitCrossModelHolders(t, svc)
waiter := enqueueProviderPoolRequest(t, svc, "model-wait")
svc.SetRuntimeConfig(reenabledFallbackStore(), catalog)
assertSnapshotPressure(t, svc, "provider-shared", 1)
assertSnapshotPressure(t, svc, "provider-fallback", 1)
```

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

- [ ] `apps/edge/internal/service/status_provider_test.go`: `TestProviderSnapshotLiveCandidatePressureFollowsRefreshAcrossModelGroups`를 actual lease/service fixture로 추가한다.
- [ ] `apps/edge/internal/service/status_provider_test.go`: normal/long queued pressure, dispatch 뒤 queue zero, 모든 반환 뒤 in-flight/long-in-flight zero를 assertion한다.
- [ ] `apps/edge/internal/service/status_provider_test.go`: bounded contexts/channels로 queue 진입과 dispatch를 동기화하고 arbitrary sleep에 의존하지 않는다.

#### 테스트 작성

- `TestProviderSnapshotLiveCandidatePressureFollowsRefreshAcrossModelGroups`: disabled-at-enqueue 후보 부재, re-enable-but-full current pressure 추가, release dispatch, final zero를 검증한다.
- `TestProviderSnapshotQueuedCountsCandidatePressureAcrossModelGroups`: 두 Edge pending request가 provider별 queued 합 3을 만들 수 있는 비가산 규칙을 유지한다.
- 같은 두 테스트를 `-race -count=5`로 반복해 snapshot/refresh/pump 경쟁을 검증한다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache-provider-snapshot-source go test -count=1 ./apps/edge/internal/service -run 'TestProviderSnapshot(LiveCandidatePressureFollowsRefreshAcrossModelGroups|QueuedCountsCandidatePressureAcrossModelGroups|ReadsResourceState|IgnoresLegacyFallbackState)$'
GOCACHE=/tmp/iop-go-cache-provider-snapshot-source go test -race -count=5 ./apps/edge/internal/service -run 'TestProviderSnapshot(LiveCandidatePressureFollowsRefreshAcrossModelGroups|QueuedCountsCandidatePressureAcrossModelGroups)$'
```

기대 결과: refresh 전후 current pressure, lease-backed in-flight, non-additive rule, final zero PASS 및 data race 없음.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/model_queue_snapshot.go` | PROVIDER_SNAPSHOT_SOURCE-1 |
| `apps/edge/internal/service/status_provider_test.go` | PROVIDER_SNAPSHOT_SOURCE-1, PROVIDER_SNAPSHOT_SOURCE-2 |

## 최종 검증

```bash
gofmt -d apps/edge/internal/service/model_queue_snapshot.go apps/edge/internal/service/status_provider_test.go
git diff --check
GOCACHE=/tmp/iop-go-cache-provider-snapshot-source go test -count=1 ./apps/edge/internal/service -run 'TestProviderSnapshot(ResolverErrorDoesNotUseStaleCandidate|LiveCandidatePressureFollowsRefreshAcrossModelGroups|QueuedCountsCandidatePressureAcrossModelGroups|ReadsResourceState|IgnoresLegacyFallbackState)$'
GOCACHE=/tmp/iop-go-cache-provider-snapshot-source go test -race -count=5 ./apps/edge/internal/service -run 'TestProviderSnapshot(LiveCandidatePressureFollowsRefreshAcrossModelGroups|QueuedCountsCandidatePressureAcrossModelGroups)$'
GOCACHE=/tmp/iop-go-cache-provider-snapshot-source go test -count=1 ./apps/edge/...
```

기대 결과: format/diff/targeted/race/Edge 전체 PASS. fresh Go test만 허용하며 predecessor 03의 current resolver와 global pump 회귀를 함께 보존한다.

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