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

# Plan - REFACTOR Edge Queue Service

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

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

## 배경

현재 Edge service는 `SubmitRun`에서 node를 resolve한 뒤 곧바로 `RunRequest`를 전송한다. 이 구조에서는 같은 OpenAI `model`에 대한 요청을 Edge-owned FIFO queue 하나로 세우거나, Node terminal/disconnect event를 기준으로 다음 Node에 dispatch할 수 없다. 앞선 작은 변경으로 `SubmitRunRequest.ModelGroupKey`는 생겼으므로, 이 plan은 그 키를 실제 Edge queue owner의 입력으로 연결한다.

## 사용자 리뷰 요청 흐름

구현 중 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-roadmap/current.md`
- `agent-roadmap/phase/inference-provider-extension/PHASE.md`
- `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/status_provider.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/service/run_dispatch_internal_test.go`
- `apps/edge/internal/events/bus.go`
- `apps/edge/internal/node/registry.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/server_test.go`

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md` 존재 및 정독 완료.
- 적용 profile: `agent-test/local/edge-smoke.md`, `agent-test/local/node-smoke.md`, `agent-test/local/platform-common-smoke.md`.
- 이 subtask의 필수 명령은 대상 Edge service package 기준 `go test -count=1 ./apps/edge/internal/service`.
- Edge service/transport 실행 경로에 닿으므로 후속 통합 subtask와 최종 regression subtask에서 `go test ./apps/edge/...`, `go test ./apps/node/...`, repo 내부 edge-node smoke/full-cycle 기준을 이어서 수행한다.

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

- 현재 `apps/edge/internal/service` 테스트는 direct dispatch metadata와 node command만 검증하고, model-group FIFO admission은 없다.
- 현재 run terminal release, node disconnect release, multi-node candidate dispatch 테스트가 없다.
- 이 plan에서 service-level fake node client와 event bus를 이용해 FIFO, overflow, timeout, terminal release, disconnect release, multi-node dispatch 테스트를 새로 작성해야 한다.

### 심볼 참조

- 새 public-ish service DTO 필드 `SubmitRunRequest.ModelGroupKey`, `RunDispatch.ModelGroupKey`는 이미 small task에서 추가됐다.
- 제거/rename 대상 없음.
- `SubmitRunRequest{}` call site는 `rg --sort path "SubmitRunRequest\\{" apps/edge/internal` 기준 OpenAI, A2A, opsconsole, service/openai/a2a 테스트에 있다. 이 plan은 service 구현과 service tests 중심으로 처리하고, surface별 의미 검증은 `02+01_surface_snapshot_contract`가 맡는다.

### 분할 판단

- split decision policy 평가 완료. 단일 plan은 부적합하다.
- 공유 task group: `m-edge-model-group-queue-scheduling`.
- `01_edge_queue_service`: Edge queue core와 service dispatch integration. 선행 없음.
- `02+01_surface_snapshot_contract`: surface/snapshot 계약. `01` 완료 필요.
- `03+01_node_queue_simplify`: Node-local queue 축소. `01` 완료 필요.
- `04+01,02,03_regression_evidence`: 통합 회귀/evidence. `01`, `02`, `03` 완료 필요.
- 이 디렉터리는 독립 시작점이므로 predecessor 확인 대상 없음.

### 범위 결정 근거

- OpenAI/A2A/console의 세부 요청 검증은 `02+01_surface_snapshot_contract`로 제외한다.
- Node-local FIFO 제거와 node tests 전환은 `03+01_node_queue_simplify`로 제외한다.
- config/proto 변경은 현재 필수로 보지 않는다. 기존 NodeStore/config provider capacity/max_queue/queue_timeout 값을 Edge scheduler 입력으로 우선 사용한다.

### 빌드 등급

- `cloud-G07`: Edge-owned concurrency/queue scheduling, event-driven release, multi-node dispatch가 결합된 변경이며 race/ordering failure mode가 hard-to-review라 cloud lane이 맞다.

## 구현 체크리스트

- [ ] Edge service에 model-group queue manager를 추가하고 `SubmitRun` direct send를 queue admission/dispatch 경로로 바꾼다.
- [ ] model group별 FIFO, capacity, max_queue, queue_timeout, in-flight 상태를 service-owned state로 관리한다.
- [ ] run terminal event와 node disconnect event를 구독해 in-flight slot을 release하고 다음 queue head를 dispatch한다.
- [ ] multi-node candidate 중 runnable slot이 있는 Node로 queue head를 순차 dispatch한다.
- [ ] `go test -count=1 ./apps/edge/internal/service`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REFACTOR-1] Edge Queue Manager 추가

#### 문제

`SubmitRun`은 node resolve 이후 즉시 proto를 전송한다.

