feat(edge): model queue priority routing with runtime config refresh

- Add classify.go/classify_test.go for config-based priority routing
- Add model_queue.go/test with priority queue support
- Update run_dispatch.go to use priority routing
- Add edge.yaml config for model priority settings
- Add config.go/config_test.go for priority config parsing
- Remove G06 priority routing plan/review (merged into main flow)
- Add archive for m-node-provider-first-config-surface task
This commit is contained in:
toki 2026-07-01 21:07:29 +09:00
parent 90bce8336f
commit c0d2748361
14 changed files with 821 additions and 120 deletions

View file

@ -41,7 +41,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c
## refresh 분류 기준
- live apply 가능: provider capacity, provider max queue, provider queue timeout, provider `enabled` toggle, `models[]` display/provider mapping, legacy node runtime concurrency metadata.
- live apply 가능: provider capacity, provider priority, provider max queue, provider queue timeout, provider `enabled` toggle, `models[]` display/provider mapping, legacy node runtime concurrency metadata.
- restart required: Edge identity/listen/bootstrap/logging/metrics/console/control-plane/openai/a2a listener config, node 추가/삭제, node token/alias/agent kind, adapter 설정, provider type/category/adapter/models/health/lifecycle capability, provider-first execution fields(`provider`, `endpoint`, `base_url`, `headers`, `command`, `args`, `env`, `mode`, `resume_args`, `output_format`, `context_size`, `request_timeout_ms`) 변경.
- rejected: candidate config load/validate 실패, invalid refresh mode, apply failure.

View file

