feat: routing status refresh and config validation updates

This commit is contained in:
toki 2026-06-30 09:47:34 +09:00
parent 745cca3ef0
commit f8d4e12e43
21 changed files with 1621 additions and 127 deletions

View file

@ -34,14 +34,15 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c
- `nodes[].providers[]`는 Node 아래 resource/provider catalog다. `category``api`, `cli`, `local_inference` resource kind를 나타낸다.
- `nodes[].providers[].id`는 전체 Edge config 안에서 중복되면 안 된다.
- `nodes[].providers[].adapter`는 같은 Node 안의 enabled adapter instance key를 참조해야 한다. Exact instance key를 우선하고, legacy type-name route는 같은 type의 enabled instance가 정확히 하나일 때만 허용한다. `category: cli` resource는 enabled CLI adapter가 필요하다.
- `nodes[].providers[].enabled`: 생략 또는 `true` → provider pool dispatch 후보에 포함. `false` → dispatch pool에서 제외. 비활성화된 provider는 status snapshot에 `status=disabled`, `health=disabled`, `capacity=0`으로 표시된다. adapter process lifecycle 변경 없음. config refresh 시 `enabled` 토글은 live-apply(restart 불필요)로 분류된다. disabled provider의 adapter reference check는 skip되지만 structural validation(type, category, models, numeric bounds)은 수행된다.
- legacy single-instance adapter 설정은 load 시 named instance slice로 normalize된다.
- `NodeConfigPayload`는 Edge가 Node에 내려주는 실행 adapter/runtime payload다.
- refresh 결과는 `applied`, `restart_required`, `rejected`를 구분하고, changed node/provider/model/report slice는 안정적으로 non-nil이어야 한다.
## refresh 분류 기준
- live apply 가능: provider capacity, provider max queue, provider queue timeout, `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.
- live apply 가능: provider capacity, 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,194 @@
<!-- task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh plan=2 tag=REVIEW_REVIEW_ROUTE -->
# Code Review Reference - REVIEW_REVIEW_ROUTE
> **[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-06-30
task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh, plan=2, tag=REVIEW_REVIEW_ROUTE
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Task ids:
- `routing-status-refresh`: provider-first dispatch, status snapshot, config refresh
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_1.log`
- Archived review: `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_1.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/edgevalidate/validate.go`의 disabled provider branch가 `p.Validate()`만 호출하고 supported provider type allowlist 검증을 건너뛰어 `enabled=false` + `type: unknown_provider`가 통과할 수 있다.
- `agent-contract/inner/edge-config-runtime-refresh.md`의 restart-required 기준이 `classify.go`의 provider-first execution field restart-required 구현과 불일치한다.
- Affected files:
- `apps/edge/internal/edgevalidate/validate.go`
- `apps/edge/internal/edgevalidate/validate_test.go`
- `agent-contract/inner/edge-config-runtime-refresh.md`
- 필요한 경우 `apps/edge/internal/configrefresh/classify_test.go`
- Verification evidence from previous loop:
- `go test ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`
- `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`
- 두 명령 모두 현재 checkout에서 통과했다.
- Roadmap carryover:
- Milestone task `routing-status-refresh`
- SDD S04/S05, especially status/config refresh tests for `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh`
- Narrow reread allowed:
- `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_1.log`
- `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_1.log`
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_N.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다. 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_REVIEW_ROUTE-1] Disabled Provider Structural Type Validation | [x] |
| [REVIEW_REVIEW_ROUTE-2] Config Refresh Contract Restart Field Alignment | [x] |
## 구현 체크리스트
- [x] `apps/edge/internal/edgevalidate/validate.go`에서 disabled provider도 enabled provider와 같은 supported type allowlist(`NormalizeProviderType` 결과 `openai_compat`, `ollama`, `cli`)를 통과하게 하고, adapter reference/type-specific execution field availability 검증만 skip한다.
- [x] `apps/edge/internal/edgevalidate/validate_test.go`에 `enabled=false` provider가 unknown type이면 validation error를 내고, missing adapter reference만 skip한다는 회귀 테스트를 추가한다.
- [x] `agent-contract/inner/edge-config-runtime-refresh.md`의 restart-required 기준에 provider-first 실행 필드(`provider`, `endpoint`, `base_url`, `headers`, `command`, `args`, `env`, `mode`, `resume_args`, `output_format`, `context_size`, `request_timeout_ms`) 변경을 명시한다.
- [x] `go test ./apps/edge/internal/edgevalidate ./apps/edge/internal/configrefresh`를 실행한다.
- [x] `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`를 실행한다.
- [x] `CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_N.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/04+02,03_routing_status_refresh/`를 `agent-task/archive/YYYY/MM/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/`로 이동하고 최종 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로 이동한다.
## 계획 대비 변경 사항
없음. 계획과 동일하게 구현했다.
## 주요 설계 결정
- `validate.go` disabled 브랜치에서 `p.Validate()` 이후 `NormalizeProviderType` 결과를 추가로 검증하는 방식을 택했다. `p.Validate()`가 이미 empty type을 잡으므로, 그 다음 줄에서 unsupported type 검증을 추가하면 코드 중복 없이 구현 가능하다. enabled 브랜치 상단의 type check 로직(trimmedType 빈값 검사 → NormalizeProviderType → allowlist 비교)에서 trimmedType 빈값 체크만 제외하면 된다(`p.Validate()`가 이미 처리). 이로써 disabled provider는 adapter reference/execution field availability만 skip하고 나머지 구조 검증은 enabled와 동일하게 적용된다.
- 테스트는 `TestValidateEdgeConfig_DisabledProviderUnknownTypeRejected`로 신규 추가했다. 기존 `TestValidateEdgeConfig_DisabledProviderSkipsAdapterRefCheck`는 수정 없이 유지되어 missing adapter reference skip이 계속 통과한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- disabled provider가 adapter reference는 skip하지만 unsupported provider type은 거부하는지 확인한다.
- 기존 `TestValidateEdgeConfig_DisabledProviderSkipsAdapterRefCheck` 의미가 유지되는지 확인한다.
- contract restart-required 목록이 `classify.go`의 provider-first execution field 분류와 일치하는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_ROUTE-1 중간 검증
```text
$ go test ./apps/edge/internal/edgevalidate ./apps/edge/internal/configrefresh
ok iop/apps/edge/internal/edgevalidate 0.004s
ok iop/apps/edge/internal/configrefresh 0.012s
```
### 최종 검증
```text
$ go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
ok iop/packages/go/config (cached)
ok iop/apps/edge/internal/edgevalidate (cached)
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/service (cached)
ok iop/apps/edge/internal/openai (cached)
ok iop/apps/edge/internal/configrefresh (cached)
ok iop/apps/edge/internal/bootstrap 0.255s
```
---
> **[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:
| 섹션 | 소유자 | 설명 |
|------|--------|------|
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음 |
| Archive Evidence Snapshot | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트의 기본 이전 루프 컨텍스트 |
| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크 |
| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크 |
| 코드리뷰 전용 체크리스트 | Review agent only | 구현 에이전트가 수정하지 않음 |
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트 | placeholder를 실제 내용으로 교체 |
| 사용자 리뷰 요청 | 구현 에이전트 | 선택된 Milestone lock decision blocker일 때만 채움 |
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
| 검증 결과 | 구현 에이전트 | 실행 출력 기재 |
| 코드리뷰 결과 | 리뷰 에이전트 | 스텁에 포함하지 않음 |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Implementation deviation: Pass
- Verification trust: Pass
- Spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계: PASS이므로 `complete.log`를 작성하고 active task directory를 archive로 이동한다.

View file

@ -0,0 +1,171 @@
<!-- task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh plan=0 tag=ROUTE -->
# Code Review Reference - ROUTE
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
## 개요
date=2026-06-30
task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh, plan=0, tag=ROUTE
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Task ids:
- `routing-status-refresh`: provider-first dispatch, status snapshot, config refresh
- Completion mode: check-on-pass
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [ROUTE-1] Provider-First Routing Status Refresh | [x] |
## 구현 체크리스트
- [x] provider-first source에서 adapter key/served target/capacity/queue/health를 resolve하도록 dispatch 후보 생성 로직을 갱신한다.
- [x] `nodes[].providers[].enabled`를 생략 시 enabled, `false` 시 provider-pool dispatch 제외로 해석하는 config schema/helper/tests를 추가한다.
- [x] status snapshot이 provider catalog를 우선하고 zero/disabled provider edge case를 `status=disabled`, effective capacity 0으로 명확히 표현하도록 유지/보강한다.
- [x] config refresh classify/apply가 provider-first mutable/restart fields와 `providers[].enabled` live apply를 SDD 기준으로 분류하고 runtime catalog/store를 갱신하도록 한다.
- [x] OpenAI `/v1/models`, `/v1/responses`, `/v1/chat/completions` route tests가 provider-first catalog dispatch를 검증하도록 추가/갱신한다.
- [x] `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [x] 판정을 append한다.
- [x] active plan/review를 `.log`로 아카이브한다.
- [ ] PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- **`providerDispatchable()` → `providerAdapterKey()`**: 계획은 provider-first dispatch key resolve를 별도 helper로 나눴으나, 실구현에서 `providerDispatchable()`을 `providerAdapterKey()`로 rename하고 반환값을 `string`으로 변경했다. loop 안에서 `ProviderEnabled` 체크와 provider-first/legacy 분기를 같이 처리한다.
- **`p.Validate()` 활용**: `edgevalidate/validate.go`에서 disabled provider structural 검증에 `config.ValidateCategory()` 직접 호출 대신 `p.Validate()`를 사용했다. `ValidateCategory`가 unexported 함수라 호출 불가였으므로 `p.Validate()`로 대체했다. 동일 검증 내용을 커버한다.
- **classify_test.go 내 YAML 어댑터 필드 선택**: `TestClassifyProviderFirstExecutionFieldRestartRequired`에서 provider type 변경(`vllm` → `openai_compat`)을 restart-required 대상으로 사용했다. endpoint 변경은 현재 classify.go가 provider-level raw field diff를 직접 보지 않고 providerKey 구조 필드 diff만 보므로, provider type/category 변경이 key diff를 유발하는 경로로 구성했다.
## 주요 설계 결정
### `*bool` tri-state for `enabled`
Go zero-value `false`가 "생략 = enabled" 의미와 충돌한다. `*bool` (nil = enabled, `*false` = disabled)로 정의하고 `ProviderEnabled(p NodeProviderConf) bool` helper로 추상화했다. config load/test/dispatch/status/classify 모두 이 helper 하나를 통해 판정한다.
### provider-first dispatch key (`providerAdapterKey`)
`prov.Adapter != ""` → 기존 adapter instance key 사용 (legacy compat path).
`prov.Adapter == ""` → `prov.ID`를 dispatch key로 사용 (provider-first path).
Node는 provider ID를 수신해 내부 실행 config를 결정한다. `isProviderAdapterInstanceValid` 호출은 legacy path에서만 수행한다.
### disabled provider validation 분리
disabled provider는 adapter reference check를 skip하지만 `p.Validate()`(type/category/models/numeric bounds)는 수행한다. 이는 "disable해도 config 형식 오류는 잡아야 한다"는 운영 관점과 일치한다.
### classify.go `providerKey.Enabled` 추가
`buildProviderIndex`에서 `Enabled: config.ProviderEnabled(p)` 설정 후, candidateProviders loop 안에서 `cur.Enabled != cp.Enabled` diff를 감지해 `StatusApplied` change를 emit한다. `providerKey` struct 자체는 restart-required structural comparison에 사용되므로, `Enabled` 필드 변화는 별도 `if` 블록에서만 처리한다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- provider-first route가 provider id/type에서 dispatch key를 얻는지 확인한다.
- `providers[].enabled=false`가 dispatch 후보에서 제외되고 status에 disabled/effective capacity 0으로 드러나는지 확인한다.
- status snapshot에 adapter duplicate가 생기지 않는지 확인한다.
- refresh classification이 `providers[].enabled` live apply와 restart-required를 SDD 기준으로 나누는지 확인한다.
## 검증 결과
### ROUTE-1 중간 검증
```text
$ go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
ok iop/packages/go/config 0.040s
ok iop/apps/edge/internal/edgevalidate 0.005s
ok iop/apps/edge/internal/node 0.004s
ok iop/apps/edge/internal/service 0.439s
ok iop/apps/edge/internal/openai 1.512s
ok iop/apps/edge/internal/configrefresh 0.011s
ok iop/apps/edge/internal/bootstrap 0.258s
```
### 최종 검증
```text
$ go test ./apps/edge/... ./packages/go/config
ok iop/apps/edge/cmd/edge 0.032s
ok iop/apps/edge/internal/bootstrap (cached)
ok iop/apps/edge/internal/configrefresh (cached)
ok iop/apps/edge/internal/controlplane 4.450s
ok iop/apps/edge/internal/edgecmd 0.013s
ok iop/apps/edge/internal/edgevalidate (cached)
ok iop/apps/edge/internal/events (cached)
ok iop/apps/edge/internal/input 0.008s
ok iop/apps/edge/internal/input/a2a 0.006s
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/openai (cached)
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service (cached)
ok iop/apps/edge/internal/transport 2.040s
ok iop/packages/go/config (cached)
```
```text
$ go test ./...
ok iop/apps/control-plane/cmd/control-plane (cached)
ok iop/apps/control-plane/internal/wire 1.470s
ok iop/apps/edge/cmd/edge (cached)
ok iop/apps/edge/internal/bootstrap (cached)
ok iop/apps/edge/internal/configrefresh (cached)
ok iop/apps/edge/internal/controlplane (cached)
ok iop/apps/edge/internal/edgecmd (cached)
ok iop/apps/edge/internal/edgevalidate (cached)
ok iop/apps/edge/internal/events (cached)
ok iop/apps/edge/internal/input (cached)
ok iop/apps/edge/internal/input/a2a (cached)
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/openai (cached)
ok iop/apps/edge/internal/opsconsole (cached)
ok iop/apps/edge/internal/service (cached)
ok iop/apps/edge/internal/transport (cached)
ok iop/apps/node/cmd/node 0.009s
ok iop/apps/node/internal/adapters 0.009s
ok iop/apps/node/internal/adapters/cli 46.513s
ok iop/apps/node/internal/adapters/cli/status (cached)
ok iop/apps/node/internal/adapters/ollama (cached)
ok iop/apps/node/internal/adapters/openai_compat (cached)
ok iop/apps/node/internal/adapters/vllm (cached)
ok iop/apps/node/internal/bootstrap 0.440s
ok iop/apps/node/internal/node 0.290s
ok iop/apps/node/internal/router (cached)
ok iop/apps/node/internal/store (cached)
ok iop/apps/node/internal/terminal (cached)
ok iop/apps/node/internal/transport (cached)
ok iop/packages/go/audit (cached)
ok iop/packages/go/config (cached)
ok iop/packages/go/hostsetup (cached)
ok iop/packages/go/observability (cached)
```
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Implementation deviation: Fail
- Verification trust: Fail
- Spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/configrefresh/classify.go:82`의 `providerKey`와 `apps/edge/internal/configrefresh/classify.go:254`-`260`의 restart-required 비교가 provider-first 실행 필드(`provider`, `endpoint`, `base_url`, `headers`, `command`, `args`, `env`, `mode`, `resume_args`, `output_format`, `context_size`, `request_timeout_ms`)를 전혀 포함하지 않습니다. 그래서 같은 provider id/type에서 endpoint나 command만 바뀌면 `Classify`가 no-change/applied로 판단할 수 있고, apply 경로는 `apps/edge/internal/bootstrap/runtime.go:220`-`224`에서 변경 목록이 비어 있으면 Node config refresh push까지 생략합니다. 이는 `agent-contract/inner/edge-config-runtime-refresh.md`의 provider execution field restart-required 계약과 SDD S05 Evidence Map을 깨며, Edge snapshot과 Node runtime config가 갈라질 수 있습니다. Fix: provider diff key에 provider-first 실행 필드를 추가하고, 해당 필드 변경을 `StatusRestartRequired`로 분류하세요. endpoint-only/base_url-only/command-or-headers-only 변경 테스트를 추가해 type 변경 없이도 restart_required가 나오는지 확인해야 합니다.
- 다음 단계: FAIL follow-up plan/review를 생성한다.

View file

@ -0,0 +1,196 @@
<!-- task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh plan=1 tag=REVIEW_ROUTE -->
# Code Review Reference - REVIEW_ROUTE
> **[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-06-30
task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh, plan=1, tag=REVIEW_ROUTE
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Task ids:
- `routing-status-refresh`: provider-first dispatch, status snapshot, config refresh
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_0.log`
- Archived review: `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/configrefresh/classify.go`의 provider diff key가 provider-first 실행 필드를 누락해 endpoint/base_url/command/header 등 변경을 restart_required로 분류하지 못한다.
- `apps/edge/internal/bootstrap/runtime.go`의 apply 경로는 변경 목록이 비어 있으면 Node config refresh push를 생략하므로, 누락된 실행 필드 변경은 Edge snapshot과 Node runtime config를 갈라놓을 수 있다.
- Affected files:
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- 필요한 경우 `apps/edge/internal/bootstrap/runtime_test.go`
- Verification evidence from previous loop:
- `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`
- `go test ./apps/edge/... ./packages/go/config`
- `go test ./...`
- Roadmap carryover:
- Milestone task `routing-status-refresh`
- SDD S04/S05, especially config refresh/status evidence for `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh`
- Narrow reread allowed:
- `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_0.log`
- `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_0.log`
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_N.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_ROUTE-1] Provider-First Execution Field Restart Classification | [x] |
## 구현 체크리스트
- [x] `apps/edge/internal/configrefresh/classify.go`에서 provider-first 실행 필드(`provider`, `endpoint`, `base_url`, `headers`, `command`, `args`, `env`, `mode`, `resume_args`, `output_format`, `context_size`, `request_timeout_ms`)를 provider diff key에 포함하고 변경 시 `StatusRestartRequired`로 분류한다.
- [x] `apps/edge/internal/configrefresh/classify_test.go`에 type/category 변경 없이 endpoint만 변경되는 provider-first config가 `restart_required`와 `nodes[].providers["..."].endpoint` restart path를 내는 회귀 테스트를 추가한다.
- [x] 필요한 경우 header/command/base_url 계열 중 최소 하나 이상의 representative execution field 회귀 테스트를 추가해 slice/map diff가 누락되지 않게 한다.
- [x] 기존 `providers[].enabled` 토글은 계속 live-apply로 남고, capacity/max_queue/queue_timeout_ms는 계속 applied로 남는지 기존 테스트 또는 보강 테스트로 확인한다.
- [x] `go test ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`를 실행한다.
- [x] `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`를 실행한다.
- [x] `CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_N.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/04+02,03_routing_status_refresh/`를 `agent-task/archive/YYYY/MM/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/`로 이동하고 최종 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이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.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로 이동한다.
## 계획 대비 변경 사항
없음. 계획과 동일하게 구현했다.
## 주요 설계 결정
- `providerKey`에 12개 provider-first 실행 필드를 추가했다. `Headers`(`map[string]string`)와 slice 필드(`Args`, `Env`, `ResumeArgs`)는 deep comparison(`appendDeepIfChanged`)을 사용하고, 나머지 scalar 필드는 `appendIfChanged`를 사용한다.
- `Headers` 맵 복사를 위한 `cloneStringMap` 헬퍼를 추가했다. nil map은 nil으로 유지해 YAML 미설정 시 양쪽 모두 nil이 되어 false positive diff를 방지한다.
- 실행 필드 비교는 기존 structural 비교(type/category/adapter/models/health/lifecycle) 루프 끝에 추가해 기존 `enabled`/`capacity`/`max_queue`/`queue_timeout_ms` live-apply 경로에 영향을 주지 않는다.
- 테스트 두 개 추가: `TestClassifyProviderFirstEndpointOnlyRestartRequired`(endpoint-only, scalar diff), `TestClassifyProviderFirstHeadersChangeRestartRequired`(headers, map deep-diff). 두 테스트 모두 어댑터 없는 pure provider-first YAML을 사용해 edgevalidate의 endpoint 존재 조건을 충족한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- provider-first endpoint/base_url/command/header 등 실행 필드 변경이 type 변경 없이도 restart_required로 분류되는지 확인한다.
- `providers[].enabled` 토글과 capacity/max_queue/queue_timeout_ms 변경은 기존처럼 live-apply로 유지되는지 확인한다.
- 새 테스트가 type 변경으로 우회하지 않고 실제 execution field diff를 검증하는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### REVIEW_ROUTE-1 중간 검증
```text
$ go test ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/configrefresh 0.013s
ok iop/apps/edge/internal/bootstrap 0.265s
```
### 최종 검증
```text
$ go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
ok iop/packages/go/config (cached)
ok iop/apps/edge/internal/edgevalidate (cached)
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/service (cached)
ok iop/apps/edge/internal/openai (cached)
ok iop/apps/edge/internal/configrefresh (cached)
ok iop/apps/edge/internal/bootstrap (cached)
```
---
> **[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:
| 섹션 | 소유자 | 설명 |
|------|--------|------|
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음 |
| Archive Evidence Snapshot | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트의 기본 이전 루프 컨텍스트 |
| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크 |
| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크 |
| 코드리뷰 전용 체크리스트 | Review agent only | 구현 에이전트가 수정하지 않음 |
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트 | placeholder를 실제 내용으로 교체 |
| 사용자 리뷰 요청 | 구현 에이전트 | 선택된 Milestone lock decision blocker일 때만 채움 |
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
| 검증 결과 | 구현 에이전트 | 실행 출력 기재 |
| 코드리뷰 결과 | 리뷰 에이전트 | 스텁에 포함하지 않음 |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Implementation deviation: Fail
- Verification trust: Pass
- Spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/edgevalidate/validate.go:82`에서 `enabled=false` provider는 `p.Validate()`만 호출하고 곧바로 `continue`하므로, `apps/edge/internal/edgevalidate/validate.go:92`-`94`의 supported provider type 검증을 우회합니다. 그런데 `packages/go/config/config.go:181`-`208`의 `NodeProviderConf.Validate()`는 type이 비어 있는지만 확인하고 `unknown_provider` 같은 unsupported type은 거부하지 않습니다. 결과적으로 disabled provider는 `adapter` 참조만 건너뛰어야 한다는 `agent-contract/inner/edge-config-runtime-refresh.md:37`의 구조 검증 계약과 SDD S01/S05 config validation 기준을 깨고, typo가 있는 provider config가 refresh candidate validation을 통과할 수 있습니다. Fix: disabled branch에서도 enabled branch와 같은 `NormalizeProviderType` allowlist 검증을 수행하되 adapter reference/type-specific execution field availability 검증만 건너뛰고, `enabled=false` + unknown type 회귀 테스트를 추가하세요.
- Required: `agent-contract/inner/edge-config-runtime-refresh.md:45`의 restart-required 기준이 새 구현(`apps/edge/internal/configrefresh/classify.go:288`-`299`)과 테스트가 요구하는 provider-first 실행 필드(`provider`, `endpoint`, `base_url`, `headers`, `command`, `args`, `env`, `mode`, `resume_args`, `output_format`, `context_size`, `request_timeout_ms`)를 여전히 명시하지 않습니다. 현재 코드는 해당 필드 변경을 `restart_required`로 분류하지만 계약 원문은 provider type/category/adapter/models/health/lifecycle까지만 말해 API contract source of truth가 실제 refresh 결과와 불일치합니다. Fix: contract의 restart-required 목록에 provider-first execution fields를 추가하고, 변경이 문서 전용임을 follow-up 검증에 남기세요.
- 다음 단계: FAIL follow-up plan/review를 생성한다.

View file

@ -0,0 +1,46 @@
# Complete - m-node-provider-first-config-surface/04+02,03_routing_status_refresh
## 완료 일시
2026-06-30
## 요약
Node Provider-First Config Surface의 `routing-status-refresh` task를 3회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | FAIL | provider-first config refresh 분류 누락 보완 필요 |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | FAIL | disabled provider structural validation과 refresh contract restart field 정렬 필요 |
| `plan_local_G05_2.log` | `code_review_local_G05_2.log` | PASS | 남은 Required가 모두 해소됨 |
## 구현/정리 내용
- provider-first dispatch에서 adapter가 비어 있는 provider는 provider id를 Node adapter key로 사용하도록 정렬했다.
- disabled provider를 dispatch 후보에서 제외하고 status snapshot에는 `status=disabled`, `health=disabled`, `capacity=0`으로 표시하도록 했다.
- `providers[].enabled`의 기본값, live-apply refresh 분류, validation 회귀 테스트를 추가했다.
- provider-first 실행 필드 변경을 config refresh `restart_required`로 분류하고 계약 문서의 restart-required 기준을 정렬했다.
- disabled provider도 supported provider type structural validation을 통과해야 하도록 보강했다.
## 최종 검증
- `go test ./apps/edge/internal/edgevalidate ./apps/edge/internal/configrefresh` - PASS; `ok iop/apps/edge/internal/edgevalidate`, `ok iop/apps/edge/internal/configrefresh`
- `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap` - PASS; 모든 대상 패키지 통과
- `go test ./...` - PASS; 전체 Go 패키지 통과
## Roadmap Completion
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Completed task ids:
- `routing-status-refresh`: PASS; evidence=`agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G05_2.log`, `agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G05_2.log`; verification=`go test ./apps/edge/internal/edgevalidate ./apps/edge/internal/configrefresh`, `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`, `go test ./...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,97 @@
<!-- task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh plan=2 tag=REVIEW_REVIEW_ROUTE -->
# Plan - REVIEW_REVIEW_ROUTE
## 이 파일을 읽는 구현 에이전트에게
구현 완료는 `CODE_REVIEW-local-G05.md` 작성 후 review-ready 보고까지다. review/finalization은 code-review 전용이다.
직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 구현 중 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 `CODE_REVIEW-local-G05.md`의 `사용자 리뷰 요청` 섹션을 증거와 함께 채우고 중단한다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니라 검증 결과 또는 후속 리뷰 이슈로 처리한다.
## 배경
이 후속 루프는 이전 리뷰의 남은 Required만 처리한다. provider-first 실행 필드 refresh 분류는 `classify.go`와 endpoint/header 테스트로 보강됐지만, 새 `providers[].enabled=false` 검증 경로가 unsupported provider type을 통과시키고, config refresh 계약 문서의 restart-required 목록이 실행 필드 분류와 불일치한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Task ids:
- `routing-status-refresh`: provider-first dispatch, status snapshot, config refresh
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_1.log`
- Archived review: `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_1.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/edgevalidate/validate.go`의 disabled provider branch가 `p.Validate()`만 호출하고 supported provider type allowlist 검증을 건너뛰어 `enabled=false` + `type: unknown_provider`가 통과할 수 있다.
- `agent-contract/inner/edge-config-runtime-refresh.md`의 restart-required 기준이 `classify.go`의 provider-first execution field restart-required 구현과 불일치한다.
- Affected files:
- `apps/edge/internal/edgevalidate/validate.go`
- `apps/edge/internal/edgevalidate/validate_test.go`
- `agent-contract/inner/edge-config-runtime-refresh.md`
- 필요한 경우 `apps/edge/internal/configrefresh/classify_test.go`
- Verification evidence from previous loop:
- `go test ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`
- `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`
- 두 명령 모두 현재 checkout에서 통과했다.
- Roadmap carryover:
- Milestone task `routing-status-refresh`
- SDD S04/S05, especially status/config refresh tests for `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh`
- Narrow reread allowed:
- `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_1.log`
- `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_1.log`
## 범위 결정 근거
새 schema나 dispatch/status semantics를 다시 설계하지 않는다. `enabled=false`는 adapter reference check만 skip해야 하며, provider id/type/category/models/numeric bounds 같은 구조 검증은 유지해야 한다. Contract 변경은 이미 구현된 refresh classification과 테스트 evidence를 문서 원문에 맞추는 작업이다.
## 구현 체크리스트
- [ ] `apps/edge/internal/edgevalidate/validate.go`에서 disabled provider도 enabled provider와 같은 supported type allowlist(`NormalizeProviderType` 결과 `openai_compat`, `ollama`, `cli`)를 통과하게 하고, adapter reference/type-specific execution field availability 검증만 skip한다.
- [ ] `apps/edge/internal/edgevalidate/validate_test.go`에 `enabled=false` provider가 unknown type이면 validation error를 내고, missing adapter reference만 skip한다는 회귀 테스트를 추가한다.
- [ ] `agent-contract/inner/edge-config-runtime-refresh.md`의 restart-required 기준에 provider-first 실행 필드(`provider`, `endpoint`, `base_url`, `headers`, `command`, `args`, `env`, `mode`, `resume_args`, `output_format`, `context_size`, `request_timeout_ms`) 변경을 명시한다.
- [ ] `go test ./apps/edge/internal/edgevalidate ./apps/edge/internal/configrefresh`를 실행한다.
- [ ] `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`를 실행한다.
- [ ] `CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_REVIEW_ROUTE-1] Disabled Provider Structural Type Validation
#### 문제
`apps/edge/internal/edgevalidate/validate.go:82`는 disabled provider에서 `p.Validate()`만 실행하고 `continue`한다. `NodeProviderConf.Validate()`는 type이 비어 있는지만 확인하므로 unsupported provider type을 거부하지 않는다. 계약은 disabled provider의 adapter reference check만 skip하고 structural validation은 유지한다고 되어 있다.
#### 해결 방법
지원 provider type 검증을 disabled 여부보다 앞에서 공통으로 수행하거나, disabled branch 안에서 같은 allowlist 검증을 호출한다. 단, disabled provider의 missing adapter reference와 type-specific execution field availability는 계속 허용한다.
#### 테스트 작성
작성한다. `enabled=false`, `type: unknown_provider`, valid category/model/numeric fields를 가진 provider가 `unknown provider type` 계열 error를 내는지 확인한다. 기존 `TestValidateEdgeConfig_DisabledProviderSkipsAdapterRefCheck`는 missing adapter reference skip이 유지되는지 계속 통과해야 한다.
### [REVIEW_REVIEW_ROUTE-2] Config Refresh Contract Restart Field Alignment
#### 문제
`apps/edge/internal/configrefresh/classify.go`는 provider-first execution field 변경을 `restart_required`로 분류하지만, `agent-contract/inner/edge-config-runtime-refresh.md`의 restart-required 기준은 해당 필드를 명시하지 않는다.
#### 해결 방법
계약 문서의 restart-required 목록에 provider-first execution fields를 추가한다. 코드 동작 변경은 필요하지 않다.
#### 테스트 작성
별도 테스트는 필요 없다. 문서 정렬 변경이며, REVIEW_REVIEW_ROUTE-1의 edgevalidate 테스트와 기존 configrefresh 테스트를 함께 실행한다.
## 최종 검증
```bash
go test ./apps/edge/internal/edgevalidate ./apps/edge/internal/configrefresh
```
```bash
go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
```
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,85 @@
<!-- task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh plan=1 tag=REVIEW_ROUTE -->
# Plan - REVIEW_ROUTE
## 이 파일을 읽는 구현 에이전트에게
구현 완료는 `CODE_REVIEW-local-G07.md` 작성 후 review-ready 보고까지다. review/finalization은 code-review 전용이다.
직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 구현 중 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 섹션을 증거와 함께 채우고 중단한다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니라 검증 결과 또는 후속 리뷰 이슈로 처리한다.
## 배경
이 후속 루프는 이전 리뷰에서 확인된 config refresh 분류 누락만 처리한다. provider-first 실행 필드 변경은 계약상 restart-required인데, 현재 `Classify`의 provider diff key가 endpoint/base URL/command/header 같은 실행 필드를 포함하지 않아 type 변경 없이 실행 필드만 바뀌면 no-change 또는 mutable-only로 처리될 수 있다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Task ids:
- `routing-status-refresh`: provider-first dispatch, status snapshot, config refresh
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_0.log`
- Archived review: `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/configrefresh/classify.go`의 provider diff key가 provider-first 실행 필드를 누락해 endpoint/base_url/command/header 등 변경을 restart_required로 분류하지 못한다.
- `apps/edge/internal/bootstrap/runtime.go`의 apply 경로는 변경 목록이 비어 있으면 Node config refresh push를 생략하므로, 누락된 실행 필드 변경은 Edge snapshot과 Node runtime config를 갈라놓을 수 있다.
- Affected files:
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- 필요한 경우 `apps/edge/internal/bootstrap/runtime_test.go`
- Verification evidence from previous loop:
- `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`
- `go test ./apps/edge/... ./packages/go/config`
- `go test ./...`
- Roadmap carryover:
- Milestone task `routing-status-refresh`
- SDD S04/S05, especially config refresh/status evidence for `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh`
- Narrow reread allowed:
- `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_0.log`
- `agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_0.log`
## 범위 결정 근거
새 schema나 dispatch/status semantics를 다시 설계하지 않는다. 이미 구현된 `providers[].enabled`, provider-first dispatch key, disabled snapshot 경로는 유지하고, config refresh 분류가 계약상 restart-required 실행 필드를 빠짐없이 감지하도록 보완한다.
## 구현 체크리스트
- [ ] `apps/edge/internal/configrefresh/classify.go`에서 provider-first 실행 필드(`provider`, `endpoint`, `base_url`, `headers`, `command`, `args`, `env`, `mode`, `resume_args`, `output_format`, `context_size`, `request_timeout_ms`)를 provider diff key에 포함하고 변경 시 `StatusRestartRequired`로 분류한다.
- [ ] `apps/edge/internal/configrefresh/classify_test.go`에 type/category 변경 없이 endpoint만 변경되는 provider-first config가 `restart_required`와 `nodes[].providers["..."].endpoint` restart path를 내는 회귀 테스트를 추가한다.
- [ ] 필요한 경우 header/command/base_url 계열 중 최소 하나 이상의 representative execution field 회귀 테스트를 추가해 slice/map diff가 누락되지 않게 한다.
- [ ] 기존 `providers[].enabled` 토글은 계속 live-apply로 남고, capacity/max_queue/queue_timeout_ms는 계속 applied로 남는지 기존 테스트 또는 보강 테스트로 확인한다.
- [ ] `go test ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`를 실행한다.
- [ ] `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`를 실행한다.
- [ ] `CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_ROUTE-1] Provider-First Execution Field Restart Classification
#### 문제
`apps/edge/internal/configrefresh/classify.go`는 provider restart-required 비교에서 `Type`, `Category`, `Adapter`, `Models`, `Health`, `LifecycleCapabilities`만 비교한다. provider-first compile에 영향을 주는 endpoint, base URL, CLI command/args, headers, timeout 같은 필드는 비교하지 않는다. 결과적으로 실제 Node adapter payload가 바뀌어야 하는 config가 no-change로 보일 수 있다.
#### 해결 방법
`providerKey`에 provider-first 실행 필드 스냅샷을 추가하고 `Classify`의 restart-required provider comparison에서 필드별 path로 비교한다. map/slice 필드는 deep comparison을 사용한다. 실행 필드 path는 `nodes[].providers["<id>"].endpoint`처럼 operator가 원인을 바로 읽을 수 있게 작성한다.
`Enabled`는 기존처럼 별도 live-apply 비교로 유지한다. capacity, max_queue, queue_timeout_ms도 기존 applied 분류를 유지한다.
#### 테스트 작성
작성한다. 최소한 type을 바꾸지 않고 endpoint만 바꾸는 provider-first `openai_compat` 또는 `vllm` 후보가 `StatusRestartRequired`가 되는 테스트를 추가한다. 가능하면 `headers` map 또는 CLI `command`/`args` 변경도 representative field로 확인한다.
## 최종 검증
```bash
go test ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
```
```bash
go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
```
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,91 +0,0 @@
<!-- task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh plan=0 tag=ROUTE -->
# Code Review Reference - ROUTE
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
## 개요
date=2026-06-29
task=m-node-provider-first-config-surface/04+02,03_routing_status_refresh, plan=0, tag=ROUTE
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
- Task ids:
- `routing-status-refresh`: provider-first dispatch, status snapshot, config refresh
- Completion mode: check-on-pass
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [ROUTE-1] Provider-First Routing Status Refresh | [ ] |
## 구현 체크리스트
- [ ] provider-first source에서 adapter key/served target/capacity/queue/health를 resolve하도록 dispatch 후보 생성 로직을 갱신한다.
- [ ] `nodes[].providers[].enabled`를 생략 시 enabled, `false` 시 provider-pool dispatch 제외로 해석하는 config schema/helper/tests를 추가한다.
- [ ] status snapshot이 provider catalog를 우선하고 zero/disabled provider edge case를 `status=disabled`, effective capacity 0으로 명확히 표현하도록 유지/보강한다.
- [ ] config refresh classify/apply가 provider-first mutable/restart fields와 `providers[].enabled` live apply를 SDD 기준으로 분류하고 runtime catalog/store를 갱신하도록 한다.
- [ ] OpenAI `/v1/models`, `/v1/responses`, `/v1/chat/completions` route tests가 provider-first catalog dispatch를 검증하도록 추가/갱신한다.
- [ ] `go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] 판정을 append한다.
- [ ] active plan/review를 `.log`로 아카이브한다.
- [ ] PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- provider-first route가 provider id/type에서 dispatch key를 얻는지 확인한다.
- `providers[].enabled=false`가 dispatch 후보에서 제외되고 status에 disabled/effective capacity 0으로 드러나는지 확인한다.
- status snapshot에 adapter duplicate가 생기지 않는지 확인한다.
- refresh classification이 `providers[].enabled` live apply와 restart-required를 SDD 기준으로 나누는지 확인한다.
## 검증 결과
### ROUTE-1 중간 검증
```text
$ go test ./packages/go/config ./apps/edge/internal/edgevalidate ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
(output)
```
### 최종 검증
```text
$ go test ./apps/edge/... ./packages/go/config
(output)
```
```text
$ go test ./...
(output)
```

View file

@ -90,6 +90,24 @@ type providerKey struct {
MaxQueue int
QueueTimeoutMS int
LifecycleCapabilities []string
// Enabled tracks the effective enabled state for live-apply detection.
// Not used for restart-required structural comparison.
Enabled bool
// Provider-First execution fields (G06) — all are restart-required on change
// because they alter what the Node adapter connects to or how it runs.
Provider string
Endpoint string
BaseURL string
Headers map[string]string
Command string
Args []string
Env []string
Mode string
ResumeArgs []string
OutputFormat string
ContextSize int
RequestTimeoutMS int
}
func buildProviderIndex(cfg *config.EdgeConfig) map[string]providerKey {
@ -108,6 +126,19 @@ func buildProviderIndex(cfg *config.EdgeConfig) map[string]providerKey {
MaxQueue: p.MaxQueue,
QueueTimeoutMS: p.QueueTimeoutMS,
LifecycleCapabilities: append([]string(nil), p.LifecycleCapabilities...),
Enabled: config.ProviderEnabled(p),
Provider: p.Provider,
Endpoint: p.Endpoint,
BaseURL: p.BaseURL,
Headers: cloneStringMap(p.Headers),
Command: p.Command,
Args: append([]string(nil), p.Args...),
Env: append([]string(nil), p.Env...),
Mode: p.Mode,
ResumeArgs: append([]string(nil), p.ResumeArgs...),
OutputFormat: p.OutputFormat,
ContextSize: p.ContextSize,
RequestTimeoutMS: p.RequestTimeoutMS,
}
}
}
@ -254,6 +285,18 @@ func Classify(current, candidate *config.EdgeConfig) Result {
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].models", provID), StatusRestartRequired, cur.Models, next.Models)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].health", provID), StatusRestartRequired, cur.Health, next.Health)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].lifecycle_capabilities", provID), StatusRestartRequired, cur.LifecycleCapabilities, next.LifecycleCapabilities)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].provider", provID), StatusRestartRequired, cur.Provider, next.Provider)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].endpoint", provID), StatusRestartRequired, cur.Endpoint, next.Endpoint)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].base_url", provID), StatusRestartRequired, cur.BaseURL, next.BaseURL)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].headers", provID), StatusRestartRequired, cur.Headers, next.Headers)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].command", provID), StatusRestartRequired, cur.Command, next.Command)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].args", provID), StatusRestartRequired, cur.Args, next.Args)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].env", provID), StatusRestartRequired, cur.Env, next.Env)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].mode", provID), StatusRestartRequired, cur.Mode, next.Mode)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].resume_args", provID), StatusRestartRequired, cur.ResumeArgs, next.ResumeArgs)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].output_format", provID), StatusRestartRequired, cur.OutputFormat, next.OutputFormat)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].context_size", provID), StatusRestartRequired, cur.ContextSize, next.ContextSize)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].request_timeout_ms", provID), StatusRestartRequired, cur.RequestTimeoutMS, next.RequestTimeoutMS)
}
for provID := range candidateProviders {
if _, exists := currentProviders[provID]; !exists {
@ -294,6 +337,16 @@ func Classify(current, candidate *config.EdgeConfig) Result {
Next: fmt.Sprintf("%d", cp.QueueTimeoutMS),
})
}
// enabled is a live-apply switch: toggling it changes dispatch pool membership
// without requiring a process restart.
if cur.Enabled != cp.Enabled {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q].enabled", provID),
Class: StatusApplied,
Previous: fmt.Sprintf("%v", cur.Enabled),
Next: fmt.Sprintf("%v", cp.Enabled),
})
}
}
currentModels := buildModelIndex(current)
@ -412,6 +465,17 @@ func extractBracketID(path, prefix string) (string, bool) {
return rest[:j], true
}
func cloneStringMap(m map[string]string) map[string]string {
if m == nil {
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = v
}
return out
}
type stringSet struct {
m map[string]struct{}
}

