From f8d4e12e435bee2775c76102d78ece79e9a1944b Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 30 Jun 2026 09:47:34 +0900 Subject: [PATCH] feat: routing status refresh and config validation updates --- .../inner/edge-config-runtime-refresh.md | 5 +- .../code_review_local_G05_2.log | 194 +++++++++++++ .../code_review_local_G07_0.log | 171 +++++++++++ .../code_review_local_G07_1.log | 196 +++++++++++++ .../complete.log | 46 +++ .../plan_local_G05_2.log | 97 +++++++ .../plan_local_G07_0.log} | 0 .../plan_local_G07_1.log | 85 ++++++ .../CODE_REVIEW-local-G07.md | 91 ------ apps/edge/internal/configrefresh/classify.go | 64 ++++ .../internal/configrefresh/classify_test.go | 274 ++++++++++++++++++ apps/edge/internal/edgevalidate/validate.go | 13 + .../internal/edgevalidate/validate_test.go | 81 ++++++ apps/edge/internal/openai/server_test.go | 35 +++ apps/edge/internal/service/model_queue.go | 27 +- .../edge/internal/service/model_queue_test.go | 220 +++++++++++++- apps/edge/internal/service/run_dispatch.go | 33 ++- apps/edge/internal/service/status_provider.go | 20 +- configs/edge.yaml | 4 + packages/go/config/config.go | 10 + packages/go/config/config_test.go | 82 ++++++ 21 files changed, 1621 insertions(+), 127 deletions(-) create mode 100644 agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G05_2.log create mode 100644 agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_0.log create mode 100644 agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_1.log create mode 100644 agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/complete.log create mode 100644 agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G05_2.log rename agent-task/{m-node-provider-first-config-surface/04+02,03_routing_status_refresh/PLAN-local-G07.md => archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_1.log delete mode 100644 agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/CODE_REVIEW-local-G07.md diff --git a/agent-contract/inner/edge-config-runtime-refresh.md b/agent-contract/inner/edge-config-runtime-refresh.md index 477629e..4480f2a 100644 --- a/agent-contract/inner/edge-config-runtime-refresh.md +++ b/agent-contract/inner/edge-config-runtime-refresh.md @@ -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. ## 금지 사항 diff --git a/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G05_2.log b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G05_2.log new file mode 100644 index 0000000..b3ae4de --- /dev/null +++ b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G05_2.log @@ -0,0 +1,194 @@ + + +# 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_0.log b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_0.log new file mode 100644 index 0000000..a0a66f4 --- /dev/null +++ b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_0.log @@ -0,0 +1,171 @@ + + +# 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를 생성한다. diff --git a/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_1.log b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_1.log new file mode 100644 index 0000000..5923f1c --- /dev/null +++ b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/code_review_local_G07_1.log @@ -0,0 +1,196 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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를 생성한다. diff --git a/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/complete.log b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/complete.log new file mode 100644 index 0000000..e24ac2d --- /dev/null +++ b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G05_2.log b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G05_2.log new file mode 100644 index 0000000..c1af15c --- /dev/null +++ b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G05_2.log @@ -0,0 +1,97 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/PLAN-local-G07.md b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_0.log similarity index 100% rename from agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/PLAN-local-G07.md rename to agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_0.log diff --git a/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_1.log b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_1.log new file mode 100644 index 0000000..0277425 --- /dev/null +++ b/agent-task/archive/2026/06/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/plan_local_G07_1.log @@ -0,0 +1,85 @@ + + +# 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[""].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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/CODE_REVIEW-local-G07.md b/agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/CODE_REVIEW-local-G07.md deleted file mode 100644 index 7aeac2c..0000000 --- a/agent-task/m-node-provider-first-config-surface/04+02,03_routing_status_refresh/CODE_REVIEW-local-G07.md +++ /dev/null @@ -1,91 +0,0 @@ - - -# 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) -``` diff --git a/apps/edge/internal/configrefresh/classify.go b/apps/edge/internal/configrefresh/classify.go index 44a0bcf..aee2fbc 100644 --- a/apps/edge/internal/configrefresh/classify.go +++ b/apps/edge/internal/configrefresh/classify.go @@ -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{} } diff --git a/apps/edge/internal/configrefresh/classify_test.go b/apps/edge/internal/configrefresh/classify_test.go index f590989..162acb9 100644 --- a/apps/edge/internal/configrefresh/classify_test.go +++ b/apps/edge/internal/configrefresh/classify_test.go @@ -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) + } +} diff --git a/apps/edge/internal/edgevalidate/validate.go b/apps/edge/internal/edgevalidate/validate.go index b9abf82..8641254 100644 --- a/apps/edge/internal/edgevalidate/validate.go +++ b/apps/edge/internal/edgevalidate/validate.go @@ -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) diff --git a/apps/edge/internal/edgevalidate/validate_test.go b/apps/edge/internal/edgevalidate/validate_test.go index 0bbe9ef..964e7b3 100644 --- a/apps/edge/internal/edgevalidate/validate_test.go +++ b/apps/edge/internal/edgevalidate/validate_test.go @@ -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) + } +} diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 90132d6..dd6e4c6 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -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) { diff --git a/apps/edge/internal/service/model_queue.go b/apps/edge/internal/service/model_queue.go index 8dd840a..75223f8 100644 --- a/apps/edge/internal/service/model_queue.go +++ b/apps/edge/internal/service/model_queue.go @@ -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) diff --git a/apps/edge/internal/service/model_queue_test.go b/apps/edge/internal/service/model_queue_test.go index cbd2e6a..c933936 100644 --- a/apps/edge/internal/service/model_queue_test.go +++ b/apps/edge/internal/service/model_queue_test.go @@ -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. diff --git a/apps/edge/internal/service/run_dispatch.go b/apps/edge/internal/service/run_dispatch.go index aded58d..3a71661 100644 --- a/apps/edge/internal/service/run_dispatch.go +++ b/apps/edge/internal/service/run_dispatch.go @@ -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) { diff --git a/apps/edge/internal/service/status_provider.go b/apps/edge/internal/service/status_provider.go index d584516..5bcf1d7 100644 --- a/apps/edge/internal/service/status_provider.go +++ b/apps/edge/internal/service/status_provider.go @@ -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), diff --git a/configs/edge.yaml b/configs/edge.yaml index 9eeeb8c..80b03a3 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -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" diff --git a/packages/go/config/config.go b/packages/go/config/config.go index abe3dbf..e5a0fb3 100644 --- a/packages/go/config/config.go +++ b/packages/go/config/config.go @@ -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"` diff --git a/packages/go/config/config_test.go b/packages/go/config/config_test.go index 5d7cd4e..7d18723 100644 --- a/packages/go/config/config_test.go +++ b/packages/go/config/config_test.go @@ -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")