@ -0,0 +1,200 @@
<!-- task=m-node-provider-first-config-surface/07_priority_routing plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[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 selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close 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 only the linked Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-01
task=m-node-provider-first-config-surface/07_priority_routing, plan=1, tag=REVIEW_API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Task ids:
- `priority-routing`: Provider-pool dispatch가 dispatch 가능한 후보 중 가장 낮은 `in_flight` provider를 우선하고, `in_flight`가 같은 후보에서만 낮은 숫자의 `priority`를 선택 기준으로 사용한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-node-provider-first-config-surface/07_priority_routing/plan_local_G06_0.log`
- Archived review: `agent-task/m-node-provider-first-config-surface/07_priority_routing/code_review_local_G06_0.log`
- Verdict: FAIL
- Required summary: `apps/edge/internal/service/model_queue_test.go:824`의 actual `resolveProviderPoolCandidates` 경로 테스트가 provider `Priority` 값을 설정하거나 `candidateNode.priority` 전달을 assert하지 않아, config `nodes[].providers[].priority`가 dispatch candidate까지 이어지는 증거가 빠져 있다.
- Affected files: `apps/edge/internal/service/model_queue_test.go`
- Prior verification evidence: `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh` PASS, `go test -count=1 ./apps/edge/... ./packages/go/...` PASS.
- Roadmap carryover: SDD S08 / Milestone Task `priority-routing` completion evidence must include priority default/non-negative validation, `in_flight` selection, priority tie-break, equal priority rotation, and resolver priority propagation.
- Allowed narrow reread paths: `agent-task/m-node-provider-first-config-surface/07_priority_routing/plan_local_G06_0.log`, `agent-task/m-node-provider-first-config-surface/07_priority_routing/code_review_local_G06_0.log`.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G03.md` -> `code_review_local_G03_N.log`, `PLAN-local-G03.md` -> `plan_local_G03_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-node-provider-first-config-surface/07_priority_routing/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-node-provider-first-config-surface`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] Resolver Priority Propagation Test | [x] |
## 구현 체크리스트
- [x] `resolveProviderPoolCandidates` actual 경로를 호출하는 service/model queue 테스트에 provider `Priority` fixture를 추가하고 반환된 `candidateNode.priority` 값을 assert한다.
- [x] `go test -count=1 -run 'TestResolveProviderPoolCandidates' ./apps/edge/internal/service`와 `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`를 실행한다.
- [x] `go test -count=1 ./apps/edge/... ./packages/go/...`를 실행한다.
- [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_local_G03_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G03_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/m-node-provider-first-config-surface/07_priority_routing/`를 `agent-task/archive/YYYY/MM/m-node-provider-first-config-surface/07_priority_routing/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-node-provider-first-config-surface`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-node-provider-first-config-surface/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- 계획에서는 `TestResolveProviderPoolCandidatesFiltersInvalidProviders` 또는 별도 focused test 중 하나를 선택하라고 했지만, 기존 `FiltersInvalidProviders` 테스트에 Priority fixture를 추가하면 테스트 목적과 fixture가 혼재된다. 별도 focused test `TestResolveProviderPoolCandidatesPropagatesPriority`로 분리했다.
## 주요 설계 결정
- 별도 test function으로 분리: 기존 `FiltersInvalidProviders` 테스트는 health/capacity/adapter/mismatch 필터링 검증이 주 목적이며, 여기에 priority를 추가하면 검증 목적이 혼재된다. priority propagation에 특화된 별도 test function을 작성했다.
- 세 가지 priority 패턴을 모두 검증: (1) explicit non-zero priority(Priority=3), (2) 다른 non-zero priority(Priority=10), (3) Priority 필드 omitted(기본값 0). 세 경우 모두 `resolveProviderPoolCandidates` 실제 경유를 통해 `candidateNode.priority`로 전달됨을 assert한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- actual `resolveProviderPoolCandidates` 테스트가 config provider `Priority` 값을 non-zero fixture로 설정하는지 확인한다.
- 반환된 `candidateNode.priority`가 fixture 값과 정확히 같은지 확인한다.
- 새 테스트가 hand-written `candidateNode` selector tests만 보강하는 것이 아니라 config -> resolver 경로를 통과하는지 확인한다.
- 검증 출력이 `-count=1` fresh execution인지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_API-1 중간 검증
```text
$ go test -count=1 -run 'TestResolveProviderPoolCandidates' ./apps/edge/internal/service
ok ipop/apps/edge/internal/service 0.004s
```
### 최종 검증
```text
$ go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh
ok ipop/packages/go/config 0.034s
ok ipop/apps/edge/internal/service 0.436s
ok ipop/apps/edge/internal/configrefresh 0.015s
```
```text
$ go test -count=1 ./apps/edge/... ./packages/go/...
ok ipop/apps/edge/cmd/edge 0.046s
ok ipop/apps/edge/internal/bootstrap 0.292s
ok ipop/apps/edge/internal/configrefresh 0.020s
ok ipop/apps/edge/internal/controlplane 4.458s
ok ipop/apps/edge/internal/edgecmd 0.005s
ok ipop/apps/edge/internal/edgevalidate 0.005s
ok ipop/apps/edge/internal/events 0.006s
ok ipop/apps/edge/internal/input 0.012s
ok ipop/apps/edge/internal/input/a2a 0.009s
ok ipop/apps/edge/internal/node 0.009s
ok ipop/apps/edge/internal/openai 1.563s
ok ipop/apps/edge/internal/opsconsole 0.006s
ok ipop/apps/edge/internal/service 0.442s
ok ipop/apps/edge/internal/transport 2.041s
ok ipop/packages/go/audit 0.002s
? ipop/packages/go/auth [no test files]
ok ipop/packages/go/config 0.036s
? ipop/packages/go/events [no test files]
ok ipop/packages/go/hostsetup 0.006s
? ipop/packages/go/jobs [no test files]
? ipop/packages/go/metadata [no test files]
ok ipop/packages/go/observability 0.012s
? ipop/packages/go/policy [no test files]
? ipop/packages/go/version [no test files]
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent uses it as prior-loop context; read only the specific archive files cited there when more detail is required |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 | Implementing agent | Fill actual command output only |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Implementation deviation: Pass
- Verification trust: Pass
- Spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 처리로 active plan/review를 로그로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -42,43 +42,50 @@ task=m-node-provider-first-config-surface/07_priority_routing, plan=0, tag=API
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Config Schema And Contract | [ ] |
| [API-2] Provider Queue Priority Selection | [ ] |
| [API-3] Refresh Classification | [ ] |
| [API-1] Config Schema And Contract | [x] |
| [API-2] Provider Queue Priority Selection | [x] |
| [API-3] Refresh Classification | [x] |
## 구현 체크리스트
- [ ] `NodeProviderConf.Priority`를 config schema, contract, `configs/edge.yaml` 예시에 추가하고 기본값 0/음수 거부를 `packages/go/config` tests로 검증한다.
- [ ] provider-pool queue candidate에 priority를 전달하고 선택 순서를 `in_flight` 오름차순 -> `priority` 오름차순 -> 기존 rotation으로 바꾸며 service/model queue tests를 갱신한다.
- [ ] config refresh classification에서 provider priority-only 변경을 live `applied`로 분류하고 classification tests를 추가한다.
- [ ] `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`와 `go test -count=1 ./apps/edge/... ./packages/go/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `NodeProviderConf.Priority`를 config schema, contract, `configs/edge.yaml` 예시에 추가하고 기본값 0/음수 거부를 `packages/go/config` tests로 검증한다.
- [x] provider-pool queue candidate에 priority를 전달하고 선택 순서를 `in_flight` 오름차순 -> `priority` 오름차순 -> 기존 rotation으로 바꾸며 service/model queue tests를 갱신한다.
- [x] config refresh classification에서 provider priority-only 변경을 live `applied`로 분류하고 classification tests를 추가한다.
- [x] `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`와 `go test -count=1 ./apps/edge/... ./packages/go/...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-node-provider-first-config-surface/07_priority_routing/`를 `agent-task/archive/YYYY/MM/m-node-provider-first-config-surface/07_priority_routing/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-node-provider-first-config-surface`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-node-provider-first-config-surface/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G03.md`와 `CODE_REVIEW-local-G03.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 계획의 `findAvailableNodeLocked` Before/After 코드에서 `loadRatioLess`/`loadRatioEqual` 대신 absolute `inflight < bestInflight` 비교를 선택했다. 기능적 차이는 없지만 함수 제거가 더 깔끔해 직접 제거했다.
- `loadRatioLess`/`loadRatioEqual` helper 함수를 완전히 제거했다. 계획에서는 `더 이상 쓰지 않으면 제거`로 명시했고, 실제 코드에서도 unused로 남아있으면 build error가 발생한다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `candidateNode`에 `priority int` 필드를 추가했다. zero value가 기본값 0이므로 YAML/struct에서 omitted시 자동으로 0이 된다.
- `resolveProviderPoolCandidates`에서 `priority: prov.Priority`를 전달한다. legacy candidate(adapter route)는 `priority`가 omitted되어 zero value 0을 유지한다.
- `findAvailableNodeLocked`의 비교 로직은 `in_flight` count를 absolute로 먼저 비교하고, equal하면 `priority`로 tie-break, equal하면 candidate slice를 유지해 기존 rotation을 유지한다.
- priority 변경은 config refresh에서 `StatusApplied`(live-apply)로 분류한다. provider pool dispatch policy 변경으로 restart 없이 즉시 적용 가능하기 때문이다.
- `loadRatioLess`/`loadRatioEqual` 함수는 제거했다. 더 이상 참조되지 않아 컴파일 에러가 발생하므로 의도대로 삭제했다.
## 사용자 리뷰 요청
@ -115,30 +122,55 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### API-1 중간 검증
```text
$ go test -count=1 ./packages/go/config
(output)
ok iop/packages/go/config 0.097s
```
### API-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service
(output)
ok iop/apps/edge/internal/service 0.450s
```
### API-3 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/configrefresh
(output)
ok iop/apps/edge/internal/configrefresh 0.062s
```
### 최종 검증
```text
$ go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh
(output)
ok iop/packages/go/config 0.130s
ok iop/apps/edge/internal/service 0.452s
ok iop/apps/edge/internal/configrefresh 0.042s
```
```text
$ go test -count=1 ./apps/edge/... ./packages/go/...
(output)
ok iop/apps/edge/cmd/edge 0.120s
ok iop/apps/edge/internal/bootstrap 0.525s
ok iop/apps/edge/internal/configrefresh 0.133s
ok iop/apps/edge/internal/controlplane 4.515s
ok iop/apps/edge/internal/edgecmd 0.057s
ok iop/apps/edge/internal/edgevalidate 0.015s
ok iop/apps/edge/internal/events 0.016s
ok iop/apps/edge/internal/input 0.022s
ok iop/apps/edge/internal/input/a2a 0.029s
ok iop/apps/edge/internal/node 0.013s
ok iop/apps/edge/internal/openai 1.607s
ok iop/apps/edge/internal/opsconsole 0.022s
ok iop/apps/edge/internal/service 0.522s
ok iop/apps/edge/internal/transport 2.063s
ok iop/packages/go/audit 0.013s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.186s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.026s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.027s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
```
---
@ -163,3 +195,19 @@ Sections and their ownership:
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Pass
- Completeness: Fail
- Test coverage: Fail
- API contract: Pass
- Code quality: Pass
- Implementation deviation: Fail
- Verification trust: Pass
- Spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/service/model_queue_test.go:824`의 actual `resolveProviderPoolCandidates` 경로 테스트가 provider `Priority` 값을 설정하거나 `candidateNode.priority` 전달을 assert하지 않습니다. 계획의 API-2 테스트 요구사항에는 “candidate resolution test에서 `Priority`가 `candidateNode.priority`로 전달되는지 확인”이 포함되어 있고, 현재 선택 순서 테스트들은 hand-written `candidateNode`만 사용하므로 config `nodes[].providers[].priority`가 실제 dispatch candidate까지 이어지는 증거가 빠져 있습니다. `TestResolveProviderPoolCandidatesFiltersInvalidProviders` 또는 별도 resolver 테스트에 `Priority` 값을 가진 provider fixture를 추가하고, 반환 candidate의 `priority`가 동일한 값인지 assert한 뒤 targeted/full Go 검증을 다시 기록하세요.
- 다음 단계: FAIL follow-up plan/review 파일을 작성한다.

View file

@ -0,0 +1,44 @@
# Complete - m-node-provider-first-config-surface/07_priority_routing
## 완료 일시
2026-07-01
## 요약
Node Provider-First Config Surface의 `priority-routing` task를 2회 루프로 완료했다. 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | resolver 실제 경로에서 provider `Priority`가 `candidateNode.priority`로 전달되는 증거가 없어 follow-up을 생성했다. |
| `plan_local_G03_1.log` | `code_review_local_G03_1.log` | PASS | `TestResolveProviderPoolCandidatesPropagatesPriority`가 config provider priority -> resolver candidate priority 경로를 검증하고, targeted/full Go 검증이 통과했다. |
## 구현/정리 내용
- `NodeProviderConf.Priority` config field, validation, example, refresh classification, provider-pool candidate propagation, queue selection policy를 SDD S08 기준에 맞췄다.
- provider-pool dispatch selection을 `in_flight` 오름차순 -> `priority` 오름차순 -> existing rotation으로 검증했다.
- follow-up에서 actual `resolveProviderPoolCandidates` 경로가 non-zero/default priority를 `candidateNode.priority`로 전달하는지 검증했다.
- active plan artifact의 stray `wkr` prefix를 아카이브 전 정리했다.
## 최종 검증
- `go test -count=1 -run 'TestResolveProviderPoolCandidates' ./apps/edge/internal/service` - PASS; `ok iop/apps/edge/internal/service 0.003s`.
- `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh` - PASS; config/service/configrefresh packages all passed.
- `go test -count=1 ./apps/edge/... ./packages/go/...` - PASS; edge and packages/go package suite passed.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Completed task ids:
- `priority-routing`: PASS; evidence=`plan_local_G06_0.log`, `code_review_local_G06_0.log`, `plan_local_G03_1.log`, `code_review_local_G03_1.log`; verification=`go test -count=1 -run 'TestResolveProviderPoolCandidates' ./apps/edge/internal/service`, `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`, `go test -count=1 ./apps/edge/... ./packages/go/...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,75 @@
<!-- task=m-node-provider-first-config-surface/07_priority_routing plan=1 tag=REVIEW_API -->
# Plan - REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
구현 완료 전에는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 채운다. 구현자는 아래 검증을 실행하고 active plan/review 파일은 그대로 둔 뒤 review 준비를 보고한다. 종결 처리, log rename, `complete.log` 작성, archive 이동은 code-review-skill 전용이다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 근거, 실행한 명령, 재개 조건을 기록하고 멈춘다. 구현 중 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service blocker, 일반 범위 조정, follow-up으로 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Task ids:
- `priority-routing`: Provider-pool dispatch가 dispatch 가능한 후보 중 가장 낮은 `in_flight` provider를 우선하고, `in_flight`가 같은 후보에서만 낮은 숫자의 `priority`를 선택 기준으로 사용한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-node-provider-first-config-surface/07_priority_routing/plan_local_G06_0.log`
- Archived review: `agent-task/m-node-provider-first-config-surface/07_priority_routing/code_review_local_G06_0.log`
- Verdict: FAIL
- Required summary: `apps/edge/internal/service/model_queue_test.go:824`의 actual `resolveProviderPoolCandidates` 경로 테스트가 provider `Priority` 값을 설정하거나 `candidateNode.priority` 전달을 assert하지 않아, config `nodes[].providers[].priority`가 dispatch candidate까지 이어지는 증거가 빠져 있다.
- Affected files: `apps/edge/internal/service/model_queue_test.go`
- Prior verification evidence: `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh` PASS, `go test -count=1 ./apps/edge/... ./packages/go/...` PASS.
- Roadmap carryover: SDD S08 / Milestone Task `priority-routing` completion evidence must include priority default/non-negative validation, `in_flight` selection, priority tie-break, equal priority rotation, and resolver priority propagation.
- Allowed narrow reread paths: `agent-task/m-node-provider-first-config-surface/07_priority_routing/plan_local_G06_0.log`, `agent-task/m-node-provider-first-config-surface/07_priority_routing/code_review_local_G06_0.log`.
## 배경
이 follow-up은 이전 리뷰의 Required 1건만 닫는다. 구현 코드는 이미 `resolveProviderPoolCandidates`에서 `priority: prov.Priority`를 설정하지만, 계획된 테스트 증거가 actual resolver 경로를 통하지 않고 hand-written `candidateNode`에 머물러 있다. SDD S08 PASS evidence로 쓰려면 config provider priority가 dispatch candidate까지 전달되는 테스트가 필요하다.
## 범위 결정 근거
- 포함: `apps/edge/internal/service/model_queue_test.go`의 resolver 테스트 보강.
- 제외: production code 변경, config schema/contract/refresh classification 변경, full-cycle runtime smoke. 이전 리뷰에서 production behavior와 Go regression은 PASS했고, 남은 문제는 누락된 planned test evidence다.
- 라우팅: `local-G03`. 범위가 단일 테스트 파일의 deterministic assertion 보강이며 local Go test로 검증 가능하다.
## 구현 체크리스트
- [ ] `resolveProviderPoolCandidates` actual 경로를 호출하는 service/model queue 테스트에 provider `Priority` fixture를 추가하고 반환된 `candidateNode.priority` 값을 assert한다.
- [ ] `go test -count=1 -run 'TestResolveProviderPoolCandidates' ./apps/edge/internal/service`와 `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`를 실행한다.
- [ ] `go test -count=1 ./apps/edge/... ./packages/go/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_API-1] Resolver Priority Propagation Test
문제:
- 이전 구현은 `apps/edge/internal/service/run_dispatch.go`에서 `priority: prov.Priority`를 설정하지만, actual resolver 테스트가 이 연결을 검증하지 않는다.
- `apps/edge/internal/service/model_queue_test.go`의 priority selection tests는 직접 만든 `candidateNode`를 사용하므로 config provider field -> resolver candidate field 연결 증거가 아니다.
해결 방법:
- `TestResolveProviderPoolCandidatesFiltersInvalidProviders` 또는 별도 focused test에서 `config.NodeProviderConf{Priority: <non-zero>}`를 가진 provider를 fixture로 둔다.
- `svc.resolveProviderPoolCandidates(...)` 결과에서 해당 provider의 `candidateNode.priority`가 fixture 값과 같은지 assert한다.
- 가능하면 explicit adapter provider와 provider-first adapter omitted provider 중 최소 하나에 대해 non-zero priority를 확인한다. 하나만 택한다면 기존 valid provider fixture에 붙여도 충분하다.
권장 예:
```go
Priority: 7,
...
if avail.priority != 7 {
t.Errorf("prov-available priority: got %d, want 7", avail.priority)
}
```
테스트 작성:
- 파일: `apps/edge/internal/service/model_queue_test.go`
- 중간 검증: `go test -count=1 -run 'TestResolveProviderPoolCandidates' ./apps/edge/internal/service`
- 최종 검증:
- `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`
- `go test -count=1 ./apps/edge/... ./packages/go/...`

View file

@ -102,11 +102,11 @@
## 구현 체크리스트
- [ ] `NodeProviderConf.Priority`를 config schema, contract, `configs/edge.yaml` 예시에 추가하고 기본값 0/음수 거부를 `packages/go/config` tests로 검증한다.
- [ ] provider-pool queue candidate에 priority를 전달하고 선택 순서를 `in_flight` 오름차순 -> `priority` 오름차순 -> 기존 rotation으로 바꾸며 service/model queue tests를 갱신한다.
- [ ] config refresh classification에서 provider priority-only 변경을 live `applied`로 분류하고 classification tests를 추가한다.
- [ ] `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`와 `go test -count=1 ./apps/edge/... ./packages/go/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `NodeProviderConf.Priority`를 config schema, contract, `configs/edge.yaml` 예시에 추가하고 기본값 0/음수 거부를 `packages/go/config` tests로 검증한다.
- [x] provider-pool queue candidate에 priority를 전달하고 선택 순서를 `in_flight` 오름차순 -> `priority` 오름차순 -> 기존 rotation으로 바꾸며 service/model queue tests를 갱신한다.
- [x] config refresh classification에서 provider priority-only 변경을 live `applied`로 분류하고 classification tests를 추가한다.
- [x] `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`와 `go test -count=1 ./apps/edge/... ./packages/go/...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] Config Schema And Contract
@ -163,10 +163,10 @@ if p.Priority < 0 {
수정 파일 및 체크리스트:
- [ ] `packages/go/config/config.go`: `NodeProviderConf.Priority`와 validation 추가.
- [ ] `packages/go/config/config_test.go`: provider-first happy path에서 priority unmarshal을 확인하고, omitted priority 기본값 0과 negative rejection test를 추가한다.
- [ ] `configs/edge.yaml`: provider-pool comment와 example에 `priority` 의미를 추가한다.
- [ ] `agent-contract/inner/edge-config-runtime-refresh.md`: priority rule과 live apply classification을 추가한다.
- [x] `packages/go/config/config.go`: `NodeProviderConf.Priority`와 validation 추가.
- [x] `packages/go/config/config_test.go`: provider-first happy path에서 priority unmarshal을 확인하고, omitted priority 기본값 0과 negative rejection test를 추가한다.
- [x] `configs/edge.yaml`: provider-pool comment와 example에 `priority` 의미를 추가한다.
- [x] `agent-contract/inner/edge-config-runtime-refresh.md`: priority rule과 live apply classification을 추가한다.
테스트 작성:
@ -255,9 +255,9 @@ case inflight == bestInflight:
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/service/model_queue.go`: `candidateNode.priority`, selector comparison, comment 갱신, unused ratio helpers 제거.
- [ ] `apps/edge/internal/service/run_dispatch.go`: provider-pool candidate에 `prov.Priority` 전달.
- [ ] `apps/edge/internal/service/model_queue_test.go`: ratio/proportional tests를 priority policy tests로 교체하고 existing equal-priority rotation을 보존한다.
- [x] `apps/edge/internal/service/model_queue.go`: `candidateNode.priority`, selector comparison, comment 갱신, unused ratio helpers 제거.
- [x] `apps/edge/internal/service/run_dispatch.go`: provider-pool candidate에 `prov.Priority` 전달.
- [x] `apps/edge/internal/service/model_queue_test.go`: ratio/proportional tests를 priority policy tests로 교체하고 existing equal-priority rotation을 보존한다.
테스트 작성:
@ -331,9 +331,9 @@ if cur.Priority != cp.Priority {
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/configrefresh/classify.go`: priority diff를 `StatusApplied`로 분류.
- [ ] `apps/edge/internal/configrefresh/classify_test.go`: priority-only diff가 applied이고 restart_required가 없음을 검증.
- [ ] `agent-contract/inner/edge-config-runtime-refresh.md`: API-1 contract update와 classification wording 일치 확인.
- [x] `apps/edge/internal/configrefresh/classify.go`: priority diff를 `StatusApplied`로 분류.
- [x] `apps/edge/internal/configrefresh/classify_test.go`: priority-only diff가 applied이고 restart_required가 없음을 검증.
- [x] `agent-contract/inner/edge-config-runtime-refresh.md`: API-1 contract update와 classification wording 일치 확인.
테스트 작성:

View file

@ -87,6 +87,7 @@ type providerKey struct {
Models []string
Health string
Capacity int
Priority int
MaxQueue int
QueueTimeoutMS int
LifecycleCapabilities []string
@ -123,6 +124,7 @@ func buildProviderIndex(cfg *config.EdgeConfig) map[string]providerKey {
Models: append([]string(nil), p.Models...),
Health: p.Health,
Capacity: p.Capacity,
Priority: p.Priority,
MaxQueue: p.MaxQueue,
QueueTimeoutMS: p.QueueTimeoutMS,
LifecycleCapabilities: append([]string(nil), p.LifecycleCapabilities...),
@ -321,6 +323,14 @@ func Classify(current, candidate *config.EdgeConfig) Result {
Next: fmt.Sprintf("%d", cp.Capacity),
})
}
if cur.Priority != cp.Priority {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q].priority", provID),
Class: StatusApplied,
Previous: fmt.Sprintf("%d", cur.Priority),
Next: fmt.Sprintf("%d", cp.Priority),
})
}
if cur.MaxQueue != cp.MaxQueue {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q].max_queue", provID),

View file

@ -998,8 +998,6 @@ nodes:
}
}
// TestClassifyProviderFirstExecutionFieldRestartRequired verifies that changing
// provider-first execution fields (endpoint, type) requires a restart.
func TestClassifyProviderFirstExecutionFieldRestartRequired(t *testing.T) {
dir := t.TempDir()
currentYAML := `
@ -1066,3 +1064,81 @@ nodes:
t.Errorf("expected restart_required for providers[].type change; changes: %+v", result.Changes)
}
}
// TestClassifyProviderPriorityApplied verifies that provider priority change
// is classified as StatusApplied (live apply, no restart required).
func TestClassifyProviderPriorityApplied(t *testing.T) {
dir := t.TempDir()
currentYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
priority: 5
`
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
// Candidate only changes priority.
candidateYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
priority: 1
`
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
current := buildNormalizedCurrent(t, currentPath)
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-priority-applied",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusApplied {
t.Fatalf("expected status=applied for priority-only change, got %q (summary: %s)", result.Status, result.Summary)
}
foundPriority := false
for _, c := range result.Changes {
if c.Path == `nodes[].providers["prov-a"].priority` {
foundPriority = true
if c.Class != configrefresh.StatusApplied {
t.Errorf("providers[].priority class: got %q, want applied", c.Class)
}
if c.Previous != "5" || c.Next != "1" {
t.Errorf("providers[].priority change: prev=%q next=%q", c.Previous, c.Next)
}
}
if c.Class == configrefresh.StatusRestartRequired {
t.Errorf("unexpected restart_required change for priority-only diff: %s", c.Path)
}
}
if !foundPriority {
t.Errorf("providers[].priority change path not found in: %+v", result.Changes)
}
}

