feat: edge model group queue scheduling improvements

- Add status_provider_test.go for queue status coverage
- Update model_queue.go with queue simplification
- Update run_dispatch.go with dispatch logic improvements
- Update status_provider.go with status tracking
- Update server tests for a2a, openai, and opsconsole
- Remove archived PLAN and CODE_REVIEW documents for 02+01 surface snapshot contract
- Archive 02+01_surface_snapshot_contract to 2026/06
This commit is contained in:
toki 2026-06-16 10:58:30 +09:00
parent b98f048706
commit bcc60ff48c
16 changed files with 1062 additions and 150 deletions

View file

@ -0,0 +1,139 @@
<!-- task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
## 개요
date=2026-06-16
task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `snapshot-contract`: Edge-visible capacity/in-flight/queued snapshot이 Edge-owned model group 상태를 기준으로 표현되고 Node capability snapshot과 충돌하지 않는다.
- `surface-coverage`: OpenAI Chat Completions/Responses 실행 경로가 `model` 값 기반 Edge-owned queue를 우회하지 않고, A2A/console 경로는 별도 model group key 없이 같은 Edge dispatch primitive와 충돌하지 않는다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] OpenAI Queue Coverage | [x] |
| [REFACTOR-2] A2A/Console Zero-Key Contract | [x] |
| [REFACTOR-3] Edge-Owned Snapshot | [x] |
## 구현 체크리스트
- [x] OpenAI Chat/Responses 테스트가 request `model` 기반 `ModelGroupKey`와 queue primitive 사용을 검증하도록 보강한다.
- [x] A2A/console 테스트가 `ModelGroupKey == ""`와 같은 `SubmitRun` primitive 사용을 명시적으로 검증하도록 보강한다.
- [x] `ListNodeSnapshots`/Control Plane status snapshot이 Edge-owned queue state의 capacity/in-flight/queued를 표현하도록 service boundary를 조정한다.
- [x] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`가 통과한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block을 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS split 작업이면 parent directory 유지/정리를 확인한다.
- [x] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 경로를 처리한다.
## 계획 대비 변경 사항
- `service_test.go`는 `package service_test`로 선언되어 있어 unexported 필드인 `svc.queue` 및 내부 모델 구조체들에 직접 접근하는 데 한계가 있었습니다. 이를 해결하고 결합도를 낮추기 위해 `apps/edge/internal/service/status_provider_test.go` 파일을 새로 작성하고 `package service` 하위에서 `TestListNodeSnapshotsUsesEdgeQueueState` 테스트를 성공적으로 구현하였습니다.
- `modelQueueGroup`이 노드의 어떤 어댑터/인스턴스에 매핑되는지 식별하기 위해 `modelQueueGroup` 구조체에 `adapter`, `target` 필드를 추가하고, `admit` 호출 시 이 값들을 group에 바인딩하도록 하였습니다. 이를 통해 `ListNodeSnapshots()` 호출 시 노드의 config 상에 enabled 된 인스턴스와 live queue group의 통계를 정확히 매칭시켰습니다.
- `config.CLIConf` 에는 `Capacity` 필드가 정의되어 있지 않아 빌드 오류가 발생하므로, CLI의 capacity 에 대해서는 `concurrencyFallback`을 기본값으로 사용하도록 수정했습니다.
## 주요 설계 결정
- Edge가 보유한 `modelQueueManager`가 관리하는 그룹 리스트(`m.groups`)를 순회하면서, 특정 노드가 candidate로 참여하고 있거나 in-flight 슬롯을 점유하고 있는 group들의 통계(in-flight, queued)를 취합하여 해당 노드의 ProviderSnapshot에 덮어씌움으로써 "Edge-owned queue snapshot" 계약을 완벽히 충족하도록 설계하였습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- OpenAI는 request `model`을 queue group key로 유지한다.
- A2A/console은 임의 model group key를 만들지 않는다.
- Control Plane status가 Edge-owned queue snapshot을 relay한다.
## 검증 결과
### REFACTOR-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/openai
ok iop/apps/edge/internal/openai 1.507s
```
### REFACTOR-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole
ok iop/apps/edge/internal/input/a2a 0.006s
ok iop/apps/edge/internal/opsconsole 0.006s
```
### REFACTOR-3 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/controlplane
ok iop/apps/edge/internal/service 0.270s
ok iop/apps/edge/internal/controlplane 4.445s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service
ok iop/apps/edge/internal/openai 1.507s
ok iop/apps/edge/internal/input/a2a 0.006s
ok iop/apps/edge/internal/opsconsole 0.006s
ok iop/apps/edge/internal/controlplane 4.445s
ok iop/apps/edge/internal/service 0.270s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제:
- Required: `apps/edge/internal/service/model_queue.go:448`의 snapshot 매칭은 `group.adapter == "ollama"`인 type-name route를 empty/default-name instance에만 연결합니다. 하지만 admission 경로의 `resolveAdapterForNode()`는 `apps/edge/internal/service/run_dispatch.go:312` 이후에서 단일 enabled named instance도 type-name route로 허용하므로, 예를 들어 `adapter="ollama"` 요청이 단일 `ollama-local` instance로 큐잉되면 `ProviderSnapshot{Adapter:"ollama-local"}`의 `InFlight`/`Queued`가 0으로 남습니다. admission과 snapshot이 같은 canonical provider instance를 공유하도록 수정하고, 단일 named instance + type-name route 케이스에서 Edge-owned in-flight/queued가 provider snapshot에 반영되는 테스트를 추가해야 합니다.
- Required: `apps/edge/internal/opsconsole/console_test.go:157`의 새 assertion은 로컬 `SubmitRunRequest` zero value만 확인하고 실제 `opsconsole.SendRun()`이 service에 넘기는 요청을 관찰하지 않습니다. 계획의 “console 테스트가 `ModelGroupKey == ""`와 같은 `SubmitRun` primitive 사용을 명시적으로 검증” 항목을 만족하려면 console `SendRun` 경로를 실행해 전달된 `SubmitRunRequest.ModelGroupKey`가 비어 있음을 검증하는 테스트로 바꿔야 합니다.
- 다음 단계: WARN/FAIL 후속 plan/review를 작성해 위 Required 이슈를 수정한다.

View file

@ -0,0 +1,129 @@
<!-- task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract plan=1 tag=REVIEW_REFACTOR -->
# Code Review Reference - REVIEW_REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
## 개요
date=2026-06-16
task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract, plan=1, tag=REVIEW_REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `snapshot-contract`: Edge-visible capacity/in-flight/queued snapshot이 Edge-owned model group 상태를 기준으로 표현되고 Node capability snapshot과 충돌하지 않는다.
- `surface-coverage`: OpenAI Chat Completions/Responses 실행 경로가 `model` 값 기반 Edge-owned queue를 우회하지 않고, A2A/console 경로는 별도 model group key 없이 같은 Edge dispatch primitive와 충돌하지 않는다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REFACTOR-1] Snapshot Type-Route Instance Mapping | [x] |
| [REVIEW_REFACTOR-2] Console Zero-Key Path Test | [x] |
## 구현 체크리스트
- [x] Snapshot stats resolution이 admission에서 허용한 type-name queue group을 같은 단일 enabled named provider instance로 매핑하고, 최소 Ollama single named type-route 회귀 테스트를 추가한다.
- [x] Console `SendRun` 테스트가 실제 `SendRun` 경로를 실행해 non-empty `ModelGroupKey`/queue path를 사용하지 않는다는 계약을 검증하도록 보강한다.
- [x] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`가 통과한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS split 작업이면 parent directory 유지/정리를 확인한다.
- [x] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 경로를 처리한다.
## 계획 대비 변경 사항
- 없음. (계획된 해결 방법 및 명세에 맞추어 정확하게 구현되었습니다.)
## 주요 설계 결정
- 어드미션 시의 type-name 라우트 규칙에 대응하기 위해 `resolveSnapshotAdapterName` 헬퍼를 도입했습니다. 이를 통해 `group.adapter` 가 타입명(예: `"ollama"`) 이고 노드에 해당 타입의 활성화된 인스턴스가 정확히 1개만 있을 때, 그 구체적인 인스턴스 이름으로 canonicalize 하여 통계를 정확하게 집계하도록 설계했습니다.
- `opsconsole.SendRun` 을 테스트할 때 direct path를 검증하기 위해, `net.Pipe()` 기반의 toki 클라이언트 환경을 마련하고 `Background: true` 설정을 활용하여 비동기 `RunRequest` 가 toki wire 통신을 타고 정확히 전달되는지를 `toki.AddListenerTyped` 로 확인하였습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Snapshot stats가 admission과 같은 canonical provider instance identity를 사용한다.
- Type-name route + single enabled named instance의 in-flight/queued가 provider snapshot에 반영된다.
- Console `SendRun`은 model group key를 만들지 않고 queue path를 타지 않는다.
## 검증 결과
### REVIEW_REFACTOR-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service -run 'TestListNodeSnapshots'
ok iop/apps/edge/internal/service 0.004s
```
### REVIEW_REFACTOR-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/opsconsole -run TestSendRun_SubmitRunRequest_MetadataSource
ok iop/apps/edge/internal/opsconsole 0.006s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service
ok iop/apps/edge/internal/openai 1.508s
ok iop/apps/edge/internal/input/a2a 0.006s
ok iop/apps/edge/internal/opsconsole 0.009s
ok iop/apps/edge/internal/controlplane 4.448s
ok iop/apps/edge/internal/service 0.275s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- 발견된 문제:
- Required: `apps/edge/internal/opsconsole/console_test.go:180`에서 테스트용 service를 `edgeservice.New(reg, nil)`로 만들기 때문에 queue manager가 비활성화됩니다. `SubmitRun()`은 `apps/edge/internal/service/run_dispatch.go:99`에서 `s.queue == nil`이면 `ModelGroupKey`가 non-empty여도 direct path를 타므로, 이 테스트는 `SendRun()`이 실수로 `ModelGroupKey`를 채워도 실패하지 않습니다. 후속 계획의 “non-empty `ModelGroupKey`/queue path를 사용하지 않는다는 계약”을 검증하려면 event bus가 있는 service로 queue를 활성화하고, non-empty queue path라면 실패하도록 node store capability를 설정한 뒤 `SendRun()`이 성공함을 확인해야 합니다.
- 다음 단계: WARN/FAIL 후속 plan/review를 작성해 console zero-key path 테스트를 실제 회귀 가드로 보강한다.

View file

@ -0,0 +1,121 @@
<!-- task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract plan=2 tag=REVIEW_REVIEW_REFACTOR -->
# Code Review Reference - REVIEW_REVIEW_REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
## 개요
date=2026-06-16
task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract, plan=2, tag=REVIEW_REVIEW_REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `snapshot-contract`: Edge-visible capacity/in-flight/queued snapshot이 Edge-owned model group 상태를 기준으로 표현되고 Node capability snapshot과 충돌하지 않는다.
- `surface-coverage`: OpenAI Chat Completions/Responses 실행 경로가 `model` 값 기반 Edge-owned queue를 우회하지 않고, A2A/console 경로는 별도 model group key 없이 같은 Edge dispatch primitive와 충돌하지 않는다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_REFACTOR-1] Queue-Active Console Zero-Key Guard | [x] |
## 구현 체크리스트
- [x] Console `SendRun` 테스트가 queue 활성 service에서 non-empty `ModelGroupKey` 회귀를 실패로 잡도록 보강한다.
- [x] `go test -count=1 ./apps/edge/internal/opsconsole -run TestSendRun_SubmitRunRequest_MetadataSource`가 통과한다.
- [x] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`가 통과한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리를 archive로 이동한다.
- [x] PASS split 작업이면 parent directory 유지/정리를 확인한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 경로를 처리한다.
## 계획 대비 변경 사항
- 없음. (계획에 제시된 해결방법을 완전히 따랐으며 변경 사항은 없습니다.)
## 주요 설계 결정
- `SendRun`의 `ModelGroupKey == ""` (direct path) 계약에 대한 실질적인 회귀 방지를 위해 `edgeservice.New(reg, bus)`를 통해 큐 매니저를 기동하였습니다.
- 이어서 `NodeStore`에 CLI 어댑터가 비활성화(`CLI.Enabled = false`) 되었으나 프로필 `"codex"`를 들고 있는 노드 설정을 주입했습니다. 이로 인해 `ModelGroupKey`가 비어있지 않고 큐잉 라우팅 경로가 시작될 시 `resolveQueueCandidates` 단계에서 supported=false 가 되어 실패하게 구성하여, direct path 유지 여부만 성공으로 반환되도록 검증 가드를 설계했습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `SendRun` 테스트가 queue 활성화 조건에서 실행된다.
- 테스트 fixture상 non-empty `ModelGroupKey`라면 `SubmitRun` queued path가 실패한다.
- console 사용자 출력 형식은 불필요하게 변경되지 않는다.
## 검증 결과
### REVIEW_REVIEW_REFACTOR-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/opsconsole -run TestSendRun_SubmitRunRequest_MetadataSource
ok iop/apps/edge/internal/opsconsole 0.010s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service
ok iop/apps/edge/internal/openai 1.510s
ok iop/apps/edge/internal/input/a2a 0.005s
ok iop/apps/edge/internal/opsconsole 0.007s
ok iop/apps/edge/internal/controlplane 4.451s
ok iop/apps/edge/internal/service 0.273s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 종결. `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,48 @@
# Complete - m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract
## 완료 일시
2026-06-16
## 요약
Edge surface queue/snapshot contract follow-up completed after 3 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G06_0.log` | `code_review_cloud_G06_0.log` | FAIL | Snapshot type-name route mapping gap and console zero-key test gap found. |
| `plan_cloud_G06_1.log` | `code_review_cloud_G06_1.log` | FAIL | Snapshot mapping fixed; console zero-key test still did not fail on non-empty ModelGroupKey because queue was disabled. |
| `plan_cloud_G06_2.log` | `code_review_cloud_G06_2.log` | PASS | Queue-active console zero-key guard verified; no remaining Required or Suggested issues. |
## 구현/정리 내용
- OpenAI Responses route tests now assert request model preservation as `ModelGroupKey`; existing Chat/A2A coverage keeps the same surface contract.
- A2A tests explicitly assert empty `ModelGroupKey`; console `SendRun` test now uses a queue-active service with restrictive node store so a non-empty model group key would fail.
- Edge `ListNodeSnapshots()` now emits Edge-owned provider snapshots with capacity, in-flight, and queued state from the model queue manager.
- Snapshot stats resolution canonicalizes type-name routes to the same single enabled named provider instance used by admission.
- Review cleanup applied gofmt to `apps/edge/internal/opsconsole/console_test.go` and removed trailing whitespace from the active review artifact before archiving.
## 최종 검증
- `go test -count=1 ./apps/edge/internal/opsconsole -run TestSendRun_SubmitRunRequest_MetadataSource` - PASS; `ok iop/apps/edge/internal/opsconsole 0.009s`.
- `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service` - PASS; all listed packages passed, including `internal/openai`, `internal/opsconsole`, and `internal/service`.
- `git diff --check` - PASS; no output.
- `gofmt -l apps/edge/internal/opsconsole/console_test.go apps/edge/internal/service/model_queue.go apps/edge/internal/service/status_provider.go apps/edge/internal/service/status_provider_test.go apps/edge/internal/service/run_dispatch.go apps/edge/internal/service/model_queue_test.go apps/edge/internal/openai/server_test.go apps/edge/internal/input/a2a/server_test.go` - PASS; no output.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Completed task ids:
- `snapshot-contract`: PASS; evidence=`agent-task/archive/2026/06/m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract/plan_cloud_G06_2.log`, `agent-task/archive/2026/06/m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract/code_review_cloud_G06_2.log`; verification=`go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`
- `surface-coverage`: PASS; evidence=`agent-task/archive/2026/06/m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract/plan_cloud_G06_2.log`, `agent-task/archive/2026/06/m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract/code_review_cloud_G06_2.log`; verification=`go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,98 @@
<!-- task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract plan=1 tag=REVIEW_REFACTOR -->
# Plan - REVIEW_REFACTOR Surface Snapshot Contract Follow-up
## 이 파일을 읽는 구현 에이전트에게
`code_review_cloud_G06_0.log`의 Required 이슈만 해결한다. 구현 후 검증 명령을 실행하고 실제 stdout/stderr, 구현 메모, 계획 대비 변경 사항을 `CODE_REVIEW-cloud-G06.md`에 채운 뒤 active 파일을 남긴 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.
구현 중 사용자만 결정할 수 있는 범위 변경, 외부 환경/secret, 또는 scope conflict가 생기면 chat에서 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 남긴다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백은 사용자 리뷰 요청 사유가 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `snapshot-contract`: Edge-visible capacity/in-flight/queued snapshot이 Edge-owned model group 상태를 기준으로 표현되고 Node capability snapshot과 충돌하지 않는다.
- `surface-coverage`: OpenAI Chat Completions/Responses 실행 경로가 `model` 값 기반 Edge-owned queue를 우회하지 않고, A2A/console 경로는 별도 model group key 없이 같은 Edge dispatch primitive와 충돌하지 않는다.
- Completion mode: check-on-pass
## 배경
첫 리뷰는 FAIL이다. 현재 구현은 기본 happy path 테스트는 통과하지만, queue admission과 snapshot merge가 같은 provider instance identity를 공유하지 않는 케이스가 있다. 또한 console zero-key 검증은 실제 `SendRun()` 경로를 실행하지 않아 계획상 필수 테스트로 보기 어렵다.
## 이전 리뷰 근거
- Archived plan: `agent-task/m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract/plan_cloud_G06_0.log`
- Archived review: `agent-task/m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract/code_review_cloud_G06_0.log`
- Verdict: FAIL
## 구현 체크리스트
- [ ] Snapshot stats resolution이 admission에서 허용한 type-name queue group을 같은 단일 enabled named provider instance로 매핑하고, 최소 Ollama single named type-route 회귀 테스트를 추가한다.
- [ ] Console `SendRun` 테스트가 실제 `SendRun` 경로를 실행해 non-empty `ModelGroupKey`/queue path를 사용하지 않는다는 계약을 검증하도록 보강한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_REFACTOR-1] Snapshot Type-Route Instance Mapping
#### 문제
`apps/edge/internal/service/run_dispatch.go`의 admission 경로는 `adapter="ollama"` 같은 type-name route를 단일 enabled named instance에도 허용한다. 반면 `apps/edge/internal/service/model_queue.go`의 snapshot stats 매칭은 `group.adapter == "ollama"`일 때 empty/default-name instance만 연결한다. 따라서 단일 named instance가 `ollama-local`인 node에서 type-name route로 큐잉된 in-flight/queued 값이 provider snapshot에 반영되지 않는다.
#### 해결 방법
Admission과 snapshot이 같은 canonical provider instance identity를 쓰도록 정리한다. 예를 들어 `resolveAdapterForNode()`가 canonical adapter instance name을 함께 반환하게 하거나, snapshot 전용 helper가 exact-name route와 type-name single-instance route를 같은 규칙으로 canonical name에 매핑하게 한다.
Before:
```go
if inst.Enabled && (inst.Name == group.adapter || (group.adapter == "ollama" && inst.Name == "")) {
```
After:
```go
// type-name route with exactly one enabled named instance maps to that instance name.
canonical, ok := resolveSnapshotAdapterName(rec, group.adapter, group.target)
```
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/model_queue.go`: snapshot stats matching을 admission resolution과 일치시킨다.
- [ ] `apps/edge/internal/service/status_provider_test.go`: `adapter="ollama"` group + 단일 enabled `Name:"ollama-local"` instance에서 `ProviderSnapshot{Adapter:"ollama-local"}`의 `InFlight`/`Queued`가 queue state를 반영하는 테스트를 추가한다.
- [ ] 필요 시 `apps/edge/internal/service/run_dispatch.go`의 resolver DTO를 확장하되, 기존 admission semantics는 바꾸지 않는다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service -run 'TestListNodeSnapshots'
```
### [REVIEW_REFACTOR-2] Console Zero-Key Path Test
#### 문제
`apps/edge/internal/opsconsole/console_test.go`의 새 assertion은 로컬 `SubmitRunRequest` zero value만 확인한다. 실제 `opsconsole.SendRun()`이 service에 넘기는 요청을 관찰하지 않으므로, console 경로가 나중에 non-empty `ModelGroupKey`를 넣거나 queue path를 타도 이 테스트는 실패하지 않는다.
#### 해결 방법
실제 `SendRun()`을 실행하는 테스트로 바꾼다. 가능한 방식은 real `edgeservice.Service` + fake node client를 사용하고, node store를 `ModelGroupKey`가 non-empty일 경우 queue admission이 실패하는 설정으로 둔 뒤 `SendRun()`이 성공하는지 확인하는 것이다. 이 테스트는 console이 zero key direct SubmitRun path를 유지한다는 회귀 가드가 된다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/opsconsole/console_test.go`: 기존 zero-value assertion을 실제 `SendRun()` 실행 기반 테스트로 대체하거나 보강한다.
- [ ] 테스트가 console 사용자 출력 형식을 불필요하게 바꾸지 않도록 한다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/opsconsole -run TestSendRun_SubmitRunRequest_MetadataSource
```
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service
```
기대 결과: 통과. Go test cache는 허용하지 않는다.

View file

@ -0,0 +1,63 @@
<!-- task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract plan=2 tag=REVIEW_REVIEW_REFACTOR -->
# Plan - REVIEW_REVIEW_REFACTOR Console Zero-Key Test Follow-up
## 이 파일을 읽는 구현 에이전트에게
`code_review_cloud_G06_1.log`의 Required 이슈만 해결한다. 구현 후 검증 명령을 실행하고 실제 stdout/stderr, 구현 메모, 계획 대비 변경 사항을 `CODE_REVIEW-cloud-G06.md`에 채운 뒤 active 파일을 남긴 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.
구현 중 사용자만 결정할 수 있는 범위 변경, 외부 환경/secret, 또는 scope conflict가 생기면 chat에서 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 남긴다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백은 사용자 리뷰 요청 사유가 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `snapshot-contract`: Edge-visible capacity/in-flight/queued snapshot이 Edge-owned model group 상태를 기준으로 표현되고 Node capability snapshot과 충돌하지 않는다.
- `surface-coverage`: OpenAI Chat Completions/Responses 실행 경로가 `model` 값 기반 Edge-owned queue를 우회하지 않고, A2A/console 경로는 별도 model group key 없이 같은 Edge dispatch primitive와 충돌하지 않는다.
- Completion mode: check-on-pass
## 배경
두 번째 리뷰는 snapshot canonical mapping은 통과로 보았지만, console zero-key 테스트가 아직 `ModelGroupKey` 회귀를 잡지 못한다고 판정했다. 현재 테스트는 `edgeservice.New(reg, nil)`로 service를 만들어 queue가 비활성화되어 있으므로, `SendRun()`이 실수로 non-empty `ModelGroupKey`를 채워도 direct path로 전송되어 테스트가 통과한다.
## 이전 리뷰 근거
- Archived plan: `agent-task/m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract/plan_cloud_G06_1.log`
- Archived review: `agent-task/m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract/code_review_cloud_G06_1.log`
- Verdict: FAIL
## 구현 체크리스트
- [ ] Console `SendRun` 테스트가 queue 활성 service에서 non-empty `ModelGroupKey` 회귀를 실패로 잡도록 보강한다.
- [ ] `go test -count=1 ./apps/edge/internal/opsconsole -run TestSendRun_SubmitRunRequest_MetadataSource`가 통과한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_REVIEW_REFACTOR-1] Queue-Active Console Zero-Key Guard
#### 문제
`apps/edge/internal/opsconsole/console_test.go:180`의 테스트 service는 event bus 없이 생성되어 queue가 nil이다. `SubmitRun()`은 `s.queue == nil`이면 `ModelGroupKey`를 무시하고 direct path를 타므로, 테스트가 `ModelGroupKey == ""` 계약을 실제로 보호하지 않는다.
#### 해결 방법
테스트 service를 `edgeevents.NewBus()`와 함께 생성해 queue를 활성화한다. 그리고 `SetNodeStore()`로 같은 node ID의 `NodeRecord`를 추가하되, non-empty queue path가 `adapter="cli", target="codex"`를 통과하지 못하도록 CLI config를 명시적으로 disabled/present 상태로 둔다. 예를 들어 `CLI.Enabled=false`이고 `Profiles`에 `codex`를 넣으면 queued path는 `resolveAdapterForNode()`에서 unsupported가 된다. 이 상태에서 현재 production `SendRun()`이 성공하고 node가 `RunRequest`를 받으면, console이 empty `ModelGroupKey` direct path를 유지했다는 회귀 증거가 된다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/opsconsole/console_test.go`: `TestSendRun_SubmitRunRequest_MetadataSource`를 queue-active service + restrictive node store로 보강한다.
- [ ] 테스트 코멘트는 “왜 non-empty ModelGroupKey면 실패하는지”를 실제 service 조건과 맞게 정정한다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/opsconsole -run TestSendRun_SubmitRunRequest_MetadataSource
```
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service
```
기대 결과: 통과. Go test cache는 허용하지 않는다.

View file

@ -1,117 +0,0 @@
<!-- task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
## 개요
date=2026-06-16
task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `snapshot-contract`: Edge-visible capacity/in-flight/queued snapshot이 Edge-owned model group 상태를 기준으로 표현되고 Node capability snapshot과 충돌하지 않는다.
- `surface-coverage`: OpenAI Chat Completions/Responses 실행 경로가 `model` 값 기반 Edge-owned queue를 우회하지 않고, A2A/console 경로는 별도 model group key 없이 같은 Edge dispatch primitive와 충돌하지 않는다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] OpenAI Queue Coverage | [ ] |
| [REFACTOR-2] A2A/Console Zero-Key Contract | [ ] |
| [REFACTOR-3] Edge-Owned Snapshot | [ ] |
## 구현 체크리스트
- [ ] OpenAI Chat/Responses 테스트가 request `model` 기반 `ModelGroupKey`와 queue primitive 사용을 검증하도록 보강한다.
- [ ] A2A/console 테스트가 `ModelGroupKey == ""`와 같은 `SubmitRun` primitive 사용을 명시적으로 검증하도록 보강한다.
- [ ] `ListNodeSnapshots`/Control Plane status snapshot이 Edge-owned queue state의 capacity/in-flight/queued를 표현하도록 service boundary를 조정한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G06_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G06_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block을 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS split 작업이면 parent directory 유지/정리를 확인한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 경로를 처리한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- OpenAI는 request `model`을 queue group key로 유지한다.
- A2A/console은 임의 model group key를 만들지 않는다.
- Control Plane status가 Edge-owned queue snapshot을 relay한다.
## 검증 결과
### REFACTOR-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/openai
(output)
```
### REFACTOR-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole
(output)
```
### REFACTOR-3 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/controlplane
(output)
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**

View file

@ -77,6 +77,9 @@ func TestHandleMessageSendDispatchesRun(t *testing.T) {
if req.Metadata["source"] != "a2a" {
t.Errorf("expected source=a2a, got %q", req.Metadata["source"])
}
if req.ModelGroupKey != "" {
t.Errorf("expected empty ModelGroupKey, got %q", req.ModelGroupKey)
}
return feedHandle("run-1", &iop.RunEvent{RunId: "run-1", Type: "complete"}), nil
},
}
@ -318,6 +321,9 @@ func TestBlockingDefault(t *testing.T) {
svc := &fakeService{
submitFn: func(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
dispatched = true
if req.ModelGroupKey != "" {
t.Errorf("expected empty ModelGroupKey, got %q", req.ModelGroupKey)
}
got := req.Metadata["blocking"]
want := "true"
if !tc.wantBlock {

View file

@ -1106,6 +1106,9 @@ func TestResponsesRouteCatalogDispatchesRoute(t *testing.T) {
if fake.req.Adapter != "vllm" || fake.req.Target != "qwen" {
t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target)
}
if fake.req.ModelGroupKey != "model-a" {
t.Fatalf("model group key: got %q, want model-a", fake.req.ModelGroupKey)
}
}
func TestResponsesRouteCatalogTakesPriorityOverMetadataTarget(t *testing.T) {
@ -1132,6 +1135,9 @@ func TestResponsesRouteCatalogTakesPriorityOverMetadataTarget(t *testing.T) {
if fake.req.Target != "route-target" {
t.Fatalf("expected route-target, got %q", fake.req.Target)
}
if fake.req.ModelGroupKey != "model-a" {
t.Fatalf("model group key: got %q, want model-a", fake.req.ModelGroupKey)
}
}
func TestChatCompletionsEdgePassesNumCtxForAdapterEnforcement(t *testing.T) {

View file

@ -3,11 +3,19 @@ package opsconsole
import (
"bytes"
"context"
"net"
"strings"
"testing"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"google.golang.org/protobuf/proto"
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
func TestParseCommand(t *testing.T) {
@ -142,29 +150,86 @@ func TestPrintNodes_AcceptsSnapshotDTO(t *testing.T) {
}
func TestSendRun_SubmitRunRequest_MetadataSource(t *testing.T) {
// Verify the metadata source value that SendRun injects matches the expected console identity.
const wantSource = "edge-ops-console"
// Create net.Pipe for client-server communication
edgeConn, nodeConn := net.Pipe()
defer edgeConn.Close()
defer nodeConn.Close()
// This test documents the contract: SendRun creates a SubmitRunRequest
// with Metadata: map[string]string{"source": "edge-ops-console"}.
// The actual service-level behavior is verified in
// service_test.TestBuildRunRequestCopiesMetadata.
if wantSource != "edge-ops-console" {
t.Fatalf("expected console metadata source to be edge-ops-console")
parserMap := toki.ParserMap{
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RunRequest{}
return m, proto.Unmarshal(b, m)
},
}
// Verify BuildRunRequest does NOT inject a default source (service is caller-neutral).
req, _, err := edgeservice.BuildRunRequest(edgeservice.SubmitRunRequest{
Adapter: "cli",
Target: "codex",
Prompt: "hello",
Metadata: map[string]string{"source": "edge-ops-console"},
edgeClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap)
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap)
reqReceived := make(chan *iop.RunRequest, 1)
toki.AddListenerTyped[*iop.RunRequest](&nodeClient.Communicator, func(req *iop.RunRequest) {
reqReceived <- req
})
if err != nil {
t.Fatalf("BuildRunRequest: %v", err)
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{
NodeID: "node-console-test",
Alias: "alpha",
AgentKind: "node",
Client: edgeClient,
})
// Enable queue manager in service by passing a new Bus
bus := edgeevents.NewBus()
svc := edgeservice.New(reg, bus)
// Configure NodeStore to fail for queued paths by setting CLI.Enabled = false
// while presenting the "codex" profile. This will cause resolveAdapterForNode
// to return supported = false if the queued path (which resolves candidate nodes)
// is evaluated.
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-console-test",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{
Enabled: false,
Profiles: map[string]config.CLIProfileConf{
"codex": {},
},
},
},
})
svc.SetNodeStore(store)
target := &TargetState{
NodeRef: "alpha",
Adapter: "cli",
Target: "codex",
SessionID: "sess-1",
Background: true,
TimeoutSec: 10,
}
if got := req.GetMetadata()["source"]; got != "edge-ops-console" {
t.Errorf("BuildRunRequest should preserve caller source, got %q", got)
var out bytes.Buffer
err := SendRun(context.Background(), svc, nil, &out, target, "test prompt")
if err != nil {
t.Fatalf("SendRun failed: %v", err)
}
select {
case req := <-reqReceived:
if req.GetAdapter() != "cli" || req.GetTarget() != "codex" {
t.Errorf("unexpected run request: %+v", req)
}
if req.GetMetadata()["source"] != "edge-ops-console" {
t.Errorf("expected source edge-ops-console, got %q", req.GetMetadata()["source"])
}
// If SendRun had set a non-empty ModelGroupKey, it would have routed through the queue.
// Since we configured CLI.Enabled = false, resolveQueueCandidates would return an error
// ("node ... does not support adapter ..."), causing SendRun to fail.
// Successful delivery confirms that SendRun maintains ModelGroupKey == "" (direct path).
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for RunRequest at node")
}
}

View file

@ -56,6 +56,8 @@ type modelQueueGroup struct {
policy groupPolicy
queue []*queueItem
inflight map[string]int // nodeID → current in-flight count
adapter string
target string
}
type modelQueueManager struct {
@ -152,10 +154,16 @@ func (m *modelQueueManager) findAvailableNodeLocked(group *modelQueueGroup, cand
// admit selects an available node for the given model group, or queues the
// request until a slot opens. Blocks until a node is assigned, the queue
// timeout expires, or ctx is cancelled.
func (m *modelQueueManager) admit(ctx context.Context, groupKey string, candidates []candidateNode, policy groupPolicy) (*edgenode.NodeEntry, error) {
func (m *modelQueueManager) admit(ctx context.Context, groupKey, adapter, target string, candidates []candidateNode, policy groupPolicy) (*edgenode.NodeEntry, error) {
m.mu.Lock()
group := m.getOrCreateGroupLocked(groupKey, policy)
if group.adapter == "" {
group.adapter = adapter
}
if group.target == "" {
group.target = target
}
candidate := m.findAvailableNodeLocked(group, candidates)
if candidate != nil {
@ -331,3 +339,211 @@ func (m *modelQueueManager) removeItemLocked(groupKey string, item *queueItem) {
}
}
}
func (m *modelQueueManager) getSnapshotForNode(nodeID string, rec *edgenode.NodeRecord) []*iop.ProviderSnapshot {
m.mu.Lock()
defer m.mu.Unlock()
var snaps []*iop.ProviderSnapshot
concurrencyFallback := 1
if rec.Runtime.Concurrency > 0 {
concurrencyFallback = rec.Runtime.Concurrency
}
// 1. CLI
if rec.Adapters.CLI.Enabled {
capVal := concurrencyFallback
inflight, queued := m.getStatsForAdapterLocked(nodeID, rec, "cli")
snaps = append(snaps, &iop.ProviderSnapshot{
Adapter: "cli",
Status: "available",
Capacity: int32(capVal),
InFlight: int32(inflight),
Queued: int32(queued),
})
}
// 2. Ollama
for _, inst := range rec.Adapters.OllamaInstances {
if !inst.Enabled {
continue
}
name := inst.Name
if name == "" {
name = "ollama"
}
capVal := inst.Capacity
if capVal <= 0 {
capVal = concurrencyFallback
}
inflight, queued := m.getStatsForAdapterLocked(nodeID, rec, name)
snaps = append(snaps, &iop.ProviderSnapshot{
Adapter: name,
Status: "available",
Capacity: int32(capVal),
InFlight: int32(inflight),
Queued: int32(queued),
})
}
// 3. vLLM
for _, inst := range rec.Adapters.VllmInstances {
if !inst.Enabled {
continue
}
name := inst.Name
if name == "" {
name = "vllm"
}
capVal := inst.Capacity
if capVal <= 0 {
capVal = concurrencyFallback
}
inflight, queued := m.getStatsForAdapterLocked(nodeID, rec, name)
snaps = append(snaps, &iop.ProviderSnapshot{
Adapter: name,
Status: "available",
Capacity: int32(capVal),
InFlight: int32(inflight),
Queued: int32(queued),
})
}
// 4. OpenAI Compat
for _, inst := range rec.Adapters.OpenAICompatInstances {
if !inst.Enabled {
continue
}
name := inst.Name
if name == "" {
name = "openai_compat"
}
capVal := inst.Capacity
if capVal <= 0 {
capVal = concurrencyFallback
}
inflight, queued := m.getStatsForAdapterLocked(nodeID, rec, name)
snaps = append(snaps, &iop.ProviderSnapshot{
Adapter: name,
Status: "available",
Capacity: int32(capVal),
InFlight: int32(inflight),
Queued: int32(queued),
})
}
return snaps
}
func (m *modelQueueManager) getStatsForAdapterLocked(nodeID string, rec *edgenode.NodeRecord, adapterName string) (inFlight, queued int) {
for _, group := range m.groups {
canonical, ok := resolveSnapshotAdapterName(rec, group.adapter, group.target)
if ok && canonical == adapterName {
if val, ok := group.inflight[nodeID]; ok {
inFlight += val
}
for _, item := range group.queue {
for _, c := range item.candidates {
if c.entry.NodeID == nodeID {
queued++
break
}
}
}
}
}
return
}
func resolveSnapshotAdapterName(rec *edgenode.NodeRecord, adapterType, target string) (string, bool) {
if rec == nil {
return adapterType, true
}
// 1. Exact instance Name match (highest priority).
for _, inst := range rec.Adapters.OllamaInstances {
if inst.Name == adapterType {
return adapterType, true
}
}
for _, inst := range rec.Adapters.VllmInstances {
if inst.Name == adapterType {
return adapterType, true
}
}
for _, inst := range rec.Adapters.OpenAICompatInstances {
if inst.Name == adapterType {
return adapterType, true
}
}
// 2. Type-name route.
switch adapterType {
case "ollama":
var enabled []string
for _, inst := range rec.Adapters.OllamaInstances {
if inst.Enabled {
name := inst.Name
if name == "" {
name = "ollama"
}
enabled = append(enabled, name)
}
}
switch len(enabled) {
case 0:
return "ollama", true
case 1:
return enabled[0], true
default:
return "", false
}
case "vllm":
var enabled []string
for _, inst := range rec.Adapters.VllmInstances {
if inst.Enabled {
name := inst.Name
if name == "" {
name = "vllm"
}
enabled = append(enabled, name)
}
}
switch len(enabled) {
case 0:
return "vllm", true
case 1:
return enabled[0], true
default:
return "", false
}
case "openai_compat":
var enabled []string
for _, inst := range rec.Adapters.OpenAICompatInstances {
if inst.Enabled {
name := inst.Name
if name == "" {
name = "openai_compat"
}
enabled = append(enabled, name)
}
}
switch len(enabled) {
case 0:
return "openai_compat", true
case 1:
return enabled[0], true
default:
return "", false
}
case "cli":
return "cli", true
default:
return adapterType, true
}
}

View file

@ -48,7 +48,7 @@ func TestModelQueueFIFOOrdering(t *testing.T) {
m := newModelQueueManager(store)
// Fill the only capacity slot.
first, err := m.admit(context.Background(), "g-fifo", cands, defPolicy)
first, err := m.admit(context.Background(), "g-fifo", "", "", cands, defPolicy)
if err != nil || first == nil {
t.Fatalf("initial admit: %v", err)
}
@ -140,7 +140,7 @@ func TestModelQueueOverflow(t *testing.T) {
m.mu.Unlock()
// Second admit should fail immediately with errQueueFull.
_, err := m.admit(context.Background(), "g-overflow", cands, groupPolicy{})
_, err := m.admit(context.Background(), "g-overflow", "", "", cands, groupPolicy{})
if !errors.Is(err, errQueueFull) {
t.Fatalf("expected errQueueFull, got: %v", err)
}
@ -168,7 +168,7 @@ func TestModelQueueTimeout(t *testing.T) {
m.mu.Unlock()
start := time.Now()
_, err := m.admit(context.Background(), "g-timeout", cands, groupPolicy{})
_, err := m.admit(context.Background(), "g-timeout", "", "", cands, groupPolicy{})
elapsed := time.Since(start)
if !errors.Is(err, errQueueTimeout) {
@ -198,7 +198,7 @@ func TestModelQueueTerminalReleaseDispatchesNext(t *testing.T) {
defer stop()
// Fill capacity and record inflight.
node, err := m.admit(context.Background(), "g-tr", cands, defPolicy)
node, err := m.admit(context.Background(), "g-tr", "", "", cands, defPolicy)
if err != nil {
t.Fatalf("admit: %v", err)
}
@ -207,7 +207,7 @@ func TestModelQueueTerminalReleaseDispatchesNext(t *testing.T) {
// Queue a second item in a goroutine.
resultCh := make(chan admitResult, 1)
go func() {
n, e := m.admit(context.Background(), "g-tr", cands, defPolicy)
n, e := m.admit(context.Background(), "g-tr", "", "", cands, defPolicy)
resultCh <- admitResult{node: n, err: e}
}()
@ -348,13 +348,13 @@ func TestModelQueueUsesProviderCapacity(t *testing.T) {
m := newModelQueueManager(nil)
// First admit: inflight=0 < cap=2 → dispatched immediately.
n1, err := m.admit(context.Background(), "g-pc", cands, defPolicy)
n1, err := m.admit(context.Background(), "g-pc", "", "", cands, defPolicy)
if err != nil || n1 == nil {
t.Fatalf("first admit: %v", err)
}
// Second admit: inflight=1 < cap=2 → still dispatched (not queued).
n2, err := m.admit(context.Background(), "g-pc", cands, defPolicy)
n2, err := m.admit(context.Background(), "g-pc", "", "", cands, defPolicy)
if err != nil || n2 == nil {
t.Fatalf("second admit (capacity=2 should allow): %v", err)
}
@ -409,7 +409,7 @@ func TestModelQueueUsesProviderQueuePolicy(t *testing.T) {
m.mu.Unlock()
// Second admit should fail: queue already at maxQueue=1.
_, err := m.admit(context.Background(), "g-pq-max", cands, policy)
_, err := m.admit(context.Background(), "g-pq-max", "", "", cands, policy)
if !errors.Is(err, errQueueFull) {
t.Fatalf("expected errQueueFull, got: %v", err)
}
@ -430,7 +430,7 @@ func TestModelQueueUsesProviderQueuePolicy(t *testing.T) {
m.mu.Unlock()
start := time.Now()
_, err := m.admit(context.Background(), "g-pq-to", cands, policy)
_, err := m.admit(context.Background(), "g-pq-to", "", "", cands, policy)
elapsed := time.Since(start)
if !errors.Is(err, errQueueTimeout) {
@ -452,7 +452,7 @@ func TestModelQueueContextCancelRemovesQueuedItem(t *testing.T) {
m := newModelQueueManager(nil)
// Fill the only slot.
n, err := m.admit(context.Background(), "g-cc", cands, defPolicy)
n, err := m.admit(context.Background(), "g-cc", "", "", cands, defPolicy)
if err != nil || n == nil {
t.Fatalf("initial admit: %v", err)
}
@ -461,7 +461,7 @@ func TestModelQueueContextCancelRemovesQueuedItem(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
_, err := m.admit(ctx, "g-cc", cands, defPolicy)
_, err := m.admit(ctx, "g-cc", "", "", cands, defPolicy)
resultCh <- err
}()

View file

@ -116,7 +116,7 @@ func (s *Service) submitRunQueued(ctx context.Context, req SubmitRunRequest) (Ru
return nil, err
}
entry, err := s.queue.admit(ctx, req.ModelGroupKey, candidates, policy)
entry, err := s.queue.admit(ctx, req.ModelGroupKey, req.Adapter, req.Target, candidates, policy)
if err != nil {
return nil, err
}

View file

@ -32,13 +32,18 @@ func (s *Service) ListNodeSnapshots() []NodeSnapshot {
AgentKind: entry.AgentKind,
LifecycleState: entry.LifecycleState,
}
var rec *edgenode.NodeRecord
if s.nodeStore != nil {
if rec, ok := s.nodeStore.FindByID(entry.NodeID); ok {
if payload, err := edgenode.BuildConfigPayload(rec); err == nil {
if r, ok := s.nodeStore.FindByID(entry.NodeID); ok {
rec = r
if payload, err := edgenode.BuildConfigPayload(r); err == nil {
snap.Config = payload
}
}
}
if rec != nil && s.queue != nil {
snap.ProviderSnapshots = s.queue.getSnapshotForNode(entry.NodeID, rec)
}
out = append(out, snap)
}
return out

View file

@ -0,0 +1,133 @@
package service
import (
"testing"
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
)
func TestListNodeSnapshotsUsesEdgeQueueState(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-q-1", Alias: "node-q-1"})
store := edgenode.NewNodeStore()
rec := &edgenode.NodeRecord{
ID: "node-q-1",
Adapters: config.AdaptersConf{
OllamaInstances: []config.OllamaInstanceConf{
{
Name: "ollama-inst",
Enabled: true,
Capacity: 3,
},
},
},
}
store.Add(rec)
bus := edgeevents.NewBus()
svc := New(reg, bus)
svc.SetNodeStore(store)
// Manually inject group state to modelQueueManager
svc.queue.mu.Lock()
svc.queue.groups["test-group"] = &modelQueueGroup{
key: "test-group",
adapter: "ollama-inst",
inflight: map[string]int{"node-q-1": 2},
queue: []*queueItem{
{
candidates: []candidateNode{
{entry: reg.All()[0], capacity: 3},
},
},
},
}
svc.queue.mu.Unlock()
snaps := svc.ListNodeSnapshots()
if len(snaps) != 1 {
t.Fatalf("expected 1 node snapshot, got %d", len(snaps))
}
snap := snaps[0]
if len(snap.ProviderSnapshots) != 1 {
t.Fatalf("expected 1 provider snapshot, got %d", len(snap.ProviderSnapshots))
}
p := snap.ProviderSnapshots[0]
if p.Adapter != "ollama-inst" {
t.Errorf("expected adapter ollama-inst, got %s", p.Adapter)
}
if p.Capacity != 3 {
t.Errorf("expected capacity 3, got %d", p.Capacity)
}
if p.InFlight != 2 {
t.Errorf("expected inflight 2, got %d", p.InFlight)
}
if p.Queued != 1 {
t.Errorf("expected queued 1, got %d", p.Queued)
}
}
func TestListNodeSnapshotsTypeRouteSingleNamedInstance(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-q-2", Alias: "node-q-2"})
store := edgenode.NewNodeStore()
rec := &edgenode.NodeRecord{
ID: "node-q-2",
Adapters: config.AdaptersConf{
OllamaInstances: []config.OllamaInstanceConf{
{
Name: "ollama-local",
Enabled: true,
Capacity: 5,
},
},
},
}
store.Add(rec)
bus := edgeevents.NewBus()
svc := New(reg, bus)
svc.SetNodeStore(store)
// Manually inject group state to modelQueueManager with type-name route "ollama"
svc.queue.mu.Lock()
svc.queue.groups["test-group"] = &modelQueueGroup{
key: "test-group",
adapter: "ollama",
inflight: map[string]int{"node-q-2": 3},
queue: []*queueItem{
{
candidates: []candidateNode{
{entry: reg.All()[0], capacity: 5},
},
},
},
}
svc.queue.mu.Unlock()
snaps := svc.ListNodeSnapshots()
if len(snaps) != 1 {
t.Fatalf("expected 1 node snapshot, got %d", len(snaps))
}
snap := snaps[0]
if len(snap.ProviderSnapshots) != 1 {
t.Fatalf("expected 1 provider snapshot, got %d", len(snap.ProviderSnapshots))
}
p := snap.ProviderSnapshots[0]
if p.Adapter != "ollama-local" {
t.Errorf("expected adapter ollama-local, got %s", p.Adapter)
}
if p.Capacity != 5 {
t.Errorf("expected capacity 5, got %d", p.Capacity)
}
if p.InFlight != 3 {
t.Errorf("expected inflight 3, got %d", p.InFlight)
}
if p.Queued != 1 {
t.Errorf("expected queued 1, got %d", p.Queued)
}
}