<!-- task=m-edge-model-group-queue-scheduling/01_edge_queue_service plan=1 tag=REVIEW_REFACTOR -->

# Plan - REVIEW_REFACTOR Edge Queue Service Follow-up

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

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증 명령을 실행하고 실제 출력, 구현 메모, 계획 대비 변경 사항을 채운 뒤 active 파일을 남긴 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 범위 변경, 외부 환경/secret, 또는 scope conflict가 생기면 chat에서 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 남긴다. 후속 에이전트가 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다.

## 배경

첫 번째 구현은 Edge-owned model group queue의 기본 골격과 서비스 테스트를 추가했지만, scheduler 입력과 live candidate 계약을 충분히 지키지 못했다. 특히 provider별 `capacity`, `max_queue`, `queue_timeout_ms`가 무시되고, `ModelGroupKey` 요청이 모든 connected node를 후보로 삼으며, node disconnect 후 stale candidate로 dispatch될 수 있다. queued `SubmitRun`이 request context cancellation을 무시하는 문제도 함께 고친다.

## 사용자 리뷰 요청 흐름

구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하지 않는다. code-review가 해당 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
  - `edge-admission`: Edge service가 model group별 FIFO admission queue, capacity, max_queue, queue_timeout, in-flight 상태를 소유한다.
  - `node-dispatch`: Edge가 Node별 실행 가능 slot과 terminal run event를 기준으로 queue head를 다음 Node에 순차 dispatch한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/plan_cloud_G07_0.log`
- `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/code_review_cloud_G07_0.log`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/events/bus.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/transport/server.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/run_result.go`
- `apps/edge/internal/openai/stream.go`
- `packages/go/config/config.go`
- `packages/go/events/events.go`

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md` 존재 및 정독 완료.
- 적용 profile: `agent-test/local/edge-smoke.md`.
- 이 follow-up의 필수 명령은 deterministic local quick check로 `go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`를 사용한다.
- edge-node 실제 왕복과 OpenAI-compatible smoke는 sibling `04+01,02,03_regression_evidence`에서 전체 변경 세트 기준으로 수행한다. 이 plan은 service queue 계약 회귀를 먼저 고친다.

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

- Provider별 `capacity`, `max_queue`, `queue_timeout_ms`가 Edge queue policy로 반영되는 테스트가 없다.
- `NodeRef == ""`인 queued request가 `Adapter`/`Target` capable node만 후보로 삼는 테스트가 없다.
- node disconnect 후 queued item이 disconnected node가 아니라 남은 live node로 dispatch되는지 검증하지 않는다.
- queued `SubmitRun` 대기 중 context cancellation이 queue item 제거와 no-dispatch로 끝나는 테스트가 없다.

### 심볼 참조

- rename/remove 없음.
- `SubmitRunRequest{}` call site는 이번 follow-up에서 구조 변경 없이 기존 필드 의미를 보강한다.

### 분할 판단

- 기존 shared task group은 `m-edge-model-group-queue-scheduling`.
- 현재 subtask는 `01_edge_queue_service`의 first review follow-up이며, active sibling `02+01_surface_snapshot_contract`, `03+01_node_queue_simplify`, `04+01,02,03_regression_evidence`와 같은 parent를 유지한다.
- 네 Required 이슈는 모두 `apps/edge/internal/service` queue admission/dispatch 계약에 묶여 있어 같은 follow-up 안에서 처리한다.
- predecessor 추가 확인은 필요 없다. 이 subtask 자체가 선행 없는 `01_` 계열이다.

### 범위 결정 근거

- OpenAI/A2A surface snapshot 계약 보강은 sibling `02+01_surface_snapshot_contract` 범위로 남긴다.
- Node-local queue 축소는 sibling `03+01_node_queue_simplify` 범위로 남긴다.
- full-cycle edge-node smoke evidence는 sibling `04+01,02,03_regression_evidence` 범위로 남긴다.
- 이 follow-up은 Edge service queue manager, candidate resolution, related service tests에 한정한다.

### 빌드 등급

- `cloud-G07`: concurrency/event-driven queue scheduling의 Required correctness follow-up이며, config policy, candidate liveness, context cancellation을 함께 판단해야 한다.

## 구현 체크리스트

- [ ] Provider별 adapter config에서 capacity, max_queue, queue_timeout_ms를 Edge queue policy 입력으로 연결한다.
- [ ] `ModelGroupKey` queued request의 후보를 `Adapter`/`Target` capable node로 필터링하고 명시 `NodeRef`도 검증한다.
- [ ] node disconnect release가 stale disconnected candidate로 queued item을 깨우지 않도록 live candidate 재검증을 추가한다.
- [ ] queued admission이 `SubmitRun` context cancellation을 존중하고 cancellation 시 queue item을 제거한다.
- [ ] `go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_REFACTOR-1] Provider Policy 연결