View file

@ -29,10 +29,12 @@ var (
// from the node's adapter config (or Runtime.Concurrency as fallback).
// For provider-pool candidates, providerID, adapter, and servedTarget carry
// the globally-unique provider id, the dispatch adapter key, and the concrete
// model name to dispatch.
// model name to dispatch. Priority is the provider's dispatch priority (lower
// is preferred) and is used as a tie-break when in_flight counts are equal.
type candidateNode struct {
entry *edgenode.NodeEntry
capacity int
priority int
providerID string // non-empty for provider-pool candidates
adapter string // non-empty for provider-pool candidates; dispatch adapter key
servedTarget string // concrete served model name; used for target rewrite
@ -180,14 +182,14 @@ func (m *modelQueueManager) updateGroupPolicyLocked(g *modelQueueGroup, policy g
}
// findAvailableNodeLocked returns the available candidate with the lowest
// in_flight/capacity load ratio. Equal-ratio candidates rotate from the last
// selected slot so idle or equally loaded providers do not pin to one provider.
// Uses provider-aware slot keys so that multiple providers on the same node
// are tracked independently.
// absolute in_flight count. When in_flight is equal, the candidate with the
// lower priority value wins. When both in_flight and priority are equal,
// all tied candidates remain in best[] so that the rotation logic in the
// caller picks the next slot after the last selected one.
func (m *modelQueueManager) findAvailableNodeLocked(group *modelQueueGroup, candidates []candidateNode) *candidateNode {
var best []candidateNode
bestInflight := 0
bestCapacity := 1
bestPriority := 0
for i := range candidates {
c := candidates[i]
if c.capacity <= 0 {
@ -199,12 +201,17 @@ func (m *modelQueueManager) findAvailableNodeLocked(group *modelQueueGroup, cand
continue
}
switch {
case len(best) == 0 || loadRatioLess(inflight, c.capacity, bestInflight, bestCapacity):
case len(best) == 0 || inflight < bestInflight:
best = []candidateNode{c}
bestInflight = inflight
bestCapacity = c.capacity
case loadRatioEqual(inflight, c.capacity, bestInflight, bestCapacity):
best = append(best, c)
bestPriority = c.priority
case inflight == bestInflight:
if c.priority < bestPriority {
best = []candidateNode{c}
bestPriority = c.priority
} else if c.priority == bestPriority {
best = append(best, c)
}
}
}
if len(best) == 0 {
@ -225,15 +232,7 @@ func (m *modelQueueManager) findAvailableNodeLocked(group *modelQueueGroup, cand
return &best[idx]
}
func loadRatioLess(leftInflight, leftCapacity, rightInflight, rightCapacity int) bool {
return leftInflight*rightCapacity < rightInflight*leftCapacity
}
func loadRatioEqual(leftInflight, leftCapacity, rightInflight, rightCapacity int) bool {
return leftInflight*rightCapacity == rightInflight*leftCapacity
}
// candidateLess provides a deterministic ordering for equal-ratio rotation:
// candidateLess provides a deterministic ordering for equal-inflight/priority rotation:
// providerID first, then nodeID.
func candidateLess(a, b *candidateNode) bool {
if b == nil {

View file

@ -496,86 +496,109 @@ func TestModelQueueUsesProviderQueuePolicy(t *testing.T) {
})
}
// TestModelQueueProviderLoadRatioSelection verifies that admit selects the
// candidate with the lowest in_flight/capacity ratio, not simply the first one.
func TestModelQueueProviderLoadRatioSelection(t *testing.T) {
entryA := &edgenode.NodeEntry{NodeID: "node-lr-a"}
entryB := &edgenode.NodeEntry{NodeID: "node-lr-b"}
// A: capacity=4, B: capacity=2.
// TestModelQueueProviderInflightSelectionBeatsPriority verifies that in_flight
// count is the primary selection criterion: a candidate with lower in_flight
// is preferred even if it has a higher (worse) priority value.
func TestModelQueueProviderInflightSelectionBeatsPriority(t *testing.T) {
entryA := &edgenode.NodeEntry{NodeID: "node-ip-a"}
entryB := &edgenode.NodeEntry{NodeID: "node-ip-b"}
// A has priority=1 (better) but inflight=3.
// B has priority=10 (worse) but inflight=0.
cands := []candidateNode{
{entry: entryA, capacity: 4, providerID: "prov-a"},
{entry: entryB, capacity: 2, providerID: "prov-b"},
{entry: entryA, capacity: 5, priority: 1, providerID: "prov-a"},
{entry: entryB, capacity: 5, priority: 10, providerID: "prov-b"},
}
m := newModelQueueManager(nil)
// Put A at inflight=2 (ratio=0.5) and B at inflight=0 (ratio=0.0).
// Uses provider-aware slot keys per REVIEW_API-2.
// Put A at inflight=3, B at inflight=0.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-lr", groupPolicy{})
g.inflight["node-lr-a:prov-a"] = 2
g := m.getOrCreateGroupLocked("g-ip", groupPolicy{})
g.inflight["node-ip-a:prov-a"] = 3
m.mu.Unlock()
// Admit should pick B (ratio=0.0 < 0.5).
sel, err := m.admit(context.Background(), "g-lr", "", "", cands, groupPolicy{})
// Admit should pick B (inflight=0 < inflight=3), not A.
sel, err := m.admit(context.Background(), "g-ip", "", "", cands, groupPolicy{})
if err != nil {
t.Fatalf("admit: %v", err)
}
if sel == nil || sel.entry.NodeID != "node-lr-b" {
t.Fatalf("expected node-lr-b (lower ratio), got %v", sel)
}
// B is now at inflight=1 (ratio=0.5). A is still at inflight=2 (ratio=0.5).
// Equal-ratio rotation advances after the last selected slot (B), so A wins.
sel2, err := m.admit(context.Background(), "g-lr", "", "", cands, groupPolicy{})
if err != nil {
t.Fatalf("admit2: %v", err)
}
if sel2 == nil || sel2.entry.NodeID != "node-lr-a" {
t.Fatalf("expected node-lr-a (tie-break by providerID), got %v", sel2)
if sel == nil || sel.entry.NodeID != "node-ip-b" {
t.Fatalf("expected node-ip-b (lower in_flight), got %v", sel)
}
}
func TestModelQueueProviderLoadRatioFillsProportionally(t *testing.T) {
gx10 := &edgenode.NodeEntry{NodeID: "node-gx10"}
onex := &edgenode.NodeEntry{NodeID: "node-onex"}
// TestModelQueueProviderPriorityBreaksEqualInflightTie verifies that when
// in_flight counts are equal, the candidate with the lower priority value
// is selected.
func TestModelQueueProviderPriorityBreaksEqualInflightTie(t *testing.T) {
entryA := &edgenode.NodeEntry{NodeID: "node-pt-a"}
entryB := &edgenode.NodeEntry{NodeID: "node-pt-b"}
// Both have inflight=2, but A has priority=1 (better) than B (priority=5).
cands := []candidateNode{
{entry: gx10, capacity: 4, providerID: "gx10-vllm"},
{entry: onex, capacity: 3, providerID: "onexplayer-lemonade"},
{entry: entryA, capacity: 5, priority: 1, providerID: "prov-a"},
{entry: entryB, capacity: 5, priority: 5, providerID: "prov-b"},
}
m := newModelQueueManager(nil)
var got []string
for i := 0; i < 7; i++ {
sel, err := m.admit(context.Background(), "g-proportional", "", "", cands, groupPolicy{})
if err != nil {
t.Fatalf("admit %d: %v", i+1, err)
}
got = append(got, sel.providerID)
}
want := []string{
"gx10-vllm",
"onexplayer-lemonade",
"gx10-vllm",
"onexplayer-lemonade",
"gx10-vllm",
"onexplayer-lemonade",
"gx10-vllm",
}
if len(got) != len(want) {
t.Fatalf("dispatch count: got %d want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("dispatch[%d]: got %q want %q; full sequence=%v", i, got[i], want[i], got)
}
}
m.mu.Lock()
defer m.mu.Unlock()
g := m.groups["g-proportional"]
if g.inflight["node-gx10:gx10-vllm"] != 4 || g.inflight["node-onex:onexplayer-lemonade"] != 3 {
t.Fatalf("inflight: got gx10=%d onex=%d, want 4/3", g.inflight["node-gx10:gx10-vllm"], g.inflight["node-onex:onexplayer-lemonade"])
g := m.getOrCreateGroupLocked("g-pt", groupPolicy{})
g.inflight["node-pt-a:prov-a"] = 2
g.inflight["node-pt-b:prov-b"] = 2
m.mu.Unlock()
// Admit should pick A (lower priority=1 when inflight is equal).
sel, err := m.admit(context.Background(), "g-pt", "", "", cands, groupPolicy{})
if err != nil {
t.Fatalf("admit: %v", err)
}
if sel == nil || sel.entry.NodeID != "node-pt-a" {
t.Fatalf("expected node-pt-a (lower priority tie-break), got %v", sel)
}
}
// TestModelQueueProviderEqualInflightPriorityRotates verifies that when both
// in_flight and priority are equal, the rotation logic selects the next slot
// after the last selected one.
func TestModelQueueProviderEqualInflightPriorityRotates(t *testing.T) {
entryA := &edgenode.NodeEntry{NodeID: "node-r-a"}
entryB := &edgenode.NodeEntry{NodeID: "node-r-b"}
// Both have inflight=0 and priority=0.
cands := []candidateNode{
{entry: entryA, capacity: 5, priority: 0, providerID: "prov-a"},
{entry: entryB, capacity: 5, priority: 0, providerID: "prov-b"},
}
m := newModelQueueManager(nil)
// First admit: A wins deterministically.
sel1, err := m.admit(context.Background(), "g-r", "", "", cands, groupPolicy{})
if err != nil || sel1 == nil {
t.Fatalf("first admit: %v", err)
}
if sel1.providerID != "prov-a" {
t.Fatalf("expected prov-a on first admit, got %q", sel1.providerID)
}
// Release prov-a's slot.
m.releaseSlot("g-r", "node-r-a", "prov-a")
// Second admit: rotation should pick B (next after last selected A).
sel2, err := m.admit(context.Background(), "g-r", "", "", cands, groupPolicy{})
if err != nil || sel2 == nil {
t.Fatalf("second admit: %v", err)
}
if sel2.providerID != "prov-b" {
t.Fatalf("expected prov-b on second admit (rotation), got %q", sel2.providerID)
}
// Release prov-b's slot.
m.releaseSlot("g-r", "node-r-b", "prov-b")
// Third admit: rotation should cycle back to A.
sel3, err := m.admit(context.Background(), "g-r", "", "", cands, groupPolicy{})
if err != nil || sel3 == nil {
t.Fatalf("third admit: %v", err)
}
if sel3.providerID != "prov-a" {
t.Fatalf("expected prov-a on third admit (rotation cycle), got %q", sel3.providerID)
}
}
@ -1468,6 +1491,115 @@ func TestStatusProviderProviderFirstNoAdapterDuplicates(t *testing.T) {
}
}
// TestResolveProviderPoolCandidatesPropagatesPriority verifies that the actual
// resolveProviderPoolCandidates path propagates config provider Priority into
// candidateNode.priority. This is the missing evidence from G06: the previous
// tests used hand-written candidateNode structs and never exercised the
// config -> resolver -> candidate.priority chain.
func TestResolveProviderPoolCandidatesPropagatesPriority(t *testing.T) {
catalog := []config.ModelCatalogEntry{
{
ID: "model-prio",
Providers: map[string]string{
"prov-high-prio": "served-model-prio",
"prov-low-prio": "served-model-prio",
"prov-no-priority": "served-model-prio",
},
},
}
store := edgenode.NewNodeStore()
// High-priority provider (priority=3).
store.Add(&edgenode.NodeRecord{
ID: "node-prio",
Runtime: config.RuntimeConf{Concurrency: 4},
Adapters: config.AdaptersConf{
VllmInstances: []config.VllmInstanceConf{
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
},
},
Providers: []config.NodeProviderConf{
{
ID: "prov-high-prio",
Adapter: "vllm-gpu",
Models: []string{"served-model-prio"},
Health: "available",
Capacity: 2,
Priority: 3,
},
{
ID: "prov-low-prio",
Adapter: "vllm-gpu",
Models: []string{"served-model-prio"},
Health: "available",
Capacity: 2,
Priority: 10,
},
{
ID: "prov-no-priority",
Adapter: "vllm-gpu",
Models: []string{"served-model-prio"},
Health: "available",
Capacity: 2,
// Priority omitted → defaults to 0.
},
},
})
reg := edgenode.NewRegistry()
for _, rec := range store.All() {
reg.Register(&edgenode.NodeEntry{NodeID: rec.ID, LifecycleState: edgenode.LifecycleConnected})
}
svc := New(reg, nil)
svc.SetNodeStore(store)
svc.SetModelCatalog(catalog)
storeSnap, catalogSnap := svc.runtimeConfigSnapshot()
candidates, _, err := svc.resolveProviderPoolCandidates(
SubmitRunRequest{ModelGroupKey: "model-prio", ProviderPool: true},
storeSnap, catalogSnap,
)
if err != nil {
t.Fatalf("resolveProviderPoolCandidates: %v", err)
}
if len(candidates) != 3 {
t.Fatalf("expected 3 candidates, got %d", len(candidates))
}
byID := map[string]*candidateNode{}
for i := range candidates {
byID[candidates[i].providerID] = &candidates[i]
}
// prov-high-prio: fixture Priority=3 → candidate.priority must be 3.
hp := byID["prov-high-prio"]
if hp == nil {
t.Fatal("prov-high-prio not in candidates")
}
if hp.priority != 3 {
t.Errorf("prov-high-prio: got priority %d, want 3", hp.priority)
}
// prov-low-prio: fixture Priority=10 → candidate.priority must be 10.
lp := byID["prov-low-prio"]
if lp == nil {
t.Fatal("prov-low-prio not in candidates")
}
if lp.priority != 10 {
t.Errorf("prov-low-prio: got priority %d, want 10", lp.priority)
}
// prov-no-priority: Priority omitted → defaults to 0.
np := byID["prov-no-priority"]
if np == nil {
t.Fatal("prov-no-priority not in candidates")
}
if np.priority != 0 {
t.Errorf("prov-no-priority: got priority %d, want 0", np.priority)
}
}
// TestGetSnapshotForNodeLegacyAdapterFallback verifies that a node with no
// providers[] catalog returns adapter snapshots (CLI, OllamaInstances, etc.)
// via the legacy adapter fallback path.

View file

@ -610,6 +610,7 @@ func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest, store *edg
candidates = append(candidates, candidateNode{
entry: entry,
capacity: cap,
priority: prov.Priority,
providerID: prov.ID,
adapter: adapterKey,
servedTarget: servedModel,

View file

@ -227,6 +227,7 @@ nodes:
# capacity: 4
# max_queue: 16
# queue_timeout_ms: 30000
# priority: 1
# request_timeout_ms: 120000
# lifecycle_capabilities: ["scale_up", "scale_down"]
# # enabled: false # exclude from dispatch pool (live-apply)
@ -240,6 +241,7 @@ nodes:
# capacity: 2
# max_queue: 8
# queue_timeout_ms: 30000
# priority: 2
#
# === Legacy adapters (compat override example — discouraged for new deploys) ===
# Only use when explicit adapter instance override or compatibility before compilation is required.

View file

@ -151,6 +151,10 @@ type NodeProviderConf struct {
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue,omitempty"`
// QueueTimeoutMS is the queue timeout in milliseconds.
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms,omitempty"`
// Priority is the provider dispatch priority. Lower values are preferred
// when in_flight counts are equal. Must be non-negative; zero is the
// default priority.
Priority int `mapstructure:"priority" yaml:"priority,omitempty"`
// LifecycleCapabilities lists coarse lifecycle capabilities.
LifecycleCapabilities []string `mapstructure:"lifecycle_capabilities" yaml:"lifecycle_capabilities,omitempty"`
// Enabled controls whether this provider participates in the dispatch pool.
@ -204,6 +208,9 @@ func (p NodeProviderConf) Validate() error {
if p.ContextSize < 0 {
return fmt.Errorf("nodes[].providers[%q].context_size must be non-negative", id)
}
if p.Priority < 0 {
return fmt.Errorf("nodes[].providers[%q].priority must be non-negative", id)
}
return nil
}

View file

@ -2871,3 +2871,110 @@ nodes:
t.Fatalf("expected error containing 'conflicts with adapters' and 'capacity mismatch', got %v", err)
}
}
// TestLoadEdge_NodeProviderPriorityOmittedDefaultsZero verifies that when
// priority is omitted from a provider entry, it defaults to zero.
func TestLoadEdge_NodeProviderPriorityOmittedDefaultsZero(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-p"
alias: "n-p"
token: "tok-p"
providers:
- id: "prov-p"
type: "vllm"
category: "api"
endpoint: "http://127.0.0.1:8000/v1"
models:
- "model-a"
capacity: 2
`
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cfg.Nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(cfg.Nodes))
}
if len(cfg.Nodes[0].Providers) != 1 {
t.Fatalf("expected 1 provider, got %d", len(cfg.Nodes[0].Providers))
}
if cfg.Nodes[0].Providers[0].Priority != 0 {
t.Fatalf("expected default priority=0, got %d", cfg.Nodes[0].Providers[0].Priority)
}
}
// TestLoadEdge_NodeProviderPriorityExplicitLoads verifies that an explicit
// priority value is loaded correctly.
func TestLoadEdge_NodeProviderPriorityExplicitLoads(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-p2"
alias: "n-p2"
token: "tok-p2"
providers:
- id: "prov-p2"
type: "vllm"
category: "api"
endpoint: "http://127.0.0.1:8000/v1"
models:
- "model-a"
capacity: 2
priority: 5
`
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Nodes[0].Providers[0].Priority != 5 {
t.Fatalf("expected priority=5, got %d", cfg.Nodes[0].Providers[0].Priority)
}
}
// TestLoadEdge_NodeProviderNegativePriorityRejected verifies that a negative
// priority value is rejected during config load.
func TestLoadEdge_NodeProviderNegativePriorityRejected(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-p3"
alias: "n-p3"
token: "tok-p3"
providers:
- id: "prov-p3"
type: "vllm"
category: "api"
endpoint: "http://127.0.0.1:8000/v1"
models:
- "model-a"
capacity: 2
priority: -1
`
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
_, err := config.LoadEdge(f)
if err == nil {
t.Fatal("expected error for negative priority")
}
if !strings.Contains(err.Error(), "priority must be non-negative") {
t.Fatalf("expected error mentioning priority non-negative, got %v", err)
}
}