View file

@ -792,3 +792,277 @@ nodes:
}
}
// TestClassifyProviderEnabledLiveApply verifies that toggling providers[].enabled
// is classified as StatusApplied (live apply, no restart required).
func TestClassifyProviderEnabledLiveApply(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
`
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
// Candidate disables prov-a.
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
enabled: false
`
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-enabled-liveapply",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusApplied {
t.Fatalf("expected status=applied for enabled toggle, got %q", result.Status)
}
foundEnabled := false
for _, c := range result.Changes {
if c.Path == `nodes[].providers["prov-a"].enabled` {
foundEnabled = true
if c.Class != configrefresh.StatusApplied {
t.Errorf("providers[].enabled class: got %q, want applied", c.Class)
}
if c.Previous != "true" || c.Next != "false" {
t.Errorf("providers[].enabled change: prev=%q next=%q", c.Previous, c.Next)
}
}
if c.Class == configrefresh.StatusRestartRequired {
t.Errorf("unexpected restart_required change for enabled-only diff: %s", c.Path)
}
}
if !foundEnabled {
t.Errorf("providers[].enabled change path not found in: %+v", result.Changes)
}
}
// TestClassifyProviderFirstEndpointOnlyRestartRequired verifies that changing
// only the provider endpoint (type/category unchanged) is classified as
// restart_required and surfaces the correct path.
func TestClassifyProviderFirstEndpointOnlyRestartRequired(t *testing.T) {
dir := t.TempDir()
currentYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
providers:
- id: "prov-oc"
type: "openai_compat"
category: "api"
endpoint: "http://127.0.0.1:8000/v1"
models: ["model-a"]
capacity: 2
`
// Only endpoint changes — type, category, models, capacity are identical.
candidateYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
providers:
- id: "prov-oc"
type: "openai_compat"
category: "api"
endpoint: "http://127.0.0.1:9000/v1"
models: ["model-a"]
capacity: 2
`
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
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-endpoint-only",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusRestartRequired {
t.Fatalf("expected status=restart_required for endpoint-only change, got %q; changes=%+v", result.Status, result.Changes)
}
found := false
for _, c := range result.Changes {
if c.Path == `nodes[].providers["prov-oc"].endpoint` && c.Class == configrefresh.StatusRestartRequired {
found = true
}
}
if !found {
t.Errorf("expected restart_required change for providers[].endpoint; changes=%+v", result.Changes)
}
}
// TestClassifyProviderFirstHeadersChangeRestartRequired verifies that changing
// a provider headers map (type/category unchanged) is classified as
// restart_required — covers map deep-diff path.
func TestClassifyProviderFirstHeadersChangeRestartRequired(t *testing.T) {
dir := t.TempDir()
currentYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
providers:
- id: "prov-oc"
type: "openai_compat"
category: "api"
endpoint: "http://127.0.0.1:8000/v1"
headers:
Authorization: "Bearer old-token"
models: ["model-a"]
capacity: 2
`
// Only headers change.
candidateYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
providers:
- id: "prov-oc"
type: "openai_compat"
category: "api"
endpoint: "http://127.0.0.1:8000/v1"
headers:
Authorization: "Bearer new-token"
models: ["model-a"]
capacity: 2
`
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
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-headers-only",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusRestartRequired {
t.Fatalf("expected status=restart_required for headers change, got %q; changes=%+v", result.Status, result.Changes)
}
found := false
for _, c := range result.Changes {
if c.Path == `nodes[].providers["prov-oc"].headers` && c.Class == configrefresh.StatusRestartRequired {
found = true
}
}
if !found {
t.Errorf("expected restart_required change for providers[].headers; changes=%+v", result.Changes)
}
}
// TestClassifyProviderFirstExecutionFieldRestartRequired verifies that changing
// provider-first execution fields (endpoint, type) requires a restart.
func TestClassifyProviderFirstExecutionFieldRestartRequired(t *testing.T) {
dir := t.TempDir()
currentYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
vllm:
enabled: true
endpoint: "http://127.0.0.1:8000/v1"
providers:
- id: "prov-vllm"
type: "vllm"
category: "api"
endpoint: "http://127.0.0.1:8000/v1"
models: ["model-a"]
capacity: 2
`
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
// Candidate changes endpoint (provider-first execution field) → restart required.
candidateYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
vllm:
enabled: true
endpoint: "http://127.0.0.1:8080/v1"
providers:
- id: "prov-vllm"
type: "openai_compat"
category: "api"
endpoint: "http://127.0.0.1:8080/v1"
models: ["model-a"]
capacity: 2
`
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-provfirst-restart",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusRestartRequired {
t.Fatalf("expected status=restart_required for provider type change, got %q", result.Status)
}
hasTypeRestart := false
for _, c := range result.Changes {
if c.Path == `nodes[].providers["prov-vllm"].type` && c.Class == configrefresh.StatusRestartRequired {
hasTypeRestart = true
}
}
if !hasTypeRestart {
t.Errorf("expected restart_required for providers[].type change; changes: %+v", result.Changes)
}
}