#### 문제

`modelQueueGroup`은 생성 시 hard-coded default만 사용한다.

```go
// apps/edge/internal/service/model_queue.go:117
117 policy: groupPolicy{
118     maxQueue:     defaultGroupMaxQueue,
119     queueTimeout: defaultQueueTimeout,
120 },
```

capacity도 node runtime concurrency만 사용한다.

```go
// apps/edge/internal/service/model_queue.go:128
128 func (m *modelQueueManager) nodeCapacityFor(nodeID string) int {
129     if m.store != nil {
130         if rec, ok := m.store.FindByID(nodeID); ok && rec.Runtime.Concurrency > 0 {
```

`packages/go/config/config.go:205` 이후에는 provider별 `Capacity`, `MaxQueue`, `QueueTimeoutMS`가 이미 존재한다.

#### 해결 방법

Queue admission 입력에 candidate별 scheduler policy를 싣는다. `SubmitRunRequest.Adapter`/`Target`에 맞는 NodeRecord adapter instance를 찾아 capacity를 후보별로 계산하고, group policy는 matching provider config의 `MaxQueue`/`QueueTimeoutMS`를 사용한다. 값이 0이면 기존 default fallback을 유지한다.

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

- [ ] `apps/edge/internal/service/model_queue.go`: candidate entry에 capacity를 포함하고 group policy override를 admission에 전달한다.
- [ ] `apps/edge/internal/service/run_dispatch.go`: NodeStore adapter config에서 policy/capacity를 추출해 queue manager에 전달한다.
- [ ] `apps/edge/internal/service/model_queue_test.go`: policy fallback과 configured capacity/max_queue/timeout 테스트를 추가한다.

#### 테스트 작성

- 작성: `TestModelQueueUsesProviderCapacity`.
- 작성: `TestModelQueueUsesProviderQueuePolicy`.
- 목표: capacity=2면 같은 node에 두 요청이 즉시 admission되고, max_queue/queue_timeout_ms가 hard-coded default가 아니라 config 값을 따른다.

#### 중간 검증

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

### [REVIEW_REFACTOR-2] Runnable Candidate 필터

#### 문제

`resolveQueueCandidates`는 node ref가 없으면 모든 connected node를 반환한다.

```go
// apps/edge/internal/service/run_dispatch.go:185
185 // resolveQueueCandidates returns the candidate nodes for a queued run.
186 // If NodeRef is set, returns only that node. Otherwise returns all connected nodes.
187 func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]*edgenode.NodeEntry, error) {
195     all := s.registry.All()
199     return all, nil
```

이러면 해당 adapter/target을 제공하지 않는 node에도 queued OpenAI/A2A request가 dispatch될 수 있다.

#### 해결 방법

NodeStore record의 normalized adapter config를 기준으로 `req.Adapter`와 `req.Target`을 지원하는 node만 후보로 남긴다. 명시 `NodeRef`가 있을 때도 해당 node가 runnable하지 않으면 Send까지 가지 말고 service error를 반환한다.

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

- [ ] `apps/edge/internal/service/run_dispatch.go`: `resolveQueueCandidates`를 capability-aware helper로 바꾼다.
- [ ] `apps/edge/internal/service/model_queue.go`: queue manager가 filtered candidate만 받도록 타입을 맞춘다.
- [ ] `apps/edge/internal/service/service_test.go`: runnable candidate 필터 테스트를 추가한다.

#### 테스트 작성

- 작성: `TestSubmitRunModelQueueFiltersCandidatesByAdapterTarget`.
- 작성: `TestSubmitRunModelQueueRejectsExplicitNodeRefWithoutTarget`.
- 목표: 두 node 중 target을 제공하는 node로만 dispatch되고, 명시 node가 target을 제공하지 않으면 명확한 error를 반환한다.

#### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueue(FiltersCandidates|RejectsExplicit)'
```

### [REVIEW_REFACTOR-3] Disconnect 후보 재검증

#### 문제

`releaseNode`는 끊어진 node의 in-flight count만 0으로 만들고 queued item의 candidates는 그대로 둔다.

```go
// apps/edge/internal/service/model_queue.go:221
221 func (m *modelQueueManager) releaseNode(nodeID, reason string) {
231     for _, group := range m.groups {
232         if count := group.inflight[nodeID]; count > 0 {
233             group.inflight[nodeID] = 0
234             m.tryDispatchLocked(group)
```

`tryDispatchLocked`는 stale candidates에서 첫 available node를 고르므로 disconnect된 node로 queued item을 깨울 수 있다.

#### 해결 방법

disconnect event를 받은 node는 queued candidate 목록에서 제거하거나, dispatch 직전에 service가 제공한 live-candidate predicate로 재검증한다. 후보가 모두 사라진 queued item은 명확한 error를 전달하거나 새 live candidate가 생길 때까지 기다리는 정책 중 하나를 코드에 명시한다. 현재 목표는 "남은 live candidate가 있으면 그 node로 dispatch"다.

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

- [ ] `apps/edge/internal/service/model_queue.go`: disconnected node를 stale candidate로 선택하지 않게 한다.
- [ ] `apps/edge/internal/service/model_queue_test.go`: disconnect 후 dispatch node id를 검증하도록 기존 테스트를 강화한다.
- [ ] `apps/edge/internal/service/service_test.go`: 실제 `SubmitRun` 흐름에서 disconnected candidate send failure 없이 남은 node로 dispatch되는 테스트를 추가한다.

#### 테스트 작성

- 수정: `TestModelQueueNodeDisconnectReleasesInflight`가 `node-nd2`로 dispatch되는지 assert한다.
- 작성: `TestSubmitRunModelQueueDispatchesQueuedRunAfterDisconnectToLiveNode`.

#### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/service -run 'Test(ModelQueueNodeDisconnect|SubmitRunModelQueueDispatchesQueuedRunAfterDisconnect)'
```

### [REVIEW_REFACTOR-4] Queue Context Cancellation

#### 문제

`SubmitRun`은 context를 버리고 queue admission도 cancellation을 받지 않는다.

```go
// apps/edge/internal/service/run_dispatch.go:98
98 func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (RunResult, error) {
```

```go
// apps/edge/internal/service/model_queue.go:150
150 func (m *modelQueueManager) admit(groupKey string, candidates []*edgenode.NodeEntry) (*edgenode.NodeEntry, error) {
```

queued HTTP/OpenAI request가 client disconnect 후에도 대기하거나 dispatch될 수 있다.

#### 해결 방법

`SubmitRun(ctx, req)`의 ctx를 queued admission까지 전달한다. `admit` select에 `ctx.Done()`을 추가하고 cancellation 시 queue item 제거, reserved slot cleanup, no-dispatch를 보장한다.

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

- [ ] `apps/edge/internal/service/run_dispatch.go`: context를 queued path로 전달한다.
- [ ] `apps/edge/internal/service/model_queue.go`: `admit`이 ctx cancellation을 처리한다.
- [ ] `apps/edge/internal/service/model_queue_test.go` 또는 `service_test.go`: cancellation 회귀 테스트를 추가한다.

#### 테스트 작성

- 작성: `TestSubmitRunModelQueueContextCancelRemovesQueuedItem`.
- 목표: capacity가 찬 상태에서 두 번째 request가 queue에 들어간 뒤 context cancel 시 error를 반환하고, 이후 slot release가 canceled request를 dispatch하지 않는다.

#### 중간 검증

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

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/model_queue.go` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-3, REVIEW_REFACTOR-4 |
| `apps/edge/internal/service/run_dispatch.go` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-2, REVIEW_REFACTOR-4 |
| `apps/edge/internal/service/model_queue_test.go` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-3, REVIEW_REFACTOR-4 |
| `apps/edge/internal/service/service_test.go` | REVIEW_REFACTOR-2, REVIEW_REFACTOR-3, REVIEW_REFACTOR-4 |

## 최종 검증

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

기대 결과: 모두 통과. Go test cache는 이 plan에서 허용하지 않는다.

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