<!-- task=m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply code-review=1 tag=LOCAL_G05 -->

# Code Review - local-G05 (REVIEW_REFRESH_APPLY follow-up)

## Review Verdict

- **Verdict**: PASS (follow-up 구현 완료)
- **Previous Verdict**: FAIL (cloud_G08_0)
- **Review Type**: REVIEW_REFRESH_APPLY - code-review FAIL follow-up

## Review Notes

이 follow-up은 이전 review에서 FAIL 받은 두 가지 문제를 해결합니다:

1. **S10 - `/v1/models` timestamp flaky**: raw JSON 비교를 stable model ID 비교로 변경
2. **S10 - Provider dispatch 보존 미검증**: net.Pipe 기반 fake node를 사용한 dispatch 보존 assertion 추가

## Previous FAIL Summary

- `apps/edge/internal/bootstrap/runtime_test.go:667`: `/v1/models` raw JSON comparison is time-dependent because response `created` uses `time.Now().Unix()`. Local rerun failed:
  `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service`
- `apps/edge/internal/bootstrap/runtime_test.go:615`: SDD S10 requires rejected refresh to preserve provider dispatch state, but `TestRefreshRejectedProviderPreservesModelsAndDispatch` does not dispatch before/after rejection.

## 구현 내용

### REVIEW_REFRESH_APPLY-1: Stable `/v1/models` Assertion

- `modelsBody` helper 함수를 `modelIDs` helper로 대체
- `/v1/models` 응답을 JSON decode하여 model ID 목록을 sorted/stable하게 반환
- `TestRefreshRejectedPreservesModels` (기존 `TestRefreshRejectedProviderPreservesModelsAndDispatch` 이름 변경):
  - `beforeIDs := modelIDs(...)`로 before 상태 captured
  - rejected refresh 실행
  - `afterIDs := modelIDs(...)`로 after 상태 captured
  - `reflect.DeepEqual(afterIDs, beforeIDs)`로 timestamp-independent 비교
- `TestRefreshApplyDoesNotMutateOnRestartRequired`도 동일하게 stable ID 비교로 변경

### REVIEW_REFRESH_APPLY-2: Dispatch Preservation Assertion

- `TestRefreshRejectedPreservesProviderDispatch` 새 테스트 추가
- net.Pipe 기반 fake node client를 사용해 실제 dispatch capture
- NodeStore에 provider-pool node record 추가 (provider ID `prov-a`, adapter `vllm-gpu`, served target `served-qwen`)
- Registry에 fake node 등록
- rejected refresh 전후 `SubmitRun(ProviderPool=true)` 실행
- fake node가 받은 `RunRequest`의 `Adapter`와 `Target`이 refresh 전후 모두 동일함을 검증

### REVIEW_REFRESH_APPLY-3: Verification Recovery

- 모든 검증 명령 실행 및 PASS 확인

## 검증 결과

### 1. targeted, count=1

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

**출력**:
```
ok  	iop/apps/edge/internal/bootstrap	0.265s
ok  	iop/apps/edge/internal/service	0.444s
```

### 2. race detector

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

**출력**:
```
ok  	iop/apps/edge/internal/bootstrap	1.509s
ok  	iop/apps/edge/internal/service	1.494s
```

### 2b. race detector (재검증)

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

**출력**:
```
ok  	iop/apps/edge/internal/bootstrap	1.441s
ok  	iop/apps/edge/internal/service	1.473s
```

### 3. targeted Edge packages

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

**출력**:
```
ok  	iop/apps/edge/internal/bootstrap	0.270s
ok  	iop/apps/edge/internal/service	0.445s
ok  	iop/apps/edge/internal/openai	1.514s
ok  	iop/apps/edge/internal/edgecmd	0.024s
```

### 4. 전체 Edge/config/proto package

```
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```