```go
// apps/edge/internal/service/run_dispatch.go:97
97  func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (RunResult, error) {
98      entry, err := s.ResolveNode(req.NodeRef)
103     runReq, runID, err := BuildRunRequest(req)
120     if err := entry.Client.Send(runReq); err != nil {
```

이 구조에는 model group별 queue state가 없다.

#### 해결 방법

`apps/edge/internal/service/model_queue.go`를 추가한다. 내부 타입은 package-private로 두고 `ModelGroupKey`, candidate node, capacity policy, `RunRequest`를 받아 FIFO admission을 수행한다. `ModelGroupKey == ""` 요청은 기존 direct path를 유지하거나 explicit fallback group 없이 dispatch한다.

```go
type modelQueueManager struct {
    mu sync.Mutex
    groups map[string]*modelQueueGroup
}
```

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

- [ ] `apps/edge/internal/service/model_queue.go`: queue manager, group state, admission result, release API 추가.
- [ ] `apps/edge/internal/service/service.go`: `Service`에 queue manager field 초기화.
- [ ] `apps/edge/internal/service/run_dispatch.go`: `SubmitRun`이 `ModelGroupKey`가 있을 때 queue manager를 통과하도록 수정.

#### 테스트 작성

- 작성: `apps/edge/internal/service/model_queue_test.go`.
- 테스트명: `TestModelQueueFIFOOrdering`, `TestModelQueueOverflow`, `TestModelQueueTimeout`.
- 목표: 같은 key 안에서 FIFO, max_queue overflow, queue_timeout rejection 검증.

#### 중간 검증

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

### [REFACTOR-2] Terminal/Disconnect Release

#### 문제

service는 foreground 응답용으로만 run/node event를 subscribe한다. queue owner가 slot release를 관찰하는 구독이 없다.

```go
// apps/edge/internal/service/run_dispatch.go:112
112 if !runReq.GetBackground() {
116     runEvents, unregisterRun = s.events.SubscribeRun(runID, 4096)
117     nodeEvents, unregisterNode = s.events.SubscribeNode(entry.NodeID, 16)
```

#### 해결 방법

Queue manager가 dispatch 성공 시 `(runID,nodeID,groupKey)` in-flight record를 만들고, `Service.SubmitRun`이 background/foreground와 무관하게 terminal run event 또는 node disconnect event를 release trigger로 연결한다. `events.Bus.SubscribeAllRuns`와 `SubscribeAllNodes`를 쓰되, goroutine lifecycle은 `Service` 내부 close hook 없이 bounded subscription으로 유지할 수 있게 테스트 가능한 helper로 둔다.

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

- [ ] `apps/edge/internal/service/model_queue.go`: `releaseRun(runID, reason)`와 `releaseNode(nodeID, reason)` 구현.
- [ ] `apps/edge/internal/service/run_dispatch.go`: dispatch 후 release watcher 등록 또는 queue manager watcher 호출 연결.
- [ ] `apps/edge/internal/service/model_queue_test.go`: complete/error/cancelled와 disconnect release 테스트.

#### 테스트 작성

- 작성: `TestModelQueueTerminalReleaseDispatchesNext`, `TestModelQueueNodeDisconnectReleasesInflight`.
- terminal event 타입: `complete`, `error`, `cancelled`.

#### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/service -run 'TestModelQueue(Terminal|NodeDisconnect)'
```

### [REFACTOR-3] Multi-Node Dispatch Candidate

#### 문제

`ResolveNode("")`는 node가 여러 개면 에러를 반환한다. Edge queue는 같은 model group의 여러 Node 후보 중 runnable slot으로 dispatch해야 한다.

```go
// apps/edge/internal/node/registry.go:119
119 if len(r.byID) == 1 {
124 if len(r.byID) == 0 {
127 return nil, fmt.Errorf("multiple nodes connected; select one with /node <id|alias>")
```

#### 해결 방법

`SubmitRunRequest.NodeRef`가 명시되면 후보를 단일 Node로 제한한다. 비어 있고 `ModelGroupKey`가 있으면 `registry.All()`과 `nodeStore`의 config payload를 이용해 `Adapter`/`Target`이 가능한 Node 후보를 만든다. 후보가 없으면 기존 resolve error를 반환한다. candidate별 in-flight를 보고 queue head를 runnable candidate에 dispatch한다.

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

- [ ] `apps/edge/internal/service/model_queue.go`: candidate set과 runnable slot 계산 추가.
- [ ] `apps/edge/internal/service/status_provider.go`: 필요 시 provider capacity 읽기 helper를 service package 안에서 공유.
- [ ] `apps/edge/internal/service/service_test.go`: multi-node candidate dispatch 테스트 추가.

#### 테스트 작성

- 작성: `TestSubmitRunModelQueueDispatchesAcrossNodes`.
- 목표: 같은 group key의 두 요청이 capacity 1인 두 Node에 각각 dispatch되는지 검증.

#### 중간 검증

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

## 수정 파일 요약

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

## 최종 검증

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

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

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