View file

@ -76,6 +76,19 @@ func ValidateEdgeConfig(cfg *config.EdgeConfig) error {
func validateProviderAdapterReferences(nodeName string, n config.NodeDefinition) error {
idx := buildAdapterIndex(n.Adapters)
for i, p := range n.Providers {
// Disabled providers are validated for structural fields (type, category,
// models, numeric bounds) but adapter reference checks are skipped since
// the provider won't be dispatched and its adapter may be offline.
if !config.ProviderEnabled(p) {
if err := p.Validate(); err != nil {
return fmt.Errorf("node %q: providers[%d]: %w", nodeName, i, err)
}
normType := config.NormalizeProviderType(p.Type)
if normType != "openai_compat" && normType != "ollama" && normType != "cli" {
return fmt.Errorf("node %q: providers[%d] %q: unknown provider type %q", nodeName, i, p.ID, p.Type)
}
continue
}
trimmedType := strings.TrimSpace(p.Type)
if trimmedType == "" {
return fmt.Errorf("node %q: providers[%d] %q: type must not be empty", nodeName, i, p.ID)

View file

@ -590,3 +590,84 @@ func TestValidateEdgeConfig_ProviderLegacyCapacityMismatchViaValidate(t *testing
})
}
}
func TestValidateEdgeConfig_DisabledProviderUnknownTypeRejected(t *testing.T) {
disabled := false
tests := []struct {
name string
node config.NodeDefinition
wantErr string
}{
{
name: "disabled provider with unknown type is rejected",
node: config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{Enabled: true},
},
Providers: []config.NodeProviderConf{
// enabled provider so the node passes the "at least one adapter" check
{ID: "prov-active", Type: "cli", Category: config.CategoryCLI, Adapter: "cli"},
// disabled provider with unknown type must be rejected
{ID: "prov-disabled", Type: "unknown_provider", Category: config.CategoryAPI, Enabled: &disabled},
},
},
wantErr: "unknown provider type",
},
{
name: "disabled provider with valid type and missing adapter passes",
node: config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{Enabled: true},
},
Providers: []config.NodeProviderConf{
{ID: "prov-active", Type: "cli", Category: config.CategoryCLI, Adapter: "cli"},
// disabled provider: valid type, missing adapter reference must be skipped
{ID: "prov-disabled", Type: "openai_compat", Category: config.CategoryAPI, Adapter: "ghost-adapter", Enabled: &disabled},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateEdgeConfig(&config.EdgeConfig{Nodes: []config.NodeDefinition{tc.node}})
if tc.wantErr == "" {
if err != nil {
t.Fatalf("ValidateEdgeConfig: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
})
}
}
func TestValidateEdgeConfig_DisabledProviderSkipsAdapterRefCheck(t *testing.T) {
disabled := false
node := config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
VllmInstances: []config.VllmInstanceConf{
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
},
},
Providers: []config.NodeProviderConf{
{ID: "prov-active", Type: "vllm", Category: config.CategoryAPI, Adapter: "vllm-gpu", Models: []string{"model-a"}, Capacity: 2},
// disabled: adapter "ghost-adapter" doesn't exist but must not error
{ID: "prov-ghost", Type: "vllm", Category: config.CategoryAPI, Adapter: "ghost-adapter", Models: []string{"model-b"}, Enabled: &disabled},
},
}
if err := ValidateEdgeConfig(&config.EdgeConfig{Nodes: []config.NodeDefinition{node}}); err != nil {
t.Fatalf("disabled provider with missing adapter should pass validation, got: %v", err)
}
}