**출력**:
```
ok  	iop/apps/edge/cmd/edge	0.102s
ok  	iop/apps/edge/internal/bootstrap	0.341s
ok  	iop/apps/edge/internal/configrefresh	0.054s
ok  	iop/apps/edge/internal/controlplane	4.463s
ok  	iop/apps/edge/internal/edgecmd	0.019s
?   	iop/apps/edge/internal/edgevalidate	[no test files]
ok  	iop/apps/edge/internal/events	0.008s
ok  	iop/apps/edge/internal/input	0.025s
ok  	iop/apps/edge/internal/input/a2a	0.018s
ok  	iop/apps/edge/internal/node	0.015s
ok  	iop/apps/edge/internal/openai	1.516s
ok  	iop/apps/edge/internal/opsconsole	0.011s
ok  	iop/apps/edge/internal/service	0.440s
ok  	iop/apps/edge/internal/transport	2.041s
ok  	iop/packages/go/audit	0.008s
?   	iop/packages/go/auth	[no test files]
ok  	iop/packages/go/config	0.038s
?   	iop/packages/go/events	[no test files]
ok  	iop/packages/go/hostsetup	0.008s
?   	iop/packages/go/jobs	[no test files]
?   	iop/packages/go/metadata	[no test files]
ok  	iop/packages/go/observability	0.013s
?   	iop/packages/go/policy	[no test files]
?   	iop/packages/go/version	[no test files]
?   	iop/proto/gen/iop	[no test files]
```

## 계획 대비 변경 사항

| 항목 | 계획 | 실제 구현 | 비고 |
|------|------|-----------|------|
| `modelsBody` 제거 | modelIDs로 교체 | modelIDs로 교체 | 계획과 동일 |
| 테스트 이름 | `TestRefreshRejectedProviderPreservesModelsAndDispatch` 유지 | `TestRefreshRejectedPreservesModels`로 변경 | 테스트 범위가 model preservation으로 좁혀져 이름 단순화 |
| dispatch 보존 | 기존 테스트에 추가 주장 | 새 테스트 `TestRefreshRejectedPreservesProviderDispatch`로 분리 | 기존 테스트와 책임 분리. 새 테스트는 dispatch capture에 특화됨 |
| import | `git.toki-labs.com/toki/proto-socket/go` | 별칭 `toki`로 import | model_queue_test.go 패턴과 동일 |

## 수정 파일

- `apps/edge/internal/bootstrap/runtime_test.go`:
  - `modelsBody` → `modelIDs` helper 변경 (stable ID comparison)
  - `TestRefreshRejectedPreservesModels` 이름 변경, raw JSON 비교 → deep equal ID 비교
  - `TestRefreshApplyDoesNotMutateOnRestartRequired`도 stable ID 비교로 변경
  - `TestRefreshRejectedPreservesProviderDispatch` 새 테스트 추가
  - import 추가: `toki`, `proto`, `iop`, `edgenode`, `edgeservice`

## SDD Evidence Map

- **S10 `atomic-apply`**:
  - `TestRefreshRejectedPreservesModels`: rejected refresh 후 `/v1/models` ID 보존 (timestamp-independent)
  - `TestRefreshRejectedPreservesProviderDispatch`: rejected refresh 후 provider-pool dispatch 보존 (Adapter/Target)
  - `TestRefreshApplyDoesNotMutateOnRestartRequired`: restart_required 후 `/v1/models` ID 보존
- **S11 `ops-report`**:
  - 기존 `TestRefreshAPIReportIncludesChangedItems` 계속 PASS (변경 없음)

## 코드 변경 상세

### 1. modelIDs helper 추가

```go
type modelsResponse struct {
    Object string      `json:"object"`
    Data   []modelItem `json:"data"`
}

type modelItem struct {
    ID      string `json:"id"`
    Object  string `json:"object"`
    Created int64  `json:"created"`
    OwnedBy string `json:"owned_by"`
}

func modelIDs(t *testing.T, client *http.Client, openAIAddr string) []string {
    resp, err := client.Get("http://" + openAIAddr + "/v1/models")
    // ... decode JSON ...
    ids := make([]string, 0, len(body.Data))
    for _, m := range body.Data {
        ids = append(ids, m.ID)
    }
    sort.Strings(ids)
    return ids
}
```

### 2. TestRefreshRejectedPreservesModels

```go
beforeIDs := modelIDs(t, client, openAIAddr)
// ... rejected refresh ...
afterIDs := modelIDs(t, client, openAIAddr)
if !reflect.DeepEqual(afterIDs, beforeIDs) {
    t.Fatalf("/v1/models changed after rejected apply:\nbefore=%v\nafter=%v", beforeIDs, afterIDs)
}
```

### 3. TestRefreshRejectedPreservesProviderDispatch

```go
// NodeStore에 provider-pool node record 추가
rt.NodeStore.Add(&edgenode.NodeRecord{
    ID:   "node-pool-dispatch",
    Providers: []config.NodeProviderConf{
        {
            ID:       "prov-a",
            Adapter:  "vllm-gpu",
            Models:   []string{"served-qwen"},
            Capacity: 2,
        },
    },
})

// Registry에 fake node 등록
rt.Registry.Register(&edgenode.NodeEntry{
    NodeID:         "node-pool-dispatch",
    LifecycleState: edgenode.LifecycleConnected,
    Client:         edgeClient,
})

// dispatchAndCapture: rejected refresh 전후 SubmitRun(ProviderPool=true) 실행
beforeReq, err := dispatchAndCapture()
// ... beforeReq.GetAdapter() == "vllm-gpu", beforeReq.GetTarget() == "served-qwen" ...
// ... rejected refresh ...
afterReq, err := dispatchAndCapture()
// ... afterReq.GetAdapter() == "vllm-gpu", afterReq.GetTarget() == "served-qwen" ...
```

## 결론

이 follow-up은 다음 두 가지를 해결했습니다:
1. `/v1/models` raw JSON 비교의 timestamp flakiness 제거
2. provider dispatch 보존에 대한 실제 검증 evidence 추가

모든 검증 명령이 PASS했으며, 이는 SDD S10 `atomic-apply`의 증거를 완성합니다.

## 코드리뷰 결과

- `종합 판정`: PASS
- `차원별 평가`:
  - correctness: Pass - rejected apply/restart_required 경로가 기존 runtime snapshot, `/v1/models`, provider-pool dispatch state를 보존하는지 검증이 추가되었다.
  - completeness: Pass - 이전 FAIL 항목인 timestamp-flaky assertion과 dispatch preservation evidence 공백이 모두 해소되었다.
  - test coverage: Pass - targeted, race, Edge 주변 패키지, 전체 Edge/config/proto 범위를 재실행해 통과를 확인했다.
  - API contract: Pass - `Result` ops-report JSON 필드는 기존 refresh 응답에 additive field로 추가되고 rejected path도 non-nil slice를 유지한다.
  - code quality: Pass - copy-on-write apply 흐름을 유지하며 테스트 helper는 timestamp 대신 stable model ID만 비교한다.
  - implementation deviation: Pass - dispatch 보존 검증은 계획의 "기존 테스트 내부 추가 또는 별도 테스트 분리" 범위 안에서 별도 테스트로 분리되었다.
  - verification trust: Pass - active review 기록과 별도로 동일 명령을 재실행해 모두 PASS를 확인했다.
  - spec conformance: Pass - Roadmap Targets `atomic-apply`, `ops-report`에 필요한 S10/S11 evidence가 충족된다.
- `발견된 문제`: 없음
- `리뷰어 검증`:
  - `go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` - PASS
  - `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` - PASS
  - `go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/edgecmd` - PASS
  - `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` - PASS
  - `gofmt -l apps/edge/internal/bootstrap/refresh_admin.go apps/edge/internal/bootstrap/runtime.go apps/edge/internal/bootstrap/runtime_test.go apps/edge/internal/configrefresh/classify.go apps/edge/internal/configrefresh/result.go apps/edge/internal/edgecmd/edgecmd_test.go apps/edge/internal/openai/server_test.go apps/edge/internal/service/model_queue.go apps/edge/internal/service/model_queue_test.go apps/edge/internal/service/service_internal_test.go` - PASS
  - `git diff --check` - PASS
- `다음 단계`: PASS - active plan/review를 로그로 아카이브하고 `complete.log` 작성 후 task 디렉터리를 `agent-task/archive/2026/06/` 아래로 이동한다.

## 코드리뷰 전용 체크리스트

> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.

- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다. (PASS라 해당 없음)
- [x] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. (PASS라 해당 없음)
- [x] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. (PASS라 해당 없음)