View file

@ -2635,6 +2635,41 @@ func TestChatCompletionsLegacyRouteSetsProviderPoolFalse(t *testing.T) {
}
}
// TestResponsesProviderPoolDispatch verifies that the /v1/responses endpoint
// sets ProviderPool=true and leaves Adapter/Target empty when the request model
// matches a catalog entry, mirroring chat/completions provider-pool semantics.
func TestResponsesProviderPoolDispatch(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}}
catalog := []config.ModelCatalogEntry{
{ID: "prov-vllm:model-a", Providers: map[string]string{"prov-vllm": "model-a"}},
}
srv := NewServer(config.EdgeOpenAIConf{TimeoutSec: 5}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"prov-vllm:model-a",
"input":"hello"
}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if !fake.req.ProviderPool {
t.Error("ProviderPool should be true for catalog-matched model via /v1/responses")
}
if fake.req.ModelGroupKey != "prov-vllm:model-a" {
t.Errorf("ModelGroupKey: got %q, want prov-vllm:model-a", fake.req.ModelGroupKey)
}
if fake.req.Adapter != "" || fake.req.Target != "" {
t.Errorf("Adapter/Target should be empty for provider-pool dispatch, got %q/%q", fake.req.Adapter, fake.req.Target)
}
}
// TestChatCompletionsProviderPoolFallsBackToLegacyRoute verifies that when the
// request model does not match the catalog, the legacy model_routes path is used.
func TestChatCompletionsProviderPoolFallsBackToLegacyRoute(t *testing.T) {

View file

@ -9,6 +9,7 @@ import (
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
eventpkg "iop/packages/go/events"
iop "iop/proto/gen/iop"
)
@ -540,15 +541,35 @@ func (m *modelQueueManager) getSnapshotForNode(nodeID string, rec *edgenode.Node
if prov.ID == "" {
continue
}
capVal := prov.Capacity
inflight, queued := m.getStatsForProviderLocked(nodeID, prov.ID)
servedModels := make([]string, len(prov.Models))
copy(servedModels, prov.Models)
lifecycleCaps := make([]string, len(prov.LifecycleCapabilities))
copy(lifecycleCaps, prov.LifecycleCapabilities)
// Disabled providers appear in the snapshot with status=disabled and
// effective capacity 0 so operators can see the switch state.
if !config.ProviderEnabled(prov) {
snaps = append(snaps, &iop.ProviderSnapshot{
Adapter: prov.Adapter,
Status: "disabled",
Capacity: 0,
InFlight: 0,
Queued: 0,
Id: prov.ID,
Type: prov.Type,
Category: string(prov.Category),
ServedModels: servedModels,
Health: "disabled",
LoadRatio: 0,
LifecycleCapabilities: lifecycleCaps,
})
continue
}
capVal := prov.Capacity
inflight, queued := m.getStatsForProviderLocked(nodeID, prov.ID)
var loadRatio float32
if capVal > 0 {
loadRatio = float32(inflight) / float32(capVal)

View file

@ -839,7 +839,8 @@ func TestResolveProviderPoolCandidatesFiltersInvalidProviders(t *testing.T) {
},
})
// Invalid: empty adapter.
// Provider-first (empty adapter, health available, capacity > 0): now a valid candidate.
// adapter key = provider ID "prov-no-adapter".
store.Add(&edgenode.NodeRecord{
ID: "node-no-adapter",
Runtime: config.RuntimeConf{Concurrency: 4},
@ -851,7 +852,8 @@ func TestResolveProviderPoolCandidatesFiltersInvalidProviders(t *testing.T) {
Providers: []config.NodeProviderConf{
{
ID: "prov-no-adapter",
Adapter: "",
Type: "vllm",
Adapter: "", // provider-first: adapter key derived from ID
Models: []string{"served-qwen"},
Health: "available",
Capacity: 2,
@ -926,26 +928,43 @@ func TestResolveProviderPoolCandidatesFiltersInvalidProviders(t *testing.T) {
t.Fatalf("resolveProviderPoolCandidates: %v", err)
}
// Only prov-available should be in candidates.
if len(candidates) != 1 {
t.Fatalf("expected 1 candidate, got %d: %v", len(candidates), candidates)
// prov-available (legacy/compat) and prov-no-adapter (provider-first) both pass.
// All others are filtered: unavailable health, model mismatch, cap=0, cap<0.
if len(candidates) != 2 {
ids := make([]string, len(candidates))
for i, c := range candidates {
ids[i] = c.providerID
}
t.Fatalf("expected 2 candidates, got %d: %v", len(candidates), ids)
}
c := candidates[0]
if c.providerID != "prov-available" {
t.Errorf("providerID: got %q, want %q", c.providerID, "prov-available")
byID := map[string]*candidateNode{}
for i := range candidates {
byID[candidates[i].providerID] = &candidates[i]
}
if c.adapter != "vllm-gpu" {
t.Errorf("adapter: got %q, want %q", c.adapter, "vllm-gpu")
// Legacy/compat candidate uses explicit adapter key.
avail := byID["prov-available"]
if avail == nil {
t.Fatal("prov-available not in candidates")
}
if c.servedTarget != "served-qwen" {
t.Errorf("servedTarget: got %q, want %q", c.servedTarget, "served-qwen")
if avail.adapter != "vllm-gpu" {
t.Errorf("prov-available adapter: got %q, want %q", avail.adapter, "vllm-gpu")
}
if c.capacity != 2 {
t.Errorf("capacity: got %d, want %d", c.capacity, 2)
if avail.servedTarget != "served-qwen" {
t.Errorf("prov-available servedTarget: got %q", avail.servedTarget)
}
if c.capacity <= 0 {
t.Error("capacity must be > 0 for dispatchable provider")
// Provider-first candidate uses provider ID as adapter key.
noAdp := byID["prov-no-adapter"]
if noAdp == nil {
t.Fatal("prov-no-adapter not in candidates (provider-first dispatch must use provider ID as adapter key)")
}
if noAdp.adapter != "prov-no-adapter" {
t.Errorf("prov-no-adapter adapter: got %q, want provider ID %q", noAdp.adapter, "prov-no-adapter")
}
if noAdp.servedTarget != "served-qwen" {
t.Errorf("prov-no-adapter servedTarget: got %q", noAdp.servedTarget)
}
// Policy should use defaults since none of the providers set policy.
@ -1242,6 +1261,175 @@ func TestGetSnapshotForNodeCatalogFirstNoDuplicates(t *testing.T) {
}
}
// TestResolveProviderPoolCandidatesSkipsDisabledProvider verifies that a
// provider with enabled=false is excluded from dispatch candidates.
func TestResolveProviderPoolCandidatesSkipsDisabledProvider(t *testing.T) {
disabled := false
catalog := []config.ModelCatalogEntry{
{
ID: "model-x",
Providers: map[string]string{
"prov-active": "served-x",
"prov-disabled": "served-x",
},
},
}
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-d",
Providers: []config.NodeProviderConf{
{ID: "prov-active", Type: "vllm", Adapter: "vllm-gpu", Models: []string{"served-x"}, Health: "available", Capacity: 2},
{ID: "prov-disabled", Type: "vllm", Adapter: "vllm-gpu", Models: []string{"served-x"}, Health: "available", Capacity: 2, Enabled: &disabled},
},
Adapters: config.AdaptersConf{
VllmInstances: []config.VllmInstanceConf{{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"}},
},
})
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-d", LifecycleState: edgenode.LifecycleConnected})
svc := New(reg, nil)
svc.SetNodeStore(store)
svc.SetModelCatalog(catalog)
storeSnap, catalogSnap := svc.runtimeConfigSnapshot()
candidates, _, err := svc.resolveProviderPoolCandidates(SubmitRunRequest{ModelGroupKey: "model-x", ProviderPool: true}, storeSnap, catalogSnap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(candidates) != 1 {
t.Fatalf("expected 1 candidate (disabled filtered), got %d", len(candidates))
}
if candidates[0].providerID != "prov-active" {
t.Errorf("expected prov-active, got %q", candidates[0].providerID)
}
}
// TestResolveProviderPoolCandidatesUsesProviderIDWhenAdapterOmitted verifies
// that a provider-first provider (empty Adapter field) uses its ID as the
// dispatch adapter key.
func TestResolveProviderPoolCandidatesUsesProviderIDWhenAdapterOmitted(t *testing.T) {
catalog := []config.ModelCatalogEntry{
{
ID: "model-y",
Providers: map[string]string{
"prov-first": "served-y",
},
},
}
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-pf",
Providers: []config.NodeProviderConf{
{ID: "prov-first", Type: "vllm", Adapter: "", Models: []string{"served-y"}, Health: "available", Capacity: 3},
},
})
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-pf", LifecycleState: edgenode.LifecycleConnected})
svc := New(reg, nil)
svc.SetNodeStore(store)
svc.SetModelCatalog(catalog)
storeSnap, catalogSnap := svc.runtimeConfigSnapshot()
candidates, _, err := svc.resolveProviderPoolCandidates(SubmitRunRequest{ModelGroupKey: "model-y", ProviderPool: true}, storeSnap, catalogSnap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(candidates) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(candidates))
}
if candidates[0].adapter != "prov-first" {
t.Errorf("provider-first adapter key: got %q, want provider ID %q", candidates[0].adapter, "prov-first")
}
if candidates[0].providerID != "prov-first" {
t.Errorf("providerID: got %q", candidates[0].providerID)
}
}
// TestProviderSnapshotsReportDisabledProvider verifies that a disabled
// provider appears in snapshots with status=disabled and effective capacity 0.
func TestProviderSnapshotsReportDisabledProvider(t *testing.T) {
disabled := false
rec := &edgenode.NodeRecord{
ID: "node-snap",
Providers: []config.NodeProviderConf{
{ID: "prov-on", Type: "vllm", Health: "available", Capacity: 4},
{ID: "prov-off", Type: "vllm", Health: "available", Capacity: 4, Enabled: &disabled},
},
}
store := edgenode.NewNodeStore()
store.Add(rec)
m := newModelQueueManager(store)
snaps := m.getSnapshotForNode("node-snap", rec)
if len(snaps) != 2 {
t.Fatalf("expected 2 snapshots, got %d", len(snaps))
}
byID := map[string]*iop.ProviderSnapshot{}
for _, s := range snaps {
byID[s.Id] = s
}
on := byID["prov-on"]
if on == nil {
t.Fatal("prov-on snapshot missing")
}
if on.Status != "available" {
t.Errorf("prov-on status: got %q, want available", on.Status)
}
if on.Capacity != 4 {
t.Errorf("prov-on capacity: got %d, want 4", on.Capacity)
}
off := byID["prov-off"]
if off == nil {
t.Fatal("prov-off snapshot missing")
}
if off.Status != "disabled" {
t.Errorf("prov-off status: got %q, want disabled", off.Status)
}
if off.Health != "disabled" {
t.Errorf("prov-off health: got %q, want disabled", off.Health)
}
if off.Capacity != 0 {
t.Errorf("prov-off effective capacity: got %d, want 0", off.Capacity)
}
}
// TestStatusProviderProviderFirstNoAdapterDuplicates verifies that provider-first
// providers (empty Adapter) appear once in snapshots with no adapter duplicate.
func TestStatusProviderProviderFirstNoAdapterDuplicates(t *testing.T) {
rec := &edgenode.NodeRecord{
ID: "node-pf-snap",
Adapters: config.AdaptersConf{
VllmInstances: []config.VllmInstanceConf{{Name: "vllm-gpu", Enabled: true}},
},
Providers: []config.NodeProviderConf{
{ID: "prov-first-a", Type: "vllm", Adapter: "", Models: []string{"model-a"}, Health: "available", Capacity: 2},
{ID: "prov-first-b", Type: "vllm", Adapter: "", Models: []string{"model-b"}, Health: "available", Capacity: 2},
},
}
store := edgenode.NewNodeStore()
store.Add(rec)
m := newModelQueueManager(store)
snaps := m.getSnapshotForNode("node-pf-snap", rec)
// catalog-first: exactly 2 provider snapshots, no adapter duplicate.
if len(snaps) != 2 {
t.Fatalf("expected 2 provider-first snapshots, got %d: %+v", len(snaps), snaps)
}
for _, s := range snaps {
if s.Id == "" {
t.Errorf("unexpected adapter-only snapshot (empty Id): %+v", s)
}
if s.Adapter != "" {
t.Errorf("provider-first snapshot should have empty Adapter, got %q for id=%q", s.Adapter, s.Id)
}
}
}
// TestGetSnapshotForNodeLegacyAdapterFallback verifies that a node with no
// providers[] catalog returns adapter snapshots (CLI, OllamaInstances, etc.)
// via the legacy adapter fallback path.

View file

@ -471,11 +471,14 @@ func providerCanServe(prov config.NodeProviderConf, servedModel string) bool {
return false
}
// providerDispatchable checks whether a provider has a non-empty adapter.
// Capacity is checked separately so zero-capacity providers remain visible in
// status snapshots while unavailable for provider-pool dispatch.
func providerDispatchable(prov config.NodeProviderConf) bool {
return strings.TrimSpace(prov.Adapter) != ""
// providerAdapterKey returns the dispatch adapter key for a provider.
// For legacy/compat providers the explicit Adapter field is used; for
// provider-first providers (Adapter is empty) the provider ID is used.
func providerAdapterKey(prov config.NodeProviderConf) string {
if k := strings.TrimSpace(prov.Adapter); k != "" {
return k
}
return prov.ID
}
// isProviderAvailable checks provider health status. Only "available" (and
@ -578,17 +581,21 @@ func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest, store *edg
if !inCatalog {
continue
}
// Exclude disabled providers from dispatch.
if !config.ProviderEnabled(prov) {
continue
}
// Defensive SDD compliance: served target must be in provider's own models list.
if !providerCanServe(prov, servedModel) {
continue
}
// Dispatch adapter must be present.
if !providerDispatchable(prov) {
continue
}
// Defensive: adapter must resolve to an enabled instance on this node.
if !isProviderAdapterInstanceValid(rec, prov.Adapter) {
continue
// Derive dispatch adapter key: explicit adapter wins; provider-first uses provider ID.
adapterKey := providerAdapterKey(prov)
if strings.TrimSpace(prov.Adapter) != "" {
// Legacy/compat: adapter must resolve to an enabled instance on this node.
if !isProviderAdapterInstanceValid(rec, adapterKey) {
continue
}
}
// Only available/healthy providers are dispatchable.
if !isProviderAvailable(prov.Health) {
@ -604,7 +611,7 @@ func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest, store *edg
entry: entry,
capacity: cap,
providerID: prov.ID,
adapter: prov.Adapter,
adapter: adapterKey,
servedTarget: servedModel,
})
if !policySet && (prov.MaxQueue > 0 || prov.QueueTimeoutMS > 0) {

View file

@ -2,6 +2,7 @@ package service
import (
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
@ -77,15 +78,30 @@ func staticProviderCatalogSnapshots(rec *edgenode.NodeRecord) []*iop.ProviderSna
if prov.ID == "" {
continue
}
capVal := prov.Capacity
servedModels := make([]string, len(prov.Models))
copy(servedModels, prov.Models)
lifecycleCaps := make([]string, len(prov.LifecycleCapabilities))
copy(lifecycleCaps, prov.LifecycleCapabilities)
if !config.ProviderEnabled(prov) {
snaps = append(snaps, &iop.ProviderSnapshot{
Adapter: prov.Adapter,
Status: "disabled",
Capacity: 0,
Id: prov.ID,
Type: prov.Type,
Category: string(prov.Category),
ServedModels: servedModels,
Health: "disabled",
LifecycleCapabilities: lifecycleCaps,
})
continue
}
snaps = append(snaps, &iop.ProviderSnapshot{
Adapter: prov.Adapter,
Status: "available",
Capacity: int32(capVal),
Capacity: int32(prov.Capacity),
Id: prov.ID,
Type: prov.Type,
Category: string(prov.Category),

View file

@ -129,6 +129,9 @@ openai:
# nodes[].providers[].max_queue — max queue depth (per-provider).
# nodes[].providers[].queue_timeout_ms — queue timeout in milliseconds.
# nodes[].providers[].lifecycle_capabilities — coarse lifecycle capabilities list.
# nodes[].providers[].enabled — on/off dispatch switch; omit or true = enabled, false = excluded from
# dispatch pool. Disabled providers appear in status with status=disabled, capacity=0. Does not
# stop adapter processes. Classified as live-apply (no restart required) on config refresh.
#
# Example node with provider pool (Provider-First, recommended):
# nodes:
@ -147,6 +150,7 @@ openai:
# queue_timeout_ms: 30000
# request_timeout_ms: 120000
# lifecycle_capabilities: ["scale_up", "scale_down"]
# # enabled: false # set to exclude this provider from dispatch pool (live-apply)
# - id: "ollama-local"
# type: "ollama"
# category: "local_inference"

View file

@ -153,6 +153,9 @@ type NodeProviderConf struct {
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms,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.
// Nil (omitted) means enabled; false disables the provider without removing it.
Enabled *bool `mapstructure:"enabled" yaml:"enabled,omitempty"`
// Provider-First fields (G06)
Provider string `mapstructure:"provider" yaml:"provider,omitempty"`
@ -205,6 +208,13 @@ func (p NodeProviderConf) Validate() error {
return nil
}
// ProviderEnabled reports whether a provider is enabled.
// Returns true when Enabled is nil (omitted) or *Enabled is true.
// Returns false only when Enabled is explicitly set to false.
func ProviderEnabled(p NodeProviderConf) bool {
return p.Enabled == nil || *p.Enabled
}
type NodeInfo struct {
ID string `mapstructure:"id" yaml:"id"`
Name string `mapstructure:"name" yaml:"name"`

View file

@ -985,6 +985,88 @@ nodes:
}
}
func TestLoadEdge_NodeProviderEnabledDefaultsTrue(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := `
server:
listen: "0.0.0.0:9090"
nodes:
- alias: "node-a"
token: "tok-a"
adapters:
vllm:
enabled: true
endpoint: "http://127.0.0.1:8000/v1"
providers:
- id: "prov-default"
type: "vllm"
category: "api"
adapter: "vllm"
models: ["model-x"]
capacity: 2
- id: "prov-explicit-true"
type: "vllm"
category: "api"
adapter: "vllm"
models: ["model-y"]
capacity: 2
enabled: true
- id: "prov-explicit-false"
type: "vllm"
category: "api"
adapter: "vllm"
models: ["model-z"]
capacity: 2
enabled: false
`
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))
}
provs := cfg.Nodes[0].Providers
if len(provs) != 3 {
t.Fatalf("expected 3 providers, got %d", len(provs))
}
byID := map[string]config.NodeProviderConf{}
for _, p := range provs {
byID[p.ID] = p
}
// prov-default: Enabled is nil → ProviderEnabled returns true.
def := byID["prov-default"]
if def.Enabled != nil {
t.Errorf("prov-default: expected Enabled=nil (omitted), got %v", def.Enabled)
}
if !config.ProviderEnabled(def) {
t.Error("prov-default: ProviderEnabled must be true when Enabled is nil")
}
// prov-explicit-true: Enabled is *true → ProviderEnabled returns true.
exTrue := byID["prov-explicit-true"]
if exTrue.Enabled == nil || !*exTrue.Enabled {
t.Errorf("prov-explicit-true: expected Enabled=*true, got %v", exTrue.Enabled)
}
if !config.ProviderEnabled(exTrue) {
t.Error("prov-explicit-true: ProviderEnabled must be true when Enabled=true")
}
// prov-explicit-false: Enabled is *false → ProviderEnabled returns false.
exFalse := byID["prov-explicit-false"]
if exFalse.Enabled == nil || *exFalse.Enabled {
t.Errorf("prov-explicit-false: expected Enabled=*false, got %v", exFalse.Enabled)
}
if config.ProviderEnabled(exFalse) {
t.Error("prov-explicit-false: ProviderEnabled must be false when Enabled=false")
}
}
func TestLoadEdge_ControlPlaneDefaults(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")