feat(edge): runtime config refresh mechanism implementation

- Add configrefresh package for config classification, request handling
- Add bootstrap refresh_admin.go for admin-level config refresh
- Add edgevalidate package for config validation
- Update edge bootstrap runtime with refresh capabilities
- Update edge service layer with config refresh integration
- Update openai layer routes/chat_handler/server for config refresh
- Update edgecmd config handling with refresh support
- Update input manager, transport server, status provider
- Add edge.yaml config updates and go/config package changes
- Add hostsetup templates and test updates
- Update roadmap and phase documentation for runtime-reconnect-config-refresh
This commit is contained in:
toki 2026-06-21 21:35:50 +09:00
parent ff161b9b12
commit 445716cc3e
39 changed files with 4720 additions and 128 deletions

View file

@ -51,14 +51,14 @@ provider/device/model별 qualification report와 모델 lifecycle 관리는 prov
- 경로: `agent-roadmap/phase/operational-observability-provider-management/PHASE.md`
- 요약: 사용자/토큰/사용량/로그 추적과 API/CLI/local inference provider catalog, 로컬 디바이스 provider 상태 관리, provider/device/model qualification report와 모델 lifecycle 관리 방향을 MVP 운영 축과 후속 심화 축으로 스케치한다.
- [진행중] Update Plane과 자체 업데이트 기반
- 경로: `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
- 요약: frontend와 Control Plane만 재배포해도 Edge/Node가 안정 업데이트 프로토콜, 로컬 상태 캐시, host-local manager를 통해 스스로 버전 수렴하는 기반을 정리한다.
- [계획] Automation Runtime과 Bridge 확장
- 경로: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- 요약: CLI Agent 실행과 운영 자동화의 MVP 표면을 정리하고, 사용량 limit 알림/자동 이어받기를 우선 스케치하되 원격 터널링과 oto scheduler/CI-CD는 2차로 잠근다.
- [계획] Update Plane과 자체 업데이트 기반
- 경로: `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
- 요약: frontend와 Control Plane만 재배포해도 Edge/Node가 안정 업데이트 프로토콜, 로컬 상태 캐시, host-local manager를 통해 스스로 버전 수렴하는 기반을 정리한다.
- [계획] 지식과 도구 최적화 확장
- 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
- 요약: 로컬 모델 성능 향상 축으로 단계 호출, tool/schema 강제, 검증/retry/fallback의 MVP 실행 모드를 먼저 스케치하고, RAG 장기 기억, advisor, context hook은 2차로 분리한다.

View file

@ -2,7 +2,7 @@
## 상태
[계획]
[진행중]
## 목표
@ -16,7 +16,7 @@ Control Plane은 release manifest, desired version, rollout/audit view를 제공
완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
스케치 Milestone은 아직 구현 가능한 계획이 아니므로 사용자 검토와 구체화 후 `[계획]`으로 승격한다.
- [계획] Edge/Node Runtime Reconnect와 Config Refresh
- [진행중] Edge/Node Runtime Reconnect와 Config Refresh
- 경로: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- 요약: Edge 단절 후 Node 10초 간격 10회 재접속과 종료 정책, 운영 중 config refresh/diff/apply/Node 전파의 MVP 경계를 구현 가능한 계획으로 정리한다.

View file

@ -12,7 +12,7 @@ Edge 재시작이나 일시 단절 뒤에도 Node가 지정된 retry 정책으
## 상태
[계획]
[진행중]
## 승격 조건
@ -48,10 +48,10 @@ Edge 재시작이나 일시 단절 뒤에도 Node가 지정된 retry 정책으
Node가 Edge 단절을 운영 가능한 transient failure로 처리하되, 무한 루프 없이 명시된 retry 한계에서 종료한다.
- [ ] [retry-policy] Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다. 검증: reconnect 단위 테스트에서 10초 정책을 fake clock 또는 injectable sleeper로 확인하고, 10회 초과 시 종료 경로를 확인한다.
- [ ] [supervisor] Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다. 검증: transport fake나 integration test에서 Edge stop/start 후 Node가 재등록된다.
- [ ] [exit-limit] 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다. 검증: 실패 Edge endpoint로 10회 시도 후 non-zero 종료 또는 fx shutdown path가 관측된다.
- [ ] [dedupe] Edge registry는 이전 연결 disconnect 정리 뒤 같은 node id의 재등록을 정상 수용하고, 아직 살아 있는 duplicate connection은 계속 거부한다. 검증: duplicate active connection과 reconnect-after-unregister 케이스가 각각 테스트된다.
- [x] [retry-policy] Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다. 검증: reconnect 단위 테스트에서 10초 정책을 fake clock 또는 injectable sleeper로 확인하고, 10회 초과 시 종료 경로를 확인한다.
- [x] [supervisor] Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다. 검증: transport fake나 integration test에서 Edge stop/start 후 Node가 재등록된다.
- [x] [exit-limit] 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다. 검증: 실패 Edge endpoint로 10회 시도 후 non-zero 종료 또는 fx shutdown path가 관측된다.
- [x] [dedupe] Edge registry는 이전 연결 disconnect 정리 뒤 같은 node id의 재등록을 정상 수용하고, 아직 살아 있는 duplicate connection은 계속 거부한다. 검증: duplicate active connection과 reconnect-after-unregister 케이스가 각각 테스트된다.
### Epic: [refresh] Edge config refresh 계약
@ -83,12 +83,12 @@ Edge가 refresh된 NodeConfigPayload를 연결 Node에 전달하고, Node는 변
- 상태: 없음
- 요청일: 없음
- 완료 근거: 구현 전 계획 상태이며 기능 Task는 아직 미완료다.
- 완료 근거: `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/complete.log``Roadmap Completion`에 따라 `retry-policy`, `supervisor`, `exit-limit`가 PASS로 확인되었다. `dedupe``TestEdgeServerDuplicateRegistrationReason``TestEdgeServerReconnectAfterUnregisterAccepted` 존재 및 `go test -count=1 ./apps/edge/internal/transport` PASS로 확인되었다.
- 검토 항목:
- [x] SDD gate가 승인되어 구현 잠금이 해제되었다
- [ ] 모든 기능 Task와 Task 안의 검증이 충족되었다
- [ ] 사용자가 field dev 환경 결과를 확인했다
- 리뷰 코멘트: 없음
- 리뷰 코멘트: refresh/node-config/field Task는 완료 근거가 없어 미완료로 유지한다.
## 범위 제외

View file

@ -0,0 +1,265 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=0 tag=REFRESH_CONTRACT -->
# Code Review Reference - REFRESH_CONTRACT
> **[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 linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, 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 Milestone lock decisions 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-21
task=m-runtime-reconnect-config-refresh/02_refresh_contract, plan=0, tag=REFRESH_CONTRACT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFRESH_CONTRACT-1] Edge-local refresh admin API와 CLI entrypoint | [x] |
| [REFRESH_CONTRACT-2] config refresh diff classifier | [x] |
| [REFRESH_CONTRACT-3] final evidence and review stub completion | [x] |
## 구현 체크리스트
- [x] [REFRESH_CONTRACT-1] Edge-local refresh admin API와 `iop-edge config refresh` CLI를 추가해 running Edge process에 dry-run/apply 요청이 도달하게 한다.
- [x] [REFRESH_CONTRACT-2] candidate config load/validation/diff classifier를 추가하고 provider capacity는 `applied`, listen port는 `restart_required`, invalid YAML/provider config는 `rejected`로 테스트한다.
- [x] [REFRESH_CONTRACT-3] `entrypoint`와 `diff-classify` 대상 최종 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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로 이동한다.
## 계획 대비 변경 사항
- **`configrefresh.Evaluate` 시그니처**: 계획의 After shape는 `(ctx, current, req) -> (Result, error)` 2-tuple이었으나, apply 모드에서 Runtime이 candidate config를 커밋하기 위해 `(Result, *config.EdgeConfig, error)` 3-tuple로 구현했다. `RefreshConfig` 메서드가 apply+applied 시 내부적으로 `applyMutableConfig`를 호출하므로 외부 HTTP/CLI 계약은 변경 없다.
- **CLI mode 정규화**: `--mode dry-run`(하이픈)을 `dry_run`(언더스코어) Mode 상수로 정규화하는 switch를 추가했다. 계획의 예시(`--mode dry-run`)와 내부 Mode 상수(`dry_run`) 불일치를 해소하기 위함이다.
- **테스트 픽스처**: `TestRefreshAdminDryRunAndApplyReachRunningRuntime`에서 in-memory config를 직접 빌드하면 viper 기본값(bootstrap.listen 등)과 candidate YAML의 기본값이 달라 restart_required 오탐이 발생했다. base config도 `config.LoadEdge`로 로드하고 explicit 필드를 YAML에 명시하는 방식으로 수정했다.
- **`validateEdgeConfig` 공유 정리**: 계획에서 "config check와 refresh가 같은 validator를 쓰게 정리"를 명시했으나, configrefresh는 `config.LoadEdge`만으로 S09 시나리오(YAML parse error, duplicate provider id) 커버가 충분해 `edgecmd.validateEdgeConfig`(adapter URL 비어있음 등 CLI 전용 체크)의 공유 통합은 REFRESH_APPLY plan으로 이관했다. plan 변경 사항에 기록한다.
## 주요 설계 결정
- **RefreshAdminServer는 Runtime 포인터를 직접 보유**: HTTP 핸들러가 `runtime.RefreshConfig(ctx, req)`를 호출하고 결과를 JSON으로 반환한다. fx lifecycle 바깥에서 port-0 테스트가 가능하도록 `Addr()` 메서드를 노출했다.
- **apply 모드의 mutable-only happy path**: `configrefresh.Evaluate`가 `StatusApplied` + 후보 config를 반환하면, `Runtime.applyMutableConfig`가 `cfgMu` write lock 아래서 `r.Cfg.Nodes[].Providers[]` 용량 필드와 `r.Cfg.Models`를 갱신하고 `r.Service.SetModelCatalog`를 호출한다. rollback 증명은 REFRESH_APPLY plan 범위다.
- **Refresh.enabled=false이면 admin server가 시작되지 않음**: `Runtime.Start` 내에서 `cfg.Refresh.Enabled` 체크로 guard하므로 기존 Edge 운영에 영향 없다.
- **rejected HTTP status 422**: load/validate 실패 시 `http.StatusUnprocessableEntity`를 반환해 2xx 성공과 명확히 구분한다. dry-run/apply 성공은 200.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Running Edge process에 실제 HTTP request가 도달하며, CLI가 offline validation으로 우회하지 않는지 확인한다.
- `dry-run`과 `apply` 모드가 request/response와 tests에서 분리되어 있는지 확인한다.
- Provider capacity, listen port, invalid YAML/provider config가 S07-S09 기대 classification을 정확히 만족하는지 확인한다.
- Node config push/proto 변경이 이번 refresh 에픽 범위를 넘어 섞이지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REFRESH_CONTRACT-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
ok iop/apps/edge/internal/edgecmd 0.014s
ok iop/apps/edge/internal/bootstrap 0.021s
ok iop/apps/edge/cmd/edge 0.036s
ok iop/packages/go/config 0.033s
```
### REFRESH_CONTRACT-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/...
ok iop/apps/edge/cmd/edge 0.097s
ok iop/apps/edge/internal/bootstrap 0.085s
ok iop/apps/edge/internal/configrefresh 0.009s
ok iop/apps/edge/internal/controlplane 4.451s
ok iop/apps/edge/internal/edgecmd 0.021s
ok iop/apps/edge/internal/events 0.005s
ok iop/apps/edge/internal/input 0.011s
ok iop/apps/edge/internal/input/a2a 0.008s
ok iop/apps/edge/internal/node 0.011s
ok iop/apps/edge/internal/openai 1.510s
ok iop/apps/edge/internal/opsconsole 0.012s
ok iop/apps/edge/internal/service 0.440s
ok iop/apps/edge/internal/transport 2.049s
ok iop/packages/go/audit 0.004s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.041s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.008s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.013s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
```
### REFRESH_CONTRACT-3 중간 검증
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.065s
ok iop/apps/edge/internal/bootstrap 0.033s
ok iop/apps/edge/internal/configrefresh 0.008s
ok iop/apps/edge/internal/controlplane 4.451s
ok iop/apps/edge/internal/edgecmd 0.017s
ok iop/apps/edge/internal/events 0.004s
ok iop/apps/edge/internal/input 0.007s
ok iop/apps/edge/internal/input/a2a 0.013s
ok iop/apps/edge/internal/node 0.009s
ok iop/apps/edge/internal/openai 1.516s
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service 0.437s
ok iop/apps/edge/internal/transport 2.041s
ok iop/packages/go/audit 0.002s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.028s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.005s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.016s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.065s
ok iop/apps/edge/internal/bootstrap 0.033s
ok iop/apps/edge/internal/configrefresh 0.008s
ok iop/apps/edge/internal/controlplane 4.451s
ok iop/apps/edge/internal/edgecmd 0.017s
ok iop/apps/edge/internal/events 0.004s
ok iop/apps/edge/internal/input 0.007s
ok iop/apps/edge/internal/input/a2a 0.013s
ok iop/apps/edge/internal/node 0.009s
ok iop/apps/edge/internal/openai 1.516s
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service 0.437s
ok iop/apps/edge/internal/transport 2.041s
ok iop/packages/go/audit 0.002s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.028s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.005s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.016s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
```
```bash
$ go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
ok iop/apps/edge/internal/edgecmd 0.014s
ok iop/apps/edge/internal/bootstrap 0.021s
ok iop/apps/edge/cmd/edge 0.036s
ok iop/packages/go/config 0.033s
```
---
> **[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.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Warn
- implementation deviation: Fail
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/configrefresh/classify.go:12` only calls `config.LoadEdge`, while `apps/edge/internal/edgecmd/config.go:124` still applies `validateEdgeConfig`; this means refresh accepts candidate configs that `iop-edge config check` rejects, such as enabled adapters with empty endpoints at `apps/edge/internal/edgecmd/config.go:248`, `apps/edge/internal/edgecmd/config.go:256`, and `apps/edge/internal/edgecmd/config.go:264`. This violates the plan's same-validator requirement and S09 invalid provider config evidence. Move the shared runtime validator out of the CLI-only path or inject it into refresh candidate loading, then add a refresh rejection test for an adapter/provider config that currently fails `config check`.
- Required: `apps/edge/internal/bootstrap/runtime.go:170` releases `cfgMu` before `configrefresh.Evaluate` reads the `current` config pointer, and `apps/edge/internal/bootstrap/runtime.go:188` mutates the same config/provider backing arrays while the service reads provider data through `apps/edge/internal/service/run_dispatch.go:516` and `apps/edge/internal/service/run_dispatch.go:520`. `Service.SetModelCatalog` also writes an unlocked slice at `apps/edge/internal/service/service.go:47` while dispatch reads it. This introduces data races and inconsistent runtime snapshots as soon as refresh apply is used concurrently with traffic. Use copy-on-write snapshots or proper service/node-store locking, and add deterministic concurrency coverage, preferably including a `go test -race` targeted command.
- Required: `apps/edge/internal/configrefresh/classify.go:108` only classifies provider capacity/max_queue/queue_timeout changes. It misses the planned mutable `models[].providers` diff and also silently ignores provider add/remove or unsupported provider field changes, so `Classify` can return `applied` with `no changes detected` while `applyMutableConfig` still changes `r.Cfg.Models` at `apps/edge/internal/bootstrap/runtime.go:212`. Extend the classifier to report model catalog changes and classify unsupported node/provider structural changes as `restart_required` or `rejected`, with tests for model/provider mapping changes.
- Required: `packages/go/config/config.go:58` adds the new `refresh` config contract, but the user-facing config examples/templates do not expose it: `configs/edge.yaml:30` goes straight from metrics to `control_plane`, and `packages/go/hostsetup/templates.go:32` / `packages/go/hostsetup/templates.go:68` do the same. Since the new CLI requires `refresh.enabled=true`, update `configs/edge.yaml` and both hostsetup Edge templates/tests so operators can discover and enable the admin endpoint consistently with platform-common rules.
- 다음 단계: WARN/FAIL follow-up plan을 생성한다. User-review gate는 트리거하지 않는다.

View file

@ -0,0 +1,259 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=1 tag=REVIEW_REFRESH_CONTRACT -->
# Code Review Reference - REVIEW_REFRESH_CONTRACT
> **[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-21
task=m-runtime-reconnect-config-refresh/02_refresh_contract, plan=1, tag=REVIEW_REFRESH_CONTRACT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- Refresh candidate loading uses only `config.LoadEdge` while `config check` applies `validateEdgeConfig`, so invalid adapter/provider configs can be accepted by refresh.
- Refresh apply mutates runtime config/model catalog without a thread-safe runtime/service snapshot boundary.
- Classifier reports only provider capacity/queue fields and misses planned model catalog/provider structural diffs.
- New `refresh` config contract is absent from `configs/edge.yaml` and hostsetup Edge templates.
- Affected files:
- `apps/edge/internal/configrefresh/*.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/refresh_admin.go`
- `apps/edge/internal/service/*.go`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `packages/go/hostsetup/templates.go`
- `packages/go/hostsetup/setup_test.go`
- `configs/edge.yaml`
- Verification evidence from prior loop:
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed in the archived review.
- `go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config` passed in the archived review.
- Allowed narrow archive reread:
- Read only the two archived files listed above if exact prior context is needed.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REFRESH_CONTRACT-1] Shared refresh validation | [x] |
| [REVIEW_REFRESH_CONTRACT-2] Thread-safe runtime snapshot | [x] |
| [REVIEW_REFRESH_CONTRACT-3] Complete diff classification | [x] |
| [REVIEW_REFRESH_CONTRACT-4] Config examples and templates | [x] |
| [REVIEW_REFRESH_CONTRACT-5] Final verification evidence | [x] |
## 구현 체크리스트
- [x] [REVIEW_REFRESH_CONTRACT-1] Refresh candidate validation is shared with `config check`, and invalid adapter/provider configs are rejected through `/refresh`.
- [x] [REVIEW_REFRESH_CONTRACT-2] Refresh apply uses a thread-safe runtime/service snapshot boundary with deterministic concurrency coverage.
- [x] [REVIEW_REFRESH_CONTRACT-3] Config diff classification reports model catalog/provider structural changes instead of silently returning `no changes detected`.
- [x] [REVIEW_REFRESH_CONTRACT-4] The new `refresh` config contract is represented in user-facing Edge config examples/templates and covered by tests.
- [x] [REVIEW_REFRESH_CONTRACT-5] Final verification commands are executed and the review stub records actual stdout/stderr.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
- 계획 범위의 thread-safe snapshot 보완을 닫기 위해 service뿐 아니라 OpenAI provider-pool catalog reader와 transport registration NodeStore reader까지 같은 copy-on-write 교체 경계에 포함했다.
- `configrefresh.Classify`는 mixed mutable/immutable 변경을 부분 적용하지 않도록 `restart_required`가 하나라도 있으면 apply mutation을 수행하지 않는 기존 status contract를 유지했다.
- field/full-cycle 실제 구동은 이 review-fix plan의 고정 검증 명령에 포함되지 않았고, 이번 변경은 local unit/race 검증으로 한정했다.
## 주요 설계 결정
- `apps/edge/internal/edgevalidate`를 shared validator로 두고 `iop-edge config check`와 `configrefresh.LoadCandidate`가 같은 validation path를 사용하게 했다. enabled `openai_compat` endpoint 누락은 `/refresh`에서도 `rejected`가 된다.
- refresh apply는 기존 `Runtime.Cfg` 또는 provider slice를 in-place로 바꾸지 않고, validated candidate config와 새 NodeStore를 만든 뒤 Runtime/transport/service/input catalog snapshot을 교체한다.
- service는 NodeStore와 model catalog를 하나의 lock으로 snapshot하며, model catalog map은 copy-on-write로 보관/반환한다. OpenAI server도 provider-pool catalog를 lock/copy snapshot으로 읽는다.
- classifier는 provider capacity/max_queue/queue_timeout_ms를 `applied`, top-level model catalog provider mapping을 `applied`, provider add/remove 및 type/category/adapter/models/health/lifecycle changes와 node/top-level runtime surface changes를 `restart_required`로 분류한다.
- `refresh.enabled=false`, `refresh.listen=127.0.0.1:19093`을 tracked `configs/edge.yaml`과 hostsetup installed/bundle templates에 노출했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Refresh candidate loading and `config check` share the same runtime validation path.
- Refresh apply cannot race with provider pool dispatch, status snapshots, or model catalog reads.
- `Result.Changes` includes model catalog/provider structural diffs and never hides unsupported changes as `no changes detected`.
- `refresh.enabled` and `refresh.listen` are visible in tracked Edge examples/templates with safe defaults.
- Final verification includes the targeted race command and the full Edge/config/proto command.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REFRESH_CONTRACT-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd ./packages/go/config
ok iop/apps/edge/internal/configrefresh 0.016s
ok iop/apps/edge/internal/edgecmd 0.015s
ok iop/packages/go/config 0.032s
```
### REVIEW_REFRESH_CONTRACT-2 중간 검증
```bash
$ go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/bootstrap 1.218s
ok iop/apps/edge/internal/service 1.482s
```
### REVIEW_REFRESH_CONTRACT-3 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh
ok iop/apps/edge/internal/configrefresh 0.009s
```
### REVIEW_REFRESH_CONTRACT-4 중간 검증
```bash
$ go test -count=1 ./packages/go/hostsetup ./packages/go/config
ok iop/packages/go/hostsetup 0.006s
ok iop/packages/go/config 0.045s
```
### 최종 검증
```bash
$ go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/bootstrap 1.218s
ok iop/apps/edge/internal/service 1.482s
```
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.096s
ok iop/apps/edge/internal/bootstrap 0.123s
ok iop/apps/edge/internal/configrefresh 0.032s
ok iop/apps/edge/internal/controlplane 4.460s
ok iop/apps/edge/internal/edgecmd 0.031s
? iop/apps/edge/internal/edgevalidate [no test files]
ok iop/apps/edge/internal/events 0.012s
ok iop/apps/edge/internal/input 0.009s
ok iop/apps/edge/internal/input/a2a 0.008s
ok iop/apps/edge/internal/node 0.014s
ok iop/apps/edge/internal/openai 1.512s
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service 0.469s
ok iop/apps/edge/internal/transport 2.022s
ok iop/packages/go/audit 0.005s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.084s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.016s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.052s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent may use it as prior-loop context; only listed archive files may be narrowly reread. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- implementation deviation: Warn
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/cmd/edge/main.go:68` and `apps/edge/cmd/edge/main.go:78` normalize the runtime config used by `iop-edge serve`: empty `logging.path` becomes `<binary-dir>/logs/edge.log`, and relative or empty `bootstrap.artifact_dir` becomes a binary-dir path through `apps/edge/internal/edgecmd/paths.go:53` and `apps/edge/internal/edgecmd/paths.go:64`. Refresh candidates do not go through that same runtime normalization path: `apps/edge/internal/configrefresh/classify.go:15` only calls `config.LoadEdge` plus validation. Because the classifier then compares `bootstrap.artifact_dir` and full `logging` as `restart_required` at `apps/edge/internal/configrefresh/classify.go:138` and `apps/edge/internal/configrefresh/classify.go:140`, an unchanged operator YAML such as omitted `logging.path` plus `bootstrap.artifact_dir: "artifacts"` will be seen as different from the running process. In the real `serve` path, a capacity-only refresh can therefore return `restart_required` instead of S07 `applied`, and apply mode will refuse to commit the mutable change. The current running-runtime tests miss this because they build `currentCfg` with `config.LoadEdge` directly at `apps/edge/internal/bootstrap/runtime_test.go:263` and `apps/edge/internal/bootstrap/runtime_test.go:402`, bypassing `loadRuntimeConfig` normalization. Fix by making refresh candidate loading use the same runtime-normalized config contract as `serve` or by normalizing both current and candidate before classification, then add a regression test where the current runtime config has serve-normalized paths and the candidate YAML uses the same source-level values while only provider capacity changes.
- 다음 단계: WARN/FAIL follow-up plan을 생성한다. User-review gate는 트리거하지 않는다.

View file

@ -0,0 +1,238 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=3 tag=REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT -->
# Code Review Reference - REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT
> **[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-21
task=m-runtime-reconnect-config-refresh/02_refresh_contract, plan=3, tag=REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G05_2.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G05_2.log`
- Verdict: FAIL
- Required summary:
- `configrefresh.applyRuntimeNormalization` cleans explicitly configured relative `logging.path` values with `filepath.Clean`.
- `iop-edge serve` does not clean explicit log paths; `edgecmd.ResolveEdgeLogPath` returns non-empty explicit values unchanged.
- Therefore source-identical YAML with `logging.path: "./logs/edge.log"` can still be classified as `restart_required` against the running process when only provider capacity changed.
- The new regression tests cover empty `logging.path`, but not explicit relative `logging.path`, and the test helper duplicates the same clean behavior.
- Prior loop context:
- Earlier archives: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log`.
- Current loop verification commands all passed, but they do not cover explicit relative log paths.
- Affected files:
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go` if runtime-level helper duplication is adjusted
- `apps/edge/internal/edgecmd/paths.go` or a new import-safe helper location if normalization is shared
- Verification evidence from current review:
- `go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd` passed.
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` passed.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed.
- Allowed narrow archive reread:
- Read only the six archive files listed above if exact prior-loop context is needed.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G04.md` -> `code_review_local_G04_N.log`, `PLAN-local-G04.md` -> `plan_local_G04_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1] Exact serve logging path contract | [x] |
| [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2] Explicit relative path regression | [x] |
| [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification evidence | [x] |
## 구현 체크리스트
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1] Refresh candidate logging path normalization exactly matches `iop-edge serve` for empty, absolute, and explicit relative `logging.path`.
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2] Regression tests cover explicit relative `logging.path` with capacity-only refresh and prove no `logging` restart-required diff is produced.
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification commands are executed and the review stub records actual stdout/stderr.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G04_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G04_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G04.md`와 `CODE_REVIEW-local-G04.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로 이동한다.
## 계획 대비 변경 사항
계획에서 제시한 narrow fix를 정확히 수행했으며, 계획 대비 별도 변경사항 없음.
## 주요 설계 결정
1. `applyRuntimeNormalization`에서 explicit relative `logging.path`에 대한 `filepath.Clean` 호출을 완전히 제거했다. empty path만 `defaultEdgeLogPath()`로 resolve되고, absolute/relative 모두 원본을 그대로 유지한다.
2. test helper `buildNormalizedCurrent`도 동일하게 수정: explicit relative `logging.path`에 clean을 호출하지 않도록 변경.
3. `bootstrap.artifact_dir` 관련 행동은 변경하지 않았다. 기존 `resolveBootstrapArtifactDir`이 이미 absolute check 후 `filepath.Join(binaryDir(), dir)`로 절대경로를 생산하므로 alignment 유지.
4. explicit relative `logging.path: "./logs/edge.log"`를 사용하는 capacity-only refresh 테스트(`TestClassifyExplicitRelativeLoggingPathNoRestartRequired`)를 추가하여 회귀를 차단.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Refresh candidate normalization preserves explicit relative `logging.path` exactly like `edgecmd.ResolveEdgeLogPath`.
- Empty `logging.path` and `bootstrap.artifact_dir` behavior from the previous loop remains covered.
- The regression test fails before the fix and passes after it.
- Final verification includes targeted configrefresh/bootstrap/cmd/edge/edgecmd tests, race tests, and full Edge/config/proto tests.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd
ok iop/apps/edge/internal/configrefresh 0.012s
ok iop/apps/edge/internal/edgecmd 0.016s
```
### REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/configrefresh 0.014s
ok iop/apps/edge/internal/bootstrap 0.063s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
ok iop/apps/edge/internal/configrefresh 0.022s
ok iop/apps/edge/internal/bootstrap 0.072s
ok iop/apps/edge/cmd/edge 0.061s
ok iop/apps/edge/internal/edgecmd 0.034s
```
```bash
$ go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/bootstrap 1.199s
ok iop/apps/edge/internal/service 1.479s
```
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.061s
ok iop/apps/edge/internal/bootstrap 0.072s
ok iop/apps/edge/internal/configrefresh 0.022s
ok iop/apps/edge/internal/controlplane 4.450s
ok iop/apps/edge/internal/edgecmd 0.034s
? iop/apps/edge/internal/edgevalidate [no test files]
ok iop/apps/edge/internal/events 0.010s
ok iop/apps/edge/internal/input 0.013s
ok iop/apps/edge/internal/input/a2a 0.012s
ok iop/apps/edge/internal/node 0.017s
ok iop/apps/edge/internal/openai 1.509s
ok iop/apps/edge/internal/opsconsole 0.011s
ok iop/apps/edge/internal/service 0.447s
ok iop/apps/edge/internal/transport 2.041s
ok iop/packages/go/audit 0.007s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.029s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.009s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.017s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent may use it as prior-loop context; only listed archive files may be narrowly reread. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계: PASS finalization을 진행한다.

View file

@ -0,0 +1,243 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=2 tag=REVIEW_REVIEW_REFRESH_CONTRACT -->
# Code Review Reference - REVIEW_REVIEW_REFRESH_CONTRACT
> **[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-21
task=m-runtime-reconnect-config-refresh/02_refresh_contract, plan=2, tag=REVIEW_REVIEW_REFRESH_CONTRACT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `iop-edge serve` normalizes runtime config after load: `logging.path` is resolved with `edgecmd.ResolveEdgeLogPath`, and `bootstrap.artifact_dir` is resolved with `edgecmd.ResolveBootstrapArtifactDir`.
- `configrefresh.LoadCandidate` loads and validates the candidate but does not apply the same runtime normalization.
- `configrefresh.Classify` compares `bootstrap.artifact_dir` and full `logging` as `restart_required`, so source-level identical YAML can be classified as restart-required against a running serve-normalized process.
- Existing running-runtime tests build `currentCfg` through `config.LoadEdge` directly, so they miss the real `serve` path mismatch.
- Prior loop context:
- Earlier FAIL archive: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`.
- The current loop fixed shared validation, thread-safe snapshots, model/provider diff classification, refresh templates, and final verification evidence.
- Affected files:
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/edgecmd/paths.go`
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- Verification evidence from current review:
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` passed.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed.
- These commands are not sufficient to close the issue because no test covers a serve-normalized current config against a source-level refresh candidate.
- Allowed narrow archive reread:
- Read only the four archive files listed above if exact prior-loop context is needed.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_REFRESH_CONTRACT-1] Runtime-normalized refresh candidate | [x] |
| [REVIEW_REVIEW_REFRESH_CONTRACT-2] Serve-path regression coverage | [x] |
| [REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification evidence | [x] |
## 구현 체크리스트
- [x] [REVIEW_REVIEW_REFRESH_CONTRACT-1] Refresh classification uses the same runtime-normalized config contract as `iop-edge serve` for `logging.path` and `bootstrap.artifact_dir`.
- [x] [REVIEW_REVIEW_REFRESH_CONTRACT-2] Regression tests cover a serve-normalized current config plus source-level candidate YAML where only provider capacity changes and the result remains `applied`.
- [x] [REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification commands are executed and the review stub records actual stdout/stderr.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G04.md`와 `CODE_REVIEW-local-G04.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로 이동한다.
## 계획 대비 변경 사항
계획에서 제시한 `export an import-safe helper` 대신, internal package에 `applyRuntimeNormalization` 함수를 추가하여 `LoadCandidate`에서 호출하도록 구현했다. 테스트에서 `binaryDir`, `resolveBootstrapArtifactDir`는 external test에서 접근 불가하므로 동일한 정규화 로직을 테스트용 헬퍼 함수로 재구현했다.
또한 기존 `bootstrap/runtime_test.go`의 통합 테스트들이 serve-normalized current config를 사용하지 않아 FAIL하고 있어, 테스트용 정규화 헬퍼(`serveNormalizedLogPath`, `serveNormalizedArtifactDir`)를 추가하여 current config를 정규화하도록 수정했다.
## 주요 설계 결정
1. **정규화 위치**: `applyRuntimeNormalization`을 `LoadCandidate` 호출 직후에 실행하여 candidate가 Classify에 들어가기 전부터 effective 값을 갖도록 했다.
2. **logging.path 정규화**: `edgecmd.ResolveEdgeLogPath`와 동일하게 empty이면 binary 디렉터리의 `logs/edge.log`로 해결하되, test 환경에서 os.Executable이 실제로 동작하지 않으므로 fallback 로직을 포함했다.
3. **bootstrap.artifact_dir 정규화**: `edgecmd.ResolveBootstrapArtifactDir`와 동일하게 relative path를 absolute path로 변환한다.
4. ** classifier 무시하지 않음**: `logging`과 `bootstrap.artifact_dir`를 restart-required에서 제외하지 않는다. effective 값이 실제로 변경된 경우에만 restart-required로 분류된다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `serve`와 refresh 후보 로딩이 `logging.path`, `bootstrap.artifact_dir`의 같은 effective-value normalization을 공유한다.
- `Classify`는 effective value가 실제로 바뀐 경우에만 `logging` 또는 `bootstrap.artifact_dir`을 `restart_required`로 보고한다.
- 회귀 테스트는 serve-normalized current config와 source-level candidate YAML의 불일치를 직접 재현한다.
- 최종 검증에는 targeted configrefresh/bootstrap/cmd/edge/edgecmd 테스트, race 테스트, full Edge/config/proto 테스트가 포함된다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_REFRESH_CONTRACT-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd
ok iop/apps/edge/cmd/edge 0.037s
ok iop/apps/edge/internal/configrefresh 0.018s
ok iop/apps/edge/internal/edgecmd 0.015s
```
### REVIEW_REVIEW_REFRESH_CONTRACT-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/configrefresh 0.018s
ok iop/apps/edge/internal/bootstrap 0.053s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
ok iop/apps/edge/internal/configrefresh 0.018s
ok iop/apps/edge/internal/bootstrap 0.053s
ok iop/apps/edge/cmd/edge 0.040s
ok iop/apps/edge/internal/edgecmd 0.016s
```
```bash
$ go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/bootstrap 1.295s
ok iop/apps/edge/internal/service 1.520s
```
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.087s
ok iop/apps/edge/internal/bootstrap 0.084s
ok iop/apps/edge/internal/configrefresh 0.033s
ok iop/apps/edge/internal/controlplane 4.457s
ok iop/apps/edge/internal/edgecmd 0.031s
? iop/apps/edge/internal/edgevalidate [no test files]
ok iop/apps/edge/internal/events 0.015s
ok iop/apps/edge/internal/input 0.015s
ok iop/apps/edge/internal/input/a2a 0.013s
ok iop/apps/edge/internal/node 0.011s
ok iop/apps/edge/internal/openai 1.512s
ok iop/apps/edge/internal/opsconsole 0.011s
ok iop/apps/edge/internal/service 0.461s
ok iop/apps/edge/internal/transport 2.043s
ok iop/packages/go/audit 0.009s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.035s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.012s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.014s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent may use it as prior-loop context; only listed archive files may be narrowly reread. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Warn
- implementation deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/configrefresh/classify.go:21` changes an explicitly configured relative `logging.path` with `filepath.Clean`, but the real `serve` path keeps explicit log paths unchanged: `apps/edge/internal/edgecmd/paths.go:53` returns `explicit` as-is and `apps/edge/cmd/edge/main.go:68` stores that value into the running config. This means an operator config with `logging.path: "./logs/edge.log"` can still be source-identical except for provider capacity, yet the running process holds `"./logs/edge.log"` while the refresh candidate becomes `"logs/edge.log"`. Because `Classify` compares full `logging` as `restart_required` at `apps/edge/internal/configrefresh/classify.go:190`, the capacity-only refresh is again reported as `restart_required` instead of S07 `applied`. The regression tests only cover empty `logging.path`, and their test helper duplicates the same `filepath.Clean` behavior at `apps/edge/internal/configrefresh/classify_test.go:35`, so this mismatch is not caught. Fix the refresh normalization to exactly match `edgecmd.ResolveEdgeLogPath` for explicit values, preferably by moving the shared effective-config helper to an import-safe package and using it from both `serve` and refresh; add a regression test with explicit relative `logging.path: "./logs/edge.log"` plus a capacity-only candidate.
- 다음 단계: WARN/FAIL follow-up plan을 생성한다. User-review gate는 트리거하지 않는다.

View file

@ -0,0 +1,50 @@
# Complete - m-runtime-reconnect-config-refresh/02_refresh_contract
## 완료 일시
2026-06-21
## 요약
Refresh entrypoint and diff-classify contract completed after 4 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | shared validation, thread-safe runtime snapshot, diff coverage, refresh templates, and evidence gaps required follow-up |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | serve-normalized config path mismatch required follow-up |
| `plan_local_G05_2.log` | `code_review_local_G05_2.log` | FAIL | explicit relative `logging.path` mismatch required follow-up |
| `plan_local_G04_3.log` | `code_review_local_G04_3.log` | PASS | explicit relative `logging.path` contract fixed and verified |
## 구현/정리 내용
- Added Edge-local config refresh entrypoint with dry-run/apply responses reaching a running Edge process.
- Added refresh candidate validation, config diff classification, applied/restart_required/rejected status reporting, and model/provider diff coverage.
- Added copy-on-write runtime/service/input/OpenAI/transport snapshot handling for refresh apply.
- Added refresh config defaults to examples/templates.
- Aligned refresh candidate runtime normalization with `iop-edge serve`, including empty and explicit relative `logging.path` behavior.
- Added regression coverage for serve-normalized current config, explicit relative `logging.path`, provider capacity applied, listen port restart_required, and invalid config rejected cases.
## 최종 검증
- `go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd` - PASS; packages returned ok.
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` - PASS; packages returned ok under race detector.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` - PASS; all listed packages returned ok or no test files.
- `go test -count=1 ./apps/edge/internal/configrefresh` - PASS; post-review comment-only cleanup targeted rerun returned ok.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Completed task ids:
- `entrypoint`: PASS; evidence=`agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G04_3.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G04_3.log`; verification=`go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd`
- `diff-classify`: PASS; evidence=`agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G04_3.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G04_3.log`; verification=`go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,374 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=0 tag=REFRESH_CONTRACT -->
# Plan - REFRESH_CONTRACT
## 이 파일을 읽는 구현 에이전트에게
이 계획을 구현한 뒤에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. 구현이 끝나도 review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이며 구현 에이전트가 수행하지 않는다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 연결 근거와 함께 채우고 멈춘다. 구현 중 사용자에게 직접 묻거나, chat 선택지를 제시하거나, `request_user_input`을 호출하거나, `USER_REVIEW.md`를 만들지 않는다. 환경/secret/service 준비, 일반 범위 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 계획으로 기록한다.
## 배경
현재 Edge는 시작 시 `edge.yaml`을 한 번 읽어 runtime을 조립하고, 운영 중 config refresh를 받는 표면이 없다. Milestone의 refresh 에픽은 실행 중 Edge process에 도달하는 명시 entrypoint와 dry-run/apply 모드, 그리고 mutable/immutable/rejected diff 분류를 요구한다. 이 계획은 runtime apply hardening과 Node push를 제외하고, refresh 요청/응답 계약과 classifier의 첫 구현 범위를 고정한다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone 잠금 결정이 실구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 사용하며, 직접 사용자 prompt는 금지된다. code-review가 요청의 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/testing-smoke.md`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/root.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/bootstrap/module.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/input/manager.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/routes.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/service_internal_test.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/registry.go`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `proto/iop/runtime.proto`
- `configs/edge.yaml`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`, 상태 `[승인됨]`, SDD 잠금 `해제`.
- 대상 Acceptance Scenario:
- S06 -> `entrypoint`: running Edge process가 dry-run/apply refresh 요청을 받고 mode별 결과를 반환해야 한다.
- S07 -> `diff-classify`: provider capacity 변경은 `applied`로 분류된다.
- S08 -> `diff-classify`: listen port 변경은 `restart_required`로 분류되고 live apply되지 않는다.
- S09 -> `diff-classify`: invalid YAML 또는 invalid provider config는 `rejected`이고 기존 상태가 유지된다.
- Evidence Map:
- S06: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract`, refresh entrypoint unit/integration test.
- S07: same task, provider capacity classifier test.
- S08: same task, listen port classifier test.
- S09: same task, invalid config refresh test.
- 반영 방식: 구현 checklist는 Edge-local admin API와 CLI가 running process에 도달하는지, dry-run/apply 모드가 응답에 분리되는지, classifier가 S07-S09 세 케이스를 deterministic test로 증명하는지에서 역산한다.
### 테스트 환경 규칙
- `test_env=local`.
- `agent-test/local/rules.md`가 존재해 전체를 읽었다.
- 매칭 profile:
- `agent-test/local/edge-smoke.md`: Edge 단위 테스트 `go test ./apps/edge/...`, smoke `./scripts/e2e-smoke.sh`, e2e `make test-e2e`.
- `agent-test/local/platform-common-smoke.md`: config/proto 공통 확인 `go test ./packages/go/... ./proto/gen/...`, proto 변경 시 `make proto`.
- `agent-test/local/testing-smoke.md`: 검증 규칙 변경 시 테스트 규칙 자체 확인. 이번 계획에서는 테스트 규칙 파일을 수정하지 않는다.
- 적용 명령은 Go cache를 허용하지 않도록 `-count=1`을 사용한다. 새 외부 dependency는 추가하지 않으므로 manifest fallback은 필요 없다.
### 테스트 커버리지 공백
- Running Edge refresh entrypoint: 기존 테스트 없음. `apps/edge/internal/bootstrap` 또는 `apps/edge/cmd/edge` 레벨에서 local admin API에 dry-run/apply 요청이 도달하는 통합 테스트가 필요하다.
- CLI refresh command: 기존 `edgecmd` 테스트는 config/node command 중심이며 refresh command가 없다. CLI flag, mode, HTTP request/response 출력 테스트가 필요하다.
- Diff classifier: provider capacity, listen port, invalid YAML/provider config classifier 테스트가 없다. 새 package 단위 테스트가 필요하다.
- 기존 config validation: `packages/go/config/config_test.go`는 provider/model validation을 넓게 커버하지만 runtime current-vs-candidate diff는 커버하지 않는다.
### 심볼 참조
- renamed/removed symbol 없음.
- 새 public-ish internal API 후보: `configrefresh.Classify`, `configrefresh.Result`, `Runtime.Refresh`, `edgecmd.RefreshCmd`는 신규 추가라 기존 call site 없음.
### 분할 판단
- split decision policy를 plan 파일 선택 전에 평가했다.
- 공유 task group: `agent-task/m-runtime-reconnect-config-refresh`.
- sibling plan:
- `02_refresh_contract`: entrypoint와 diff classifier. 선행 의존성 없음.
- `03+02_edge_refresh_apply`: atomic apply와 ops report. `02_refresh_contract`의 `complete.log`가 필요하다.
- API/contract와 runtime apply hardening은 위험도와 검증 전략이 달라 split한다. 이 plan은 S06-S09만 check-on-pass하고, S10-S11은 dependent plan에서 처리한다.
### 범위 결정 근거
- 이번 plan은 Node config push/ack `push-contract`를 제외한다. SDD Evidence Map에는 S12가 같은 `02_refresh_contract` 경로로 적혀 있지만, 사용자의 실행 범위는 refresh 에픽의 네 작업이며 S12는 node-config 에픽이다.
- Node runtime, adapter registry, generated proto 변경은 제외한다.
- 자동 file watcher hot reload와 frontend refresh UI는 Milestone 범위 제외에 따라 제외한다.
- 실제 apply 실패 rollback과 ops report completeness는 `03+02_edge_refresh_apply`에서 다룬다. 이 plan의 apply mode는 running process 도달, mode 구분, classify 결과 반환, mutable-only happy path hook까지로 제한한다.
### 빌드 등급
- `cloud-G07`: running process entrypoint, CLI/API stdout/HTTP contract, process lifecycle test가 중심이라 local allowlist를 넘는다. 범위는 Edge 내부로 제한되므로 G08 이상은 dependent apply hardening plan에 남긴다.
## 구현 체크리스트
- [ ] [REFRESH_CONTRACT-1] Edge-local refresh admin API와 `iop-edge config refresh` CLI를 추가해 running Edge process에 dry-run/apply 요청이 도달하게 한다.
- [ ] [REFRESH_CONTRACT-2] candidate config load/validation/diff classifier를 추가하고 provider capacity는 `applied`, listen port는 `restart_required`, invalid YAML/provider config는 `rejected`로 테스트한다.
- [ ] [REFRESH_CONTRACT-3] `entrypoint`와 `diff-classify` 대상 최종 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## REFRESH_CONTRACT-1 - Running Process Entrypoint
### 문제
`config check`는 `LoadEdgeConfig`로 파일을 읽어 검증만 하고 종료한다. 실행 중 Edge process와 연결되는 refresh 표면은 없다.
Before:
```go
// apps/edge/internal/edgecmd/config.go:110
func configCheckCmd() *cobra.Command {
return &cobra.Command{
Use: "check",
Short: "Validate edge config can be loaded",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, path, err := LoadEdgeConfig(cmd)
```
`serve`도 시작 시 config를 한 번 읽고 `bootstrap.Module(cfg)`를 실행한다.
```go
// apps/edge/cmd/edge/main.go:38
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadRuntimeConfig(cmd)
if err != nil {
return err
}
fx.New(bootstrap.Module(cfg)).Run()
return nil
},
```
### 해결 방법
- MVP 표면은 "Edge-local admin API + CLI wrapper"로 고정한다.
- `packages/go/config.EdgeConfig`에 `Refresh EdgeRefreshConf`를 추가한다. 기본은 disabled, listen 기본값은 loopback으로 둔다. 테스트는 `refresh.enabled=true`, `refresh.listen=127.0.0.1:0`을 사용한다.
- `bootstrap.Runtime`에 refresh admin server를 추가하고, enabled일 때 `POST /refresh`를 받는다. request body는 `{mode, config_path, request_id}`이고 mode는 `dry_run|apply`만 허용한다.
- `edgecmd config refresh`는 `--mode dry-run|apply`, `--addr`, `--config`를 받아 running Edge의 admin API로 요청하고 JSON 결과를 출력한다. `--mode` 기본은 `dry-run`으로 둔다.
- `apply` 모드는 이 plan에서 classifier 결과와 mutable-only happy path를 반환하되, apply failure rollback 증명은 REFRESH_APPLY plan으로 넘긴다.
After shape:
```go
// apps/edge/internal/bootstrap/runtime.go
type Runtime struct {
Cfg *config.EdgeConfig
Refresh *RefreshAdminServer
}
func (r *Runtime) RefreshConfig(ctx context.Context, req configrefresh.Request) (configrefresh.Result, error) {
return configrefresh.Evaluate(ctx, r.Cfg, req)
}
```
```go
// apps/edge/internal/edgecmd/config.go
c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd(), configRefreshCmd())
```
### 수정 파일 및 체크리스트
- [ ] `packages/go/config/config.go`: `EdgeRefreshConf`와 `EdgeConfig.Refresh` 추가, defaults 추가.
- [ ] `packages/go/config/config_test.go`: refresh defaults/unmarshal test 추가.
- [ ] `apps/edge/internal/bootstrap/runtime.go`: refresh admin server lifecycle wiring 추가.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: local HTTP handler 추가.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: running runtime에 dry-run/apply 요청 도달 테스트 추가.
- [ ] `apps/edge/internal/edgecmd/config.go`: `config refresh` command 추가.
- [ ] `apps/edge/internal/edgecmd/edgecmd_test.go`: CLI request/JSON output/invalid mode test 추가.
- [ ] `apps/edge/cmd/edge/main.go`: running config path를 Runtime refresh 기본값으로 전달할 수 있게 보존한다.
### 테스트 작성
- 작성: `apps/edge/internal/bootstrap/runtime_test.go`에 `TestRefreshAdminDryRunAndApplyReachRunningRuntime`.
- 작성: `apps/edge/internal/edgecmd/edgecmd_test.go`에 `TestConfigRefreshCommandPostsModeAndConfigPath`, `TestConfigRefreshCommandRejectsInvalidMode`.
- 작성: `packages/go/config/config_test.go`에 `TestEdgeRefreshDefaults`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
```
예상: refresh config schema, admin API lifecycle, CLI wrapper 테스트가 모두 PASS.
## REFRESH_CONTRACT-2 - Diff Classifier
### 문제
현재 runtime 조립은 config 값을 서비스에 직접 주입하고 끝난다. current config와 candidate config의 차이를 `applied`, `restart_required`, `rejected`로 분류하는 경계가 없다.
Before:
```go
// apps/edge/internal/bootstrap/runtime.go:48
registry := edgenode.NewRegistry()
nodeStore, err := edgenode.LoadFromConfig(cfg.Nodes)
if err != nil {
return nil, fmt.Errorf("edge: seed node store: %w", err)
}
svc := edgeservice.New(registry, bus)
svc.SetNodeStore(nodeStore)
svc.SetModelCatalog(cfg.Models)
```
Provider dispatch는 현재 `s.modelCatalog`와 `s.nodeStore`를 읽지만 refresh diff source가 없다.
```go
// apps/edge/internal/service/run_dispatch.go:491
func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest) ([]candidateNode, groupPolicy, error) {
var catalogEntry *config.ModelCatalogEntry
for i := range s.modelCatalog {
```
### 해결 방법
- 새 package `apps/edge/internal/configrefresh`를 추가한다.
- `LoadCandidate(path)`는 `config.LoadEdge`와 shared runtime validation을 수행한다. 기존 `edgecmd.validateEdgeConfig` 로직은 CLI 전용 private 함수에서 공유 가능한 validator로 이동하거나 wrapper를 둔다.
- `Classify(current, candidate)`는 stable key 기준으로 changed node/provider/model paths를 만든다.
- Mutable `applied`:
- `nodes[].providers[].capacity`
- `nodes[].providers[].max_queue`
- `nodes[].providers[].queue_timeout_ms`
- `models[].providers`가 기존 provider id와 served model membership validation을 통과하는 변경
- `restart_required`:
- `server.listen`
- `bootstrap.listen`
- `bootstrap.artifact_dir`
- `openai.listen`
- `a2a.listen`
- `metrics.port`
- `edge.id`, `edge.name`
- `rejected`:
- YAML parse/load error
- provider validation error
- duplicate provider/model id
- unsupported mixed mutable and immutable apply request if implementation cannot safely apply partial changes
- Result는 `status`, `mode`, `request_id`, `changes[]`, `summary`를 포함한다.
After shape:
```go
// apps/edge/internal/configrefresh/result.go
type Status string
const (
StatusApplied Status = "applied"
StatusRestartRequired Status = "restart_required"
StatusRejected Status = "rejected"
)
type Change struct {
Path string `json:"path"`
Class Status `json:"class"`
Previous string `json:"previous,omitempty"`
Next string `json:"next,omitempty"`
}
```
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/configrefresh/*.go`: request/result types, candidate loading, validation wrapper, classifier 구현.
- [ ] `apps/edge/internal/configrefresh/*_test.go`: S07-S09 classifier tests 추가.
- [ ] `apps/edge/internal/edgecmd/config.go`: `config check`와 refresh가 같은 validator를 쓰게 정리.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: rejected/restart_required/applied 결과를 HTTP status와 JSON body로 반환.
### 테스트 작성
- 작성: `TestClassifyProviderCapacityApplied`.
- 작성: `TestClassifyListenPortRestartRequired`.
- 작성: `TestRefreshInvalidYAMLRejected`.
- 작성: `TestRefreshInvalidProviderRejected`.
- fixture는 `t.TempDir()`에 current/candidate YAML을 쓰고 `config.LoadEdge`와 classifier를 통과시킨다.
### 중간 검증
```bash
go test -count=1 ./apps/edge/... ./packages/go/...
```
예상: 새 classifier package를 포함한 Edge와 shared config 테스트가 모두 PASS.
## REFRESH_CONTRACT-3 - Final Evidence
### 문제
Milestone Task의 검증은 SDD Evidence Map의 S06-S09를 complete.log에서 복사 가능한 형태로 남겨야 한다. 구현만 하고 review stub을 비워두면 loop가 완료 증거를 만들 수 없다.
### 해결 방법
- 최종 검증 명령을 모두 실행한다.
- `CODE_REVIEW-cloud-G07.md`의 구현 항목별 완료 여부, 구현 체크리스트, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과를 실제 내용으로 채운다.
- 출력이 길면 command와 저장 경로를 함께 기록한다.
### 수정 파일 및 체크리스트
- [ ] `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/CODE_REVIEW-cloud-G07.md`: 구현 에이전트 소유 섹션 작성.
### 테스트 작성
- 별도 테스트 없음. 이 항목은 evidence 기록 단계다.
### 중간 검증
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: Edge, shared config, generated proto package가 모두 PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `packages/go/config/config.go` | REFRESH_CONTRACT-1 |
| `packages/go/config/config_test.go` | REFRESH_CONTRACT-1, REFRESH_CONTRACT-2 |
| `apps/edge/internal/configrefresh/*.go` | REFRESH_CONTRACT-2 |
| `apps/edge/internal/configrefresh/*_test.go` | REFRESH_CONTRACT-2 |
| `apps/edge/internal/bootstrap/runtime.go` | REFRESH_CONTRACT-1 |
| `apps/edge/internal/bootstrap/refresh_admin.go` | REFRESH_CONTRACT-1, REFRESH_CONTRACT-2 |
| `apps/edge/internal/bootstrap/runtime_test.go` | REFRESH_CONTRACT-1 |
| `apps/edge/internal/edgecmd/config.go` | REFRESH_CONTRACT-1, REFRESH_CONTRACT-2 |
| `apps/edge/internal/edgecmd/edgecmd_test.go` | REFRESH_CONTRACT-1 |
| `apps/edge/cmd/edge/main.go` | REFRESH_CONTRACT-1 |
| `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/CODE_REVIEW-cloud-G07.md` | REFRESH_CONTRACT-3 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: 모든 Edge/config/proto generated package 테스트 PASS.
```bash
go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
```
예상: CLI entrypoint, running admin API, refresh config schema targeted tests PASS.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,161 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=1 tag=REVIEW_REFRESH_CONTRACT -->
# Plan - REVIEW_REFRESH_CONTRACT
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 `REFRESH_CONTRACT` 구현의 코드리뷰 FAIL 항목만 해결한다. 구현 뒤에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션에 정확한 연결 근거를 남기고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이번 FAIL은 repo 내부 구현/검증으로 해소 가능한 항목이므로 기본적으로 사용자 리뷰 요청 대상이 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- Refresh candidate loading uses only `config.LoadEdge` while `config check` applies `validateEdgeConfig`, so invalid adapter/provider configs can be accepted by refresh.
- Refresh apply mutates runtime config/model catalog without a thread-safe runtime/service snapshot boundary.
- Classifier reports only provider capacity/queue fields and misses planned model catalog/provider structural diffs.
- New `refresh` config contract is absent from `configs/edge.yaml` and hostsetup Edge templates.
- Affected files:
- `apps/edge/internal/configrefresh/*.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/refresh_admin.go`
- `apps/edge/internal/service/*.go`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `packages/go/hostsetup/templates.go`
- `packages/go/hostsetup/setup_test.go`
- `configs/edge.yaml`
- Verification evidence from prior loop:
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed in the archived review.
- `go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config` passed in the archived review.
- Allowed narrow archive reread:
- Read only the two archived files listed above if exact prior context is needed.
## 구현 체크리스트
- [ ] [REVIEW_REFRESH_CONTRACT-1] Refresh candidate validation is shared with `config check`, and invalid adapter/provider configs are rejected through `/refresh`.
- [ ] [REVIEW_REFRESH_CONTRACT-2] Refresh apply uses a thread-safe runtime/service snapshot boundary with deterministic concurrency coverage.
- [ ] [REVIEW_REFRESH_CONTRACT-3] Config diff classification reports model catalog/provider structural changes instead of silently returning `no changes detected`.
- [ ] [REVIEW_REFRESH_CONTRACT-4] The new `refresh` config contract is represented in user-facing Edge config examples/templates and covered by tests.
- [ ] [REVIEW_REFRESH_CONTRACT-5] Final verification commands are executed and the review stub records actual stdout/stderr.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## REVIEW_REFRESH_CONTRACT-1 - Shared Refresh Validation
### 문제
`configrefresh.LoadCandidate` only calls `config.LoadEdge`, while `iop-edge config check` additionally calls `validateEdgeConfig`. This violates the plan's same-validator requirement and S09 invalid provider config evidence.
### 해결 방법
Move the Edge runtime validation currently in `apps/edge/internal/edgecmd/config.go` into a non-CLI internal package or helper that both `config check` and `configrefresh.LoadCandidate` can call without an import cycle. Keep CLI command behavior unchanged.
### 수정 파일 및 체크리스트
- [ ] Shared validator is introduced outside the CLI command path.
- [ ] `config check` uses the shared validator.
- [ ] refresh candidate loading uses the shared validator.
- [ ] Add a refresh rejection test for at least one config that `validateEdgeConfig` currently rejects, such as enabled `vllm` or `openai_compat` with an empty endpoint.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd ./packages/go/config
```
## REVIEW_REFRESH_CONTRACT-2 - Thread-Safe Runtime Snapshot
### 문제
Refresh apply mutates `Runtime.Cfg` and `Service.modelCatalog` while request dispatch can read provider and catalog data. The current locks do not protect the shared backing arrays or service catalog reads.
### 해결 방법
Use a single safe runtime state boundary. Either make this contract phase classify/report only and defer mutation to the dependent apply plan, or implement copy-on-write replacement plus service/node-store locking so dispatch sees one consistent immutable snapshot. Do not mutate provider slices that are shared with `NodeStore` without the same synchronization used by dispatch/status readers.
### 수정 파일 및 체크리스트
- [ ] Runtime refresh reads a stable current snapshot.
- [ ] Apply does not race with provider pool dispatch, status snapshots, or model catalog reads.
- [ ] Tests cover apply during concurrent service reads or otherwise prove mutation is not performed in this phase.
- [ ] Targeted race verification is recorded.
### 중간 검증
```bash
go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
```
## REVIEW_REFRESH_CONTRACT-3 - Complete Diff Classification
### 문제
The classifier only reports capacity/max_queue/queue_timeout changes. Planned mutable model catalog changes and unsupported provider structural changes can be hidden as `applied` with an empty `changes` list.
### 해결 방법
Extend `Classify` to compare the planned diff surface:
- `models[].providers` and model catalog entry changes should produce `applied` changes when valid and mutable.
- provider add/remove, adapter/type/category/models/health changes, and any unsupported node/provider structure changes should be classified explicitly as `restart_required` or `rejected`, depending on the chosen contract for this phase.
- mixed mutable and immutable changes must not be silently partial-applied.
### 수정 파일 및 체크리스트
- [ ] Model catalog/provider mapping changes are present in `Result.Changes`.
- [ ] Provider add/remove or unsupported provider field changes are not reported as `no changes detected`.
- [ ] Tests assert the exact status and at least one concrete changed path for these cases.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh
```
## REVIEW_REFRESH_CONTRACT-4 - Config Examples And Templates
### 문제
`packages/go/config.EdgeConfig` now has a `refresh` section, but neither the tracked `configs/edge.yaml` example nor hostsetup Edge templates show it. Operators cannot discover how to enable the local admin endpoint required by `iop-edge config refresh`.
### 해결 방법
Add a conservative default `refresh` block to `configs/edge.yaml`, the setup-installed Edge template, and the bundle-local `config init` template. Keep `enabled: false` by default and document the loopback listen address. Update hostsetup template tests.
### 수정 파일 및 체크리스트
- [ ] `configs/edge.yaml` includes `refresh.enabled` and `refresh.listen`.
- [ ] `packages/go/hostsetup/templates.go` Edge templates include the same default refresh block.
- [ ] hostsetup/config tests cover the template/default consistency.
### 중간 검증
```bash
go test -count=1 ./packages/go/hostsetup ./packages/go/config
```
## 최종 검증
```bash
go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
```
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: targeted race check와 Edge/config/proto generated package 테스트가 모두 PASS.

View file

@ -0,0 +1,133 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=3 tag=REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT -->
# Plan - REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 `REVIEW_REVIEW_REFRESH_CONTRACT` 구현의 코드리뷰 FAIL 항목만 해결한다. 구현 뒤에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션에 정확한 연결 근거를 남기고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이번 FAIL은 repo 내부 구현/검증으로 해소 가능한 항목이므로 기본적으로 사용자 리뷰 요청 대상이 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G05_2.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G05_2.log`
- Verdict: FAIL
- Required summary:
- `configrefresh.applyRuntimeNormalization` cleans explicitly configured relative `logging.path` values with `filepath.Clean`.
- `iop-edge serve` does not clean explicit log paths; `edgecmd.ResolveEdgeLogPath` returns non-empty explicit values unchanged.
- Therefore source-identical YAML with `logging.path: "./logs/edge.log"` can still be classified as `restart_required` against the running process when only provider capacity changed.
- The new regression tests cover empty `logging.path`, but not explicit relative `logging.path`, and the test helper duplicates the same clean behavior.
- Prior loop context:
- Earlier archives: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log`.
- Current loop verification commands all passed, but they do not cover explicit relative log paths.
- Affected files:
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go` if runtime-level helper duplication is adjusted
- `apps/edge/internal/edgecmd/paths.go` or a new import-safe helper location if normalization is shared
- Verification evidence from current review:
- `go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd` passed.
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` passed.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed.
- Allowed narrow archive reread:
- Read only the six archive files listed above if exact prior-loop context is needed.
## 구현 체크리스트
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1] Refresh candidate logging path normalization exactly matches `iop-edge serve` for empty, absolute, and explicit relative `logging.path`.
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2] Regression tests cover explicit relative `logging.path` with capacity-only refresh and prove no `logging` restart-required diff is produced.
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification commands are executed and the review stub records actual stdout/stderr.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1 - Exact Serve Logging Path Contract
### 문제
`edgecmd.ResolveEdgeLogPath` keeps explicit values unchanged:
```go
if explicit != "" {
return explicit, nil
}
```
`configrefresh.applyRuntimeNormalization` currently calls `filepath.Clean` for explicit relative paths. That changes source-level values such as `./logs/edge.log` to `logs/edge.log`, while a running `serve` process keeps `./logs/edge.log`. Since `Classify` treats `logging` as restart-required, a capacity-only refresh can be blocked by this false diff.
### 해결 방법
Make the refresh candidate path normalization exactly match the effective `serve` behavior:
- empty `logging.path`: resolve to `<binary-dir>/logs/edge.log`,
- absolute `logging.path`: preserve exactly,
- explicit relative `logging.path`: preserve exactly, including `./` segments.
Prefer moving the existing effective-config normalization into an import-safe helper used by both `apps/edge/cmd/edge/main.go` and `configrefresh.LoadCandidate`. If that is too broad for this follow-up, keep the implementation minimal but add comments/tests that lock the exact equivalence.
Do not remove `logging` from restart-required classification. Real effective logging changes must still produce `restart_required`.
### 수정 파일 및 체크리스트
- [x] Remove the `filepath.Clean` behavior for explicit relative `logging.path` in refresh normalization, or replace duplicated code with a shared helper that preserves explicit values like `serve`.
- [x] Keep existing empty-path normalization behavior aligned with `edgecmd.ResolveEdgeLogPath`.
- [x] Keep `bootstrap.artifact_dir` relative path behavior aligned with `edgecmd.ResolveBootstrapArtifactDir`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd
```
## REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2 - Explicit Relative Path Regression
### 문제
The current tests prove empty `logging.path` works but do not catch explicit relative path drift. Their test helper also cleans relative paths, mirroring the buggy refresh behavior rather than `serve`.
### 해결 방법
Add a focused regression test that fails before the fix:
- current config mimics `serve` with `logging.path: "./logs/edge.log"` preserved exactly,
- candidate YAML uses the same `logging.path: "./logs/edge.log"` and changes only provider capacity,
- dry-run result is `applied`,
- result changes contain the provider capacity diff,
- no `logging` restart-required diff appears.
If using a helper to build serve-normalized current config, make the helper preserve explicit relative log paths exactly.
### 수정 파일 및 체크리스트
- [x] Add or update a configrefresh test for explicit relative `logging.path`.
- [x] Update any test helper so it models `edgecmd.ResolveEdgeLogPath` exactly for explicit values.
- [x] Existing empty logging path regression remains covered.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
```
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
```
```bash
go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
```
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: explicit relative logging path 회귀 테스트, targeted race check, full Edge/config/proto tests가 모두 PASS.

View file

@ -0,0 +1,131 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=2 tag=REVIEW_REVIEW_REFRESH_CONTRACT -->
# Plan - REVIEW_REVIEW_REFRESH_CONTRACT
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 `REVIEW_REFRESH_CONTRACT` 구현의 코드리뷰 FAIL 항목만 해결한다. 구현 뒤에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션에 정확한 연결 근거를 남기고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이번 FAIL은 repo 내부 구현/검증으로 해소 가능한 항목이므로 기본적으로 사용자 리뷰 요청 대상이 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `iop-edge serve` normalizes runtime config after load: `logging.path` is resolved with `edgecmd.ResolveEdgeLogPath`, and `bootstrap.artifact_dir` is resolved with `edgecmd.ResolveBootstrapArtifactDir`.
- `configrefresh.LoadCandidate` loads and validates the candidate but does not apply the same runtime normalization.
- `configrefresh.Classify` compares `bootstrap.artifact_dir` and full `logging` as `restart_required`, so source-level identical YAML can be classified as restart-required against a running serve-normalized process.
- Existing running-runtime tests build `currentCfg` through `config.LoadEdge` directly, so they miss the real `serve` path mismatch.
- Prior loop context:
- Earlier FAIL archive: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`.
- The current loop fixed shared validation, thread-safe snapshots, model/provider diff classification, refresh templates, and final verification evidence.
- Affected files:
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/edgecmd/paths.go`
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- Verification evidence from current review:
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` passed.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed.
- These commands are not sufficient to close the issue because no test covers a serve-normalized current config against a source-level refresh candidate.
- Allowed narrow archive reread:
- Read only the four archive files listed above if exact prior-loop context is needed.
## 구현 체크리스트
- [ ] [REVIEW_REVIEW_REFRESH_CONTRACT-1] Refresh classification uses the same runtime-normalized config contract as `iop-edge serve` for `logging.path` and `bootstrap.artifact_dir`.
- [ ] [REVIEW_REVIEW_REFRESH_CONTRACT-2] Regression tests cover a serve-normalized current config plus source-level candidate YAML where only provider capacity changes and the result remains `applied`.
- [ ] [REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification commands are executed and the review stub records actual stdout/stderr.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## REVIEW_REVIEW_REFRESH_CONTRACT-1 - Runtime-Normalized Refresh Candidate
### 문제
`apps/edge/cmd/edge/main.go` loads `edge.yaml` and then mutates the runtime config into effective values:
```go
cfg.Logging.Path = logPath
cfg.Bootstrap.ArtifactDir = edgecmd.ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
```
Refresh candidates are loaded through `configrefresh.LoadCandidate`, which currently calls only `config.LoadEdge` and shared validation. The classifier then compares `bootstrap.artifact_dir` and full `logging` as restart-required fields. In a real process started with `iop-edge serve`, a candidate file that is source-level identical except for provider capacity can be rejected as `restart_required` because the current runtime holds normalized path values and the candidate does not.
### 해결 방법
Introduce one shared runtime normalization boundary and use it consistently for serve and refresh. Prefer a small helper in an import-safe internal package that:
- resolves `logging.path` with the existing `edgecmd.ResolveEdgeLogPath` behavior,
- resolves `bootstrap.artifact_dir` with the existing `edgecmd.ResolveBootstrapArtifactDir` behavior,
- is called by `loadRuntimeConfig` and by refresh candidate loading before classification/apply.
If moving the helper would create an import cycle, keep the public behavior equivalent and document why the chosen package boundary is safe. Do not weaken the classifier by ignoring `logging` or `bootstrap.artifact_dir`; they should still be restart-required when their effective values actually change.
### 수정 파일 및 체크리스트
- [ ] Add or expose an import-safe helper for Edge runtime config normalization.
- [ ] `iop-edge serve` continues to use the same effective config normalization.
- [ ] `configrefresh.LoadCandidate` or the classification path normalizes candidates before diffing.
- [ ] Capacity-only candidate refresh no longer reports `logging` or `bootstrap.artifact_dir` as restart-required when the source config did not change.
### 중간 검증
```bash
go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd
```
## REVIEW_REVIEW_REFRESH_CONTRACT-2 - Serve-Path Regression Coverage
### 문제
Current tests load the running config with `config.LoadEdge` directly, which bypasses the `loadRuntimeConfig` normalization used by `serve`. This lets the bug hide behind matched unnormalized test fixtures.
### 해결 방법
Add focused regression coverage for the real mismatch:
- Build or simulate a current runtime config with serve-equivalent normalized values for `logging.path` and `bootstrap.artifact_dir`.
- Load a candidate YAML that uses source-level defaults or relative paths and changes only provider capacity.
- Assert dry-run classification is `applied`, includes the capacity change, and does not include `logging` or `bootstrap.artifact_dir` restart-required changes.
- When practical, also assert apply followed by a second dry-run produces no diff for the same candidate.
Keep the test deterministic; do not depend on a long-running external process or host-specific absolute paths beyond values produced by the shared helper.
### 수정 파일 및 체크리스트
- [ ] Add a classifier or runtime refresh test that fails on the archived bug.
- [ ] The test covers omitted `logging.path` or relative `bootstrap.artifact_dir` using the same source-level candidate YAML style operators will use.
- [ ] Existing S07/S08/S09 tests still pass and keep their intended statuses.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
```
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
```
```bash
go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
```
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: serve-normalized refresh regression, targeted race check, full Edge/config/proto tests가 모두 PASS.

View file

@ -0,0 +1,190 @@
<!-- task=m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply plan=0 tag=REFRESH_APPLY -->
# Code Review Reference - REFRESH_APPLY
> **[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 linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, 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 Milestone lock decisions 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-21
task=m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply, plan=0, tag=REFRESH_APPLY
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `atomic-apply`: validation 또는 apply 실패 시 기존 routing/runtime 상태가 유지된다.
- `ops-report`: refresh 결과가 CLI/API 응답, 로그, event 중 최소 한 표면에 남고 변경된 node/provider/model 항목을 확인할 수 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G08.md` -> `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` -> `plan_cloud_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFRESH_APPLY-1] atomic runtime apply boundary | [ ] |
| [REFRESH_APPLY-2] runtime snapshot synchronization | [ ] |
| [REFRESH_APPLY-3] previous state regression | [ ] |
| [REFRESH_APPLY-4] ops report surface | [ ] |
| [REFRESH_APPLY-5] final evidence and review stub completion | [ ] |
## 구현 체크리스트
- [ ] [REFRESH_APPLY-1] active 또는 archived `02_refresh_contract` complete.log 존재를 확인하고, refresh apply를 staged validation -> commit 순서의 atomic boundary로 구현한다.
- [ ] [REFRESH_APPLY-2] Service/OpenAI/NodeStore runtime snapshot update를 동기화하고 provider capacity/queue policy refresh가 후속 dispatch에 반영되게 한다.
- [ ] [REFRESH_APPLY-3] invalid provider refresh 또는 apply failure 주입 후 기존 `/v1/models`와 provider dispatch 상태가 유지되는 regression test를 작성한다.
- [ ] [REFRESH_APPLY-4] refresh API/CLI 응답에 changed node/provider/model 항목과 status를 남기고 ops-report assertion을 추가한다.
- [ ] [REFRESH_APPLY-5] `atomic-apply``ops-report` 대상 최종 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G08_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G08_M.log`로 아카이브한다.
- [ ] `.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-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/``agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md``CODE_REVIEW-cloud-G08.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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- active 또는 archived `02_refresh_contract/complete.log`가 실제로 존재하고, 02의 entrypoint/diff classifier가 PASS였는지 확인한다.
- rejected/restart_required/apply failure 경로에서 기존 `Runtime.Cfg`, `Service` state, OpenAI model catalog가 바뀌지 않는지 확인한다.
- provider capacity와 queue policy refresh가 기존 in-flight run을 잃지 않고 후속 dispatch에만 반영되는지 확인한다.
- S11 완료 근거가 API/CLI response 중 최소 한 표면에서 changed node/provider/model 항목을 검증하는지 확인한다.
- Node config push/proto/field smoke/docs 변경이 이 task에 섞이지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REFRESH_APPLY-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/...
(output)
```
### REFRESH_APPLY-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai
(output)
```
### REFRESH_APPLY-3 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai
(output)
```
### REFRESH_APPLY-4 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap
(output)
```
### REFRESH_APPLY-5 중간 검증
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
(output)
```
### 최종 검증
```bash
$ find agent-task -path '*/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log' -print -quit
(output)
```
```bash
$ go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/edgecmd
(output)
```
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
(output)
```
---
> **[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.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |

View file

@ -0,0 +1,450 @@
<!-- task=m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply plan=0 tag=REFRESH_APPLY -->
# Plan - REFRESH_APPLY
## 이 파일을 읽는 구현 에이전트에게
이 계획은 dependent split task다. active 또는 archived `02_refresh_contract/complete.log`가 확인되기 전에는 구현을 시작하지 않는다. 구현 후에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 연결 근거와 함께 채우고 멈춘다. 구현 중 사용자에게 직접 묻거나, chat 선택지를 제시하거나, `request_user_input`을 호출하거나, `USER_REVIEW.md`를 만들지 않는다. 환경/secret/service 준비, 일반 범위 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 계획으로 기록한다.
## 배경
`02_refresh_contract`는 running process entrypoint와 diff classification을 세운다. 이 plan은 그 계약 위에서 apply가 실패해도 기존 `/v1/models`, provider-pool dispatch, queue state가 손상되지 않는 atomic apply 경계를 만든다. 또한 operator가 확인할 수 있도록 refresh 결과에 changed node/provider/model 항목을 남긴다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone 잠금 결정이 실구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 사용하며, 직접 사용자 prompt는 금지된다. code-review가 요청의 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `atomic-apply`: validation 또는 apply 실패 시 기존 routing/runtime 상태가 유지된다.
- `ops-report`: refresh 결과가 CLI/API 응답, 로그, event 중 최소 한 표면에 남고 변경된 node/provider/model 항목을 확인할 수 있다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/testing-smoke.md`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/root.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/bootstrap/module.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/artifact_server.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/input/manager.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/routes.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/ollama_passthrough.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/run_result.go`
- `apps/edge/internal/openai/strict_output.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/status_provider.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/node_command.go`
- `apps/edge/internal/service/control_command.go`
- `apps/edge/internal/service/service_internal_test.go`
- `apps/edge/internal/service/status_provider_test.go`
- `apps/edge/internal/service/run_dispatch_internal_test.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/registry.go`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `proto/iop/runtime.proto`
- `configs/edge.yaml`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`, 상태 `[승인됨]`, SDD 잠금 `해제`.
- 대상 Acceptance Scenario:
- S10 -> `atomic-apply`: 기존 `/v1/models`와 provider routing이 정상일 때 일부 apply 실패 refresh 후에도 기존 상태가 유지되어야 한다.
- S11 -> `ops-report`: refresh가 `applied`, `rejected`, `restart_required` 중 하나로 끝나면 changed node/provider/model 항목과 status가 CLI/API/log/event 중 최소 하나에 남아야 한다.
- Evidence Map:
- S10: `agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply`, atomic apply failure regression test.
- S11: same task, CLI/API/log/event assertion for refresh report.
- 반영 방식: 구현 checklist는 apply staging/commit/rollback 경계, service/openai runtime snapshot 동기화, queue policy update, invalid refresh 후 `/v1/models`와 provider dispatch 유지, report surface 검증으로 구성한다.
### 테스트 환경 규칙
- `test_env=local`.
- `agent-test/local/rules.md`가 존재해 전체를 읽었다.
- 매칭 profile:
- `agent-test/local/edge-smoke.md`: Edge 단위 테스트 `go test ./apps/edge/...`; smoke/e2e는 field 환경 준비가 필요한 경우 별도 후속 evidence로 남긴다.
- `agent-test/local/platform-common-smoke.md`: config/proto 공통 확인 `go test ./packages/go/... ./proto/gen/...`; proto 변경 없음이 원칙이다.
- `agent-test/local/testing-smoke.md`: 테스트 규칙 자체는 수정하지 않는다.
- 적용 명령은 Go cache를 허용하지 않도록 `-count=1`을 사용한다. 실제 field capacity smoke는 refresh 에픽 이후 `field-refresh` task 범위라 이 plan의 PASS 조건에 넣지 않는다.
### 테스트 커버리지 공백
- Apply failure previous-state preservation: 기존 테스트 없음. 실패 주입 hook 또는 staged apply error로 기존 `/v1/models`와 dispatch 상태가 유지되는 regression test가 필요하다.
- Service state concurrency: `Service.SetNodeStore``SetModelCatalog`는 단순 setter라 동시 refresh/read 보호 테스트가 없다.
- OpenAI model catalog refresh: 기존 `TestModelsUsesConfiguredModelsOrTarget`는 fallback/configured models만 검증하고 provider-pool catalog refresh 후 `/v1/models` 변화를 검증하지 않는다.
- Queue policy refresh: `modelQueueManager.getOrCreateGroupLocked`는 기존 group policy를 유지하므로 provider `max_queue`/`queue_timeout_ms` refresh test가 없다.
- Ops report: CLI/API response에 changed node/provider/model 항목이 남는지 검증하는 테스트가 없다.
### 심볼 참조
- renamed/removed symbol 없음.
- 02 plan에서 추가될 것으로 기대되는 `Runtime.RefreshConfig`, `configrefresh.Result`, `configrefresh.Change`를 확장 사용한다. 02 구현명이 다르면 이 plan 구현 전 review/plan 변경 사항에 실제 symbol mapping을 기록한다.
### 분할 판단
- split decision policy를 plan 파일 선택 전에 평가했다.
- 공유 task group: `agent-task/m-runtime-reconnect-config-refresh`.
- sibling plan:
- `02_refresh_contract`: entrypoint와 diff classifier. 이 plan의 predecessor.
- `03+02_edge_refresh_apply`: atomic apply와 ops report. directory name이 `+02`를 선언한다.
- predecessor `02` completion:
- active lookup `agent-task/m-runtime-reconnect-config-refresh/02_*/complete.log`, `02+*/complete.log`: 없음.
- archive lookup `agent-task/archive/*/*/m-runtime-reconnect-config-refresh/02_*/complete.log`, `02+*/complete.log`: 없음.
- 현재 세션에서 `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/PLAN-cloud-G07.md``CODE_REVIEW-cloud-G07.md`를 생성했지만 `complete.log`는 없다.
- 구현은 `02_refresh_contract` PASS 후 시작해야 한다. 02가 FAIL/WARN으로 follow-up plan을 만든 경우 그 follow-up까지 완료되어 active 또는 archived `02_*` complete.log가 생긴 뒤 진행한다.
### 범위 결정 근거
- Node config push/ack, generated proto, Node adapter diff/drain/runtime-config apply는 node-config 에픽이므로 제외한다.
- Field GX10/OneXPlayer capacity smoke와 docs 갱신은 field 에픽이므로 제외한다.
- 이번 plan은 Edge-local runtime state와 report surface만 다룬다. Control Plane command surface는 02 plan에서 선택한 MVP 표면이 아니면 추가하지 않는다.
### 빌드 등급
- `cloud-G08`: running Edge apply, shared runtime state, OpenAI route catalog, provider dispatch queue, rollback regression이 한꺼번에 걸린다. 02 contract에 의존하지만 실패 보존과 동시 read/write 안정성이 핵심이라 cloud 고난도 작업이다.
## 구현 체크리스트
- [ ] [REFRESH_APPLY-1] active 또는 archived `02_refresh_contract` complete.log 존재를 확인하고, refresh apply를 staged validation -> commit 순서의 atomic boundary로 구현한다.
- [ ] [REFRESH_APPLY-2] Service/OpenAI/NodeStore runtime snapshot update를 동기화하고 provider capacity/queue policy refresh가 후속 dispatch에 반영되게 한다.
- [ ] [REFRESH_APPLY-3] invalid provider refresh 또는 apply failure 주입 후 기존 `/v1/models`와 provider dispatch 상태가 유지되는 regression test를 작성한다.
- [ ] [REFRESH_APPLY-4] refresh API/CLI 응답에 changed node/provider/model 항목과 status를 남기고 ops-report assertion을 추가한다.
- [ ] [REFRESH_APPLY-5] `atomic-apply``ops-report` 대상 최종 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
1. `find agent-task -path '*/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log' -print -quit`가 active 또는 archived complete.log 경로를 출력하는지 확인한다. 출력이 없으면 구현을 시작하지 않는다.
2. 02 완료 로그의 Roadmap Completion이 `entrypoint`, `diff-classify`를 PASS로 확인했는지 읽고, 실제 symbol/API 이름이 이 plan의 기대와 다르면 `계획 대비 변경 사항`에 mapping을 기록한다.
3. 이 plan은 directory name상 predecessor가 `02` 하나뿐이다. 다른 숨은 선행 의존성을 추가하지 않는다.
## REFRESH_APPLY-1 - Atomic Runtime Apply Boundary
### 문제
현재 `Runtime`은 시작 시 config snapshot을 조립한 뒤 갱신 경계가 없다. `Service`의 node store와 model catalog도 단순 setter라 apply 중간 실패를 되돌릴 단위가 없다.
Before:
```go
// apps/edge/internal/service/service.go:38
func (s *Service) SetNodeStore(store *edgenode.NodeStore) {
s.nodeStore = store
if s.queue != nil {
s.queue.setStore(store)
}
}
// apps/edge/internal/service/service.go:47
func (s *Service) SetModelCatalog(catalog []config.ModelCatalogEntry) {
s.modelCatalog = catalog
}
```
Runtime 시작도 한 번만 주입한다.
```go
// apps/edge/internal/bootstrap/runtime.go:54
svc := edgeservice.New(registry, bus)
svc.SetNodeStore(nodeStore)
svc.SetModelCatalog(cfg.Models)
inputManager := edgeinput.NewManager(*cfg, svc, logger.Named("input"))
```
### 해결 방법
- `Runtime``refreshMu`를 추가해 concurrent refresh apply를 직렬화한다.
- apply는 새 config load/validation/classify, 새 `NodeStore`/model catalog staging, report 생성, commit 순서로 진행한다.
- commit 전까지 기존 `Runtime.Cfg`, `Service` state, `OpenAI` catalog를 변경하지 않는다.
- commit은 한 함수에서 수행하고, error 반환 가능한 단계는 commit 전에 끝낸다. 테스트용 failure injection hook은 commit 전에만 둔다.
- rejected/restart_required 결과는 state를 변경하지 않는다.
- apply 성공 후 `Runtime.Cfg` snapshot, `Service` state, `Input.OpenAI` catalog를 함께 갱신한다.
After shape:
```go
// apps/edge/internal/bootstrap/runtime.go
func (r *Runtime) ApplyRefresh(ctx context.Context, req configrefresh.Request) (configrefresh.Result, error) {
r.refreshMu.Lock()
defer r.refreshMu.Unlock()
staged, result, err := r.stageRefresh(ctx, req)
if err != nil || result.Status != configrefresh.StatusApplied {
return result, err
}
return r.commitRefresh(staged, result)
}
```
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/bootstrap/runtime.go`: `refreshMu`, staged apply, commit helper 추가.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: apply mode가 atomic apply helper를 호출하게 조정.
- [ ] `apps/edge/internal/configrefresh/*.go`: result/report 구조가 staged apply에 필요한 changed item metadata를 담도록 확장.
- [ ] `apps/edge/internal/node/store.go`: 필요 시 immutable replacement/clone helper 추가.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: rejected/restart_required가 state를 바꾸지 않는 테스트 추가.
### 테스트 작성
- 작성: `apps/edge/internal/bootstrap/runtime_test.go``TestRefreshApplyDoesNotMutateOnRejectedCandidate`, `TestRefreshApplyDoesNotMutateOnRestartRequired`.
- 작성: failure hook을 둔다면 `TestRefreshApplyFailurePreservesRuntimeSnapshot`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/...
```
예상: refresh apply boundary와 기존 Edge package 테스트 PASS.
## REFRESH_APPLY-2 - Runtime Snapshot Synchronization
### 문제
OpenAI `/v1/models`와 route dispatch는 `modelCatalog`를 직접 읽고, provider-pool dispatch는 `Service.modelCatalog``Service.nodeStore`를 직접 읽는다. refresh apply가 이 값들을 바꾸면 data race 또는 서로 다른 snapshot이 생길 수 있다.
Before:
```go
// apps/edge/internal/openai/routes.go:28
var modelIDs []string
if len(s.modelCatalog) > 0 {
for _, entry := range s.modelCatalog {
```
```go
// apps/edge/internal/openai/chat_handler.go:203
for i := range s.modelCatalog {
if s.modelCatalog[i].ID == model {
return &s.modelCatalog[i]
}
}
```
```go
// apps/edge/internal/service/run_dispatch.go:493
for i := range s.modelCatalog {
if s.modelCatalog[i].ID == req.ModelGroupKey {
catalogEntry = &s.modelCatalog[i]
```
### 해결 방법
- `Service``runtimeMu sync.RWMutex`를 추가하고 nodeStore/modelCatalog read/write를 helper로 감싼다.
- `OpenAI Server``catalogMu sync.RWMutex` 또는 atomic snapshot helper를 추가해 `/v1/models``findProviderPoolEntry`가 복사본을 읽게 한다.
- `SetModelCatalog`는 slice copy를 저장하고, read helper는 copy 또는 immutable pointer를 반환한다.
- `modelQueueManager.setStore` 호출은 `Service` commit helper에서 같은 write lock 아래 수행한다.
- `modelQueueManager.getOrCreateGroupLocked`의 기존 group policy retention을 검토하고, provider `max_queue`/`queue_timeout_ms` 변경이 기존 group에도 반영되도록 `updateGroupPolicyLocked` 또는 model group reset policy를 추가한다. In-flight run map은 유지한다.
Before queue behavior:
```go
// apps/edge/internal/service/model_queue.go:140
// getOrCreateGroupLocked returns an existing group or creates one with the
// given policy (applying defaults for zero fields). Policy is only applied
// at creation; existing groups retain their current policy.
func (m *modelQueueManager) getOrCreateGroupLocked(key string, policy groupPolicy) *modelQueueGroup {
```
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/service.go`: runtime state lock/helpers 추가.
- [ ] `apps/edge/internal/service/run_dispatch.go`: model catalog/node store read helper 사용.
- [ ] `apps/edge/internal/service/status_provider.go`: node store snapshot read helper 사용.
- [ ] `apps/edge/internal/service/model_queue.go`: store/policy refresh helper 추가.
- [ ] `apps/edge/internal/service/*_test.go`: capacity/queue policy refresh dispatch test 추가.
- [ ] `apps/edge/internal/openai/server.go`: catalog snapshot lock 추가.
- [ ] `apps/edge/internal/openai/routes.go`: `/v1/models`가 refreshed catalog snapshot을 사용.
- [ ] `apps/edge/internal/openai/chat_handler.go`: provider-pool route lookup이 refreshed catalog snapshot을 사용.
- [ ] `apps/edge/internal/openai/server_test.go`: refreshed catalog가 `/v1/models`와 provider-pool route dispatch에 반영되는 테스트 추가.
### 테스트 작성
- 작성: `TestRefreshProviderCapacityAffectsNextDispatch`.
- 작성: `TestRefreshProviderQueuePolicyUpdatesExistingGroup`.
- 작성: `TestOpenAIModelsUsesRefreshedCatalog`.
- 작성: `TestOpenAIProviderPoolDispatchUsesRefreshedCatalog`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai
```
예상: service/openai targeted refresh state tests PASS.
## REFRESH_APPLY-3 - Previous State Regression
### 문제
SDD S10은 invalid provider refresh가 기존 `/v1/models`와 provider dispatch 상태를 깨지 않아야 한다고 요구한다. 현재는 refresh apply 경계가 없어서 이 회귀를 증명하는 테스트도 없다.
### 해결 방법
- 정상 current config로 runtime을 만들고 provider-pool catalog가 `/v1/models`에 보이는 상태를 준비한다.
- 기존 provider dispatch가 성공하는 net.Pipe 기반 service fixture를 구성한다.
- invalid provider config candidate로 apply를 호출한다.
- 결과가 `rejected`이고, 직후 `/v1/models`가 기존 catalog를 반환하며, 같은 model group dispatch가 기존 provider/capacity 기준으로 성공하는지 검증한다.
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: end-to-end rejected refresh preservation test 추가.
- [ ] `apps/edge/internal/service/service_test.go` 또는 새 refresh test: provider dispatch preservation helper 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: `/v1/models` preservation assertion 추가.
### 테스트 작성
- 작성: `TestRefreshRejectedProviderPreservesModelsAndDispatch`.
- 작성: 실패 hook 사용 시 `TestRefreshApplyHookFailurePreservesModelsAndDispatch`.
- invalid candidate는 provider capacity 음수, duplicate provider id, 또는 catalog가 존재하지 않는 provider를 참조하는 fixture 중 하나를 사용한다.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai
```
예상: S10 회귀 테스트 PASS.
## REFRESH_APPLY-4 - Ops Report Surface
### 문제
refresh 결과는 operator가 바뀐 node/provider/model 항목과 최종 status를 확인할 수 있어야 한다. `02_refresh_contract`가 classifier 응답을 제공하더라도 S11 완료를 위해 API/CLI/log/event 중 최소 한 표면에 changed items가 검증되어야 한다.
### 해결 방법
- MVP report surface는 API/CLI JSON response로 고정한다.
- `configrefresh.Result``changed_nodes`, `changed_providers`, `changed_models`, `restart_required_paths`, `rejected_reason`을 추가하거나 기존 `changes[]`에서 stable derived fields를 노출한다.
- CLI는 API response를 그대로 pretty JSON 또는 stable JSON으로 출력한다. 테스트 안정성을 위해 key order가 필요한 assertion은 decode 후 field 비교로 한다.
- Runtime logger에는 `request_id`, `mode`, `status`, `changed_counts`를 남긴다. Log assertion은 필수 완료 근거가 아니며 API/CLI assertion을 S11 근거로 삼는다.
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/configrefresh/*.go`: report fields 추가.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: response에 report fields 포함.
- [ ] `apps/edge/internal/edgecmd/config.go`: CLI 출력에 report fields 유지.
- [ ] `apps/edge/internal/edgecmd/edgecmd_test.go`: changed node/provider/model response assertion 추가.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: API response report assertion 추가.
### 테스트 작성
- 작성: `TestRefreshAPIReportIncludesChangedItems`.
- 작성: `TestConfigRefreshCommandPrintsChangedItems`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap
```
예상: S11 API/CLI report assertions PASS.
## REFRESH_APPLY-5 - Final Evidence
### 문제
`atomic-apply``ops-report`는 코드 결과만으로는 완료 처리되지 않는다. 구현 에이전트가 review stub에 실제 검증 출력과 설계 결정을 남겨야 code-review가 Roadmap Completion 근거를 만들 수 있다.
### 해결 방법
- 최종 검증 명령을 모두 실행한다.
- `CODE_REVIEW-cloud-G08.md`의 구현 항목별 완료 여부, 구현 체크리스트, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과를 실제 내용으로 채운다.
- 02 plan과 API symbol이 달라진 경우 mapping을 기록한다.
### 수정 파일 및 체크리스트
- [ ] `agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/CODE_REVIEW-cloud-G08.md`: 구현 에이전트 소유 섹션 작성.
### 테스트 작성
- 별도 테스트 없음. 이 항목은 evidence 기록 단계다.
### 중간 검증
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: Edge, shared config, generated proto package가 모두 PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/bootstrap/runtime.go` | REFRESH_APPLY-1, REFRESH_APPLY-3 |
| `apps/edge/internal/bootstrap/refresh_admin.go` | REFRESH_APPLY-1, REFRESH_APPLY-4 |
| `apps/edge/internal/bootstrap/runtime_test.go` | REFRESH_APPLY-1, REFRESH_APPLY-3, REFRESH_APPLY-4 |
| `apps/edge/internal/configrefresh/*.go` | REFRESH_APPLY-1, REFRESH_APPLY-4 |
| `apps/edge/internal/node/store.go` | REFRESH_APPLY-1 |
| `apps/edge/internal/service/service.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/service/run_dispatch.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/service/status_provider.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/service/model_queue.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/service/*_test.go` | REFRESH_APPLY-2, REFRESH_APPLY-3 |
| `apps/edge/internal/openai/server.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/openai/routes.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/openai/chat_handler.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/openai/server_test.go` | REFRESH_APPLY-2, REFRESH_APPLY-3 |
| `apps/edge/internal/edgecmd/config.go` | REFRESH_APPLY-4 |
| `apps/edge/internal/edgecmd/edgecmd_test.go` | REFRESH_APPLY-4 |
| `agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/CODE_REVIEW-cloud-G08.md` | REFRESH_APPLY-5 |
## 최종 검증
```bash
find agent-task -path '*/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log' -print -quit
```
예상: active 또는 archived 02 predecessor complete.log 경로가 한 줄 출력된다.
```bash
go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/edgecmd
```
예상: atomic apply, OpenAI model surface, provider dispatch, CLI/API report targeted tests PASS.
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: 모든 Edge/config/proto generated package 테스트 PASS.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,114 @@
package bootstrap
import (
"context"
"encoding/json"
"net"
"net/http"
"time"
"go.uber.org/zap"
"iop/apps/edge/internal/configrefresh"
)
// RefreshAdminServer is the Edge-local HTTP admin server for config refresh.
// It listens on a loopback address and accepts POST /refresh requests from
// the local CLI (iop-edge config refresh). The server is only started when
// refresh.enabled=true in the Edge config.
type RefreshAdminServer struct {
listen string
runtime *Runtime
logger *zap.Logger
server *http.Server
ln net.Listener
}
func newRefreshAdminServer(listen string, rt *Runtime, logger *zap.Logger) *RefreshAdminServer {
return &RefreshAdminServer{
listen: listen,
runtime: rt,
logger: logger,
}
}
func (s *RefreshAdminServer) Start(_ context.Context) error {
ln, err := net.Listen("tcp", s.listen)
if err != nil {
return err
}
s.ln = ln
mux := http.NewServeMux()
mux.HandleFunc("/refresh", s.handleRefresh)
s.server = &http.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
go func() {
if err := s.server.Serve(ln); err != nil && err != http.ErrServerClosed {
s.logger.Warn("refresh admin server exited", zap.Error(err))
}
}()
s.logger.Info("refresh admin server listening", zap.String("addr", ln.Addr().String()))
return nil
}
func (s *RefreshAdminServer) Stop(ctx context.Context) error {
if s.server == nil {
return nil
}
return s.server.Shutdown(ctx)
}
// Addr returns the actual bound address. Useful for port-0 tests.
func (s *RefreshAdminServer) Addr() string {
if s.ln == nil {
return ""
}
return s.ln.Addr().String()
}
func (s *RefreshAdminServer) handleRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req configrefresh.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, configrefresh.Result{
Status: configrefresh.StatusRejected,
Mode: req.Mode,
Summary: "invalid request body: " + err.Error(),
Changes: []configrefresh.Change{},
})
return
}
result, err := s.runtime.RefreshConfig(r.Context(), req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, configrefresh.Result{
Status: configrefresh.StatusRejected,
Mode: req.Mode,
RequestID: req.RequestID,
Summary: "refresh error: " + err.Error(),
Changes: []configrefresh.Change{},
})
return
}
httpStatus := http.StatusOK
if result.Status == configrefresh.StatusRejected {
httpStatus = http.StatusUnprocessableEntity
}
writeJSON(w, httpStatus, result)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View file

@ -8,6 +8,7 @@ import (
"go.uber.org/zap"
"iop/apps/edge/internal/configrefresh"
edgecontrolplane "iop/apps/edge/internal/controlplane"
edgeevents "iop/apps/edge/internal/events"
edgeinput "iop/apps/edge/internal/input"
@ -34,7 +35,10 @@ type Runtime struct {
Input *edgeinput.Manager
Artifact *ArtifactServer
ControlPlane *edgecontrolplane.Connector
RefreshAdmin *RefreshAdminServer
refreshMu sync.Mutex
cfgMu sync.RWMutex
lifetimeMu sync.Mutex
lifetimeCancel context.CancelFunc
}
@ -72,7 +76,7 @@ func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
edgecontrolplane.WithNodeEventBus(bus),
)
return &Runtime{
rt := &Runtime{
Cfg: cfg,
Logger: logger,
Registry: registry,
@ -83,7 +87,9 @@ func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
Input: inputManager,
Artifact: artifactServer,
ControlPlane: connector,
}, nil
}
rt.RefreshAdmin = newRefreshAdminServer(cfg.Refresh.Listen, rt, logger.Named("refresh-admin"))
return rt, nil
}
func (r *Runtime) wireHandlers() {
@ -124,6 +130,16 @@ func (r *Runtime) Start(ctx context.Context) error {
_ = r.Server.Stop()
return err
}
if r.Cfg.Refresh.Enabled {
if err := r.RefreshAdmin.Start(lifetimeCtx); err != nil {
r.cancelLifetime()
r.ControlPlane.Stop()
_ = r.Artifact.Stop(context.Background())
_ = r.Input.Stop(context.Background())
_ = r.Server.Stop()
return err
}
}
r.startMetrics()
return nil
}
@ -133,10 +149,14 @@ func (r *Runtime) Stop() error {
r.ControlPlane.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
refreshErr := r.RefreshAdmin.Stop(ctx)
inputErr := r.Input.Stop(ctx)
artifactErr := r.Artifact.Stop(ctx)
err := r.Server.Stop()
_ = r.Logger.Sync()
if err == nil {
err = refreshErr
}
if err == nil {
err = inputErr
}
@ -146,6 +166,52 @@ func (r *Runtime) Stop() error {
return err
}
// RefreshConfig evaluates a config refresh request against the current runtime
// config. In apply mode, mutable-only changes are committed to the runtime.
func (r *Runtime) RefreshConfig(ctx context.Context, req configrefresh.Request) (configrefresh.Result, error) {
r.refreshMu.Lock()
defer r.refreshMu.Unlock()
r.cfgMu.RLock()
current := r.Cfg
r.cfgMu.RUnlock()
result, candidate, err := configrefresh.Evaluate(ctx, current, req)
if err != nil {
return result, err
}
if req.Mode == configrefresh.ModeApply && result.Status == configrefresh.StatusApplied && candidate != nil {
if err := r.applyMutableConfig(candidate); err != nil {
result.Status = configrefresh.StatusRejected
result.Summary = "apply candidate config: " + err.Error()
if result.Changes == nil {
result.Changes = []configrefresh.Change{}
}
}
}
return result, nil
}
// applyMutableConfig commits a validated mutable-only candidate via copy-on-write
// replacement. Readers observe either the previous runtime snapshot or the new
// one, and validation/build failures leave the previous snapshot untouched.
func (r *Runtime) applyMutableConfig(candidate *config.EdgeConfig) error {
nextStore, err := edgenode.LoadFromConfig(candidate.Nodes)
if err != nil {
return err
}
r.cfgMu.Lock()
r.Cfg = candidate
r.NodeStore = nextStore
r.cfgMu.Unlock()
r.Server.SetNodeStore(nextStore)
r.Service.SetRuntimeConfig(nextStore, candidate.Models)
r.Input.SetModelCatalog(candidate.Models)
return nil
}
func (r *Runtime) newLifetimeContext() context.Context {
r.lifetimeMu.Lock()
defer r.lifetimeMu.Unlock()

View file

@ -1,15 +1,20 @@
package bootstrap
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"iop/apps/edge/internal/configrefresh"
"iop/packages/go/config"
)
@ -167,6 +172,352 @@ func TestNewRuntimeRejectsDuplicateToken(t *testing.T) {
}
}
// TestRefreshAdminDryRunAndApplyReachRunningRuntime verifies S06: a running
// Edge process can receive dry-run and apply refresh requests via the admin API
// and returns mode-specific results.
func TestRefreshAdminDryRunAndApplyReachRunningRuntime(t *testing.T) {
dir := t.TempDir()
serverAddr := freeTCPAddr(t)
// Write base and candidate YAMLs with explicit immutable field values so
// that config.LoadEdge produces configs with identical defaults on both sides.
// Only capacity differs between base and candidate.
baseYAML := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "0.0.0.0:18080"
artifact_dir: "artifacts"
logging:
level: "error"
refresh:
enabled: true
listen: "127.0.0.1:0"
openai:
listen: "0.0.0.0:18081"
a2a:
listen: "0.0.0.0:8081"
metrics:
port: 0
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`, serverAddr)
candidateYAML := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "0.0.0.0:18080"
artifact_dir: "artifacts"
logging:
level: "error"
refresh:
enabled: true
listen: "127.0.0.1:0"
openai:
listen: "0.0.0.0:18081"
a2a:
listen: "0.0.0.0:8081"
metrics:
port: 0
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 8
max_queue: 4
queue_timeout_ms: 5000
`, serverAddr)
basePath := filepath.Join(dir, "base.yaml")
if err := os.WriteFile(basePath, []byte(baseYAML), 0o600); err != nil {
t.Fatalf("write base: %v", err)
}
candidatePath := filepath.Join(dir, "candidate.yaml")
if err := os.WriteFile(candidatePath, []byte(candidateYAML), 0o600); err != nil {
t.Fatalf("write candidate: %v", err)
}
currentCfg, err := config.LoadEdge(basePath)
if err != nil {
t.Fatalf("load base config: %v", err)
}
// Apply serve-equivalent normalization so the current config matches
// what iop-edge serve would hold after loadRuntimeConfig.
currentCfg.Logging.Path = serveNormalizedLogPath()
currentCfg.Bootstrap.ArtifactDir = serveNormalizedArtifactDir(currentCfg.Bootstrap.ArtifactDir)
// Override fields that don't need to match between runtime and candidate.
currentCfg.Refresh.Enabled = true
currentCfg.Refresh.Listen = "127.0.0.1:0"
currentCfg.Logging.Level = "error"
rt, err := NewRuntime(currentCfg)
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
ctx := context.Background()
if err := rt.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
if err := rt.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
}()
adminAddr := rt.RefreshAdmin.Addr()
if adminAddr == "" {
t.Fatal("refresh admin addr is empty after Start")
}
adminURL := "http://" + adminAddr + "/refresh"
client := &http.Client{Timeout: 5 * time.Second}
postRefresh := func(t *testing.T, mode configrefresh.Mode) configrefresh.Result {
t.Helper()
body, _ := json.Marshal(configrefresh.Request{
Mode: mode,
ConfigPath: candidatePath,
RequestID: string(mode) + "-req",
})
resp, err := client.Post(adminURL, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST /refresh mode=%s: %v", mode, err)
}
defer resp.Body.Close()
var result configrefresh.Result
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode response: %v", err)
}
return result
}
// dry-run: process is reached, result has mode=dry_run.
dryResult := postRefresh(t, configrefresh.ModeDryRun)
if dryResult.Mode != configrefresh.ModeDryRun {
t.Errorf("dry-run: expected mode=%q, got %q", configrefresh.ModeDryRun, dryResult.Mode)
}
if dryResult.Status != configrefresh.StatusApplied {
t.Errorf("dry-run: expected status=%q (capacity change), got %q", configrefresh.StatusApplied, dryResult.Status)
}
// apply: process is reached, result has mode=apply and status=applied.
applyResult := postRefresh(t, configrefresh.ModeApply)
if applyResult.Mode != configrefresh.ModeApply {
t.Errorf("apply: expected mode=%q, got %q", configrefresh.ModeApply, applyResult.Mode)
}
if applyResult.Status != configrefresh.StatusApplied {
t.Errorf("apply: expected status=%q, got %q", configrefresh.StatusApplied, applyResult.Status)
}
// verify dry-run and apply have distinct observable results: the apply
// committed the capacity change, so the next dry-run shows no changes.
dryResult2 := postRefresh(t, configrefresh.ModeDryRun)
if len(dryResult2.Changes) != 0 {
t.Errorf("after apply, expected no diff changes on second dry-run, got %+v", dryResult2.Changes)
}
}
func serveNormalizedLogPath() string {
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "logs", "edge.log")
}
if wd, err := os.Getwd(); err == nil {
return filepath.Join(wd, "logs", "edge.log")
}
return "logs/edge.log"
}
func serveNormalizedArtifactDir(dir string) string {
if dir == "" {
dir = "artifacts"
}
if filepath.IsAbs(dir) {
return dir
}
bd := binaryDirForTest()
return filepath.Join(bd, dir)
}
func binaryDirForTest() string {
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe)
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
func TestRefreshApplyConcurrentRuntimeReaders(t *testing.T) {
dir := t.TempDir()
serverAddr := freeTCPAddr(t)
openAIAddr := freeTCPAddr(t)
writeConfig := func(t *testing.T, name string, capacity int, displayName string) string {
t.Helper()
path := filepath.Join(dir, name)
yaml := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "0.0.0.0:18080"
artifact_dir: "artifacts"
logging:
level: "error"
refresh:
enabled: false
listen: "127.0.0.1:19093"
openai:
enabled: true
listen: %q
adapter: "openai_compat"
target: ""
a2a:
listen: "0.0.0.0:8081"
metrics:
port: 0
models:
- id: "qwen3.6:35b"
display_name: %q
providers:
prov-a: "served-qwen"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
openai_compat_instances:
- name: "vllm-gpu"
enabled: true
provider: "vllm"
endpoint: "http://127.0.0.1:8000/v1"
providers:
- id: "prov-a"
type: "vllm"
category: "api"
adapter: "vllm-gpu"
models: ["served-qwen"]
health: "available"
capacity: %d
max_queue: 4
queue_timeout_ms: 5000
`, serverAddr, openAIAddr, displayName, capacity)
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return path
}
basePath := writeConfig(t, "base.yaml", 2, "Qwen Base")
candidatePath := writeConfig(t, "candidate.yaml", 8, "Qwen Candidate")
currentCfg, err := config.LoadEdge(basePath)
if err != nil {
t.Fatalf("load base config: %v", err)
}
// Apply serve-equivalent normalization.
currentCfg.Logging.Path = serveNormalizedLogPath()
currentCfg.Bootstrap.ArtifactDir = serveNormalizedArtifactDir(currentCfg.Bootstrap.ArtifactDir)
rt, err := NewRuntime(currentCfg)
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
if err := rt.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
}()
client := &http.Client{Timeout: 5 * time.Second}
start := make(chan struct{})
var wg sync.WaitGroup
errCh := make(chan error, 3)
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < 50; i++ {
result, err := rt.RefreshConfig(context.Background(), configrefresh.Request{
Mode: configrefresh.ModeApply,
ConfigPath: candidatePath,
RequestID: fmt.Sprintf("apply-%d", i),
})
if err != nil {
errCh <- err
return
}
if result.Status != configrefresh.StatusApplied {
errCh <- fmt.Errorf("refresh status=%s summary=%s changes=%+v", result.Status, result.Summary, result.Changes)
return
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < 100; i++ {
_ = rt.Service.ListNodeSnapshots()
}
}()
wg.Add(1)
go func() {
defer wg.Done()
<-start
url := "http://" + openAIAddr + "/v1/models"
for i := 0; i < 100; i++ {
resp, err := client.Get(url)
if err != nil {
errCh <- err
return
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errCh <- fmt.Errorf("GET /v1/models status=%d", resp.StatusCode)
return
}
}
}()
close(start)
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("concurrent refresh reader failed: %v", err)
}
}
}
func freeTCPAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")

View file

@ -0,0 +1,387 @@
package configrefresh
import (
"context"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"iop/apps/edge/internal/edgevalidate"
"iop/packages/go/config"
)
// applyRuntimeNormalization normalizes candidate config so that classification
// uses the same effective values as a running serve process.
func applyRuntimeNormalization(cfg *config.EdgeConfig) {
// Normalize logging.path must match edgecmd.ResolveEdgeLogPath exactly:
// - empty → resolve to binary-dir/logs/edge.log
// - absolute → preserve exactly
// - relative → preserve exactly (include ./ segments)
// Serve keeps explicit relative paths like "./logs/edge.log" unchanged,
// so classifying against a cleaned value produces false restart_required.
if cfg.Logging.Path == "" {
cfg.Logging.Path = defaultEdgeLogPath()
}
// NOTE: explicit absolute / relative paths are intentionally left untouched.
// Normalize bootstrap.artifact_dir resolve relative paths to absolute.
cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
}
func defaultEdgeLogPath() string {
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "logs", "edge.log")
}
if wd, err := os.Getwd(); err == nil {
return filepath.Join(wd, "logs", "edge.log")
}
return "logs/edge.log"
}
func resolveBootstrapArtifactDir(dir string) string {
if dir == "" {
dir = "artifacts"
}
if filepath.IsAbs(dir) {
return dir
}
return filepath.Join(binaryDir(), dir)
}
func binaryDir() string {
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe)
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
// LoadCandidate loads and validates an EdgeConfig from the given path, then
// applies the same runtime normalization that iop-edge serve uses. This
// ensures that classification compares effective (normalized) values and
// prevents false restart_required results when source-level YAML is
// identical but paths are relative or empty.
func LoadCandidate(path string) (*config.EdgeConfig, error) {
cfg, err := config.LoadEdge(path)
if err != nil {
return nil, err
}
if err := edgevalidate.ValidateEdgeConfig(cfg); err != nil {
return nil, err
}
applyRuntimeNormalization(cfg)
return cfg, nil
}
// providerKey identifies a provider entry for diffing.
type providerKey struct {
NodeKey string
Type string
Category config.Category
Adapter string
Models []string
Health string
Capacity int
MaxQueue int
QueueTimeoutMS int
LifecycleCapabilities []string
}
func buildProviderIndex(cfg *config.EdgeConfig) map[string]providerKey {
idx := make(map[string]providerKey)
for i, node := range cfg.Nodes {
nodeKey := nodeIdentity(node, i)
for _, p := range node.Providers {
idx[p.ID] = providerKey{
NodeKey: nodeKey,
Type: p.Type,
Category: p.Category,
Adapter: p.Adapter,
Models: append([]string(nil), p.Models...),
Health: p.Health,
Capacity: p.Capacity,
MaxQueue: p.MaxQueue,
QueueTimeoutMS: p.QueueTimeoutMS,
LifecycleCapabilities: append([]string(nil), p.LifecycleCapabilities...),
}
}
}
return idx
}
type nodeKey struct {
Alias string
Token string
AgentKind string
Adapters config.AdaptersConf
Runtime config.RuntimeConf
}
func buildNodeIndex(cfg *config.EdgeConfig) map[string]nodeKey {
idx := make(map[string]nodeKey, len(cfg.Nodes))
for i, node := range cfg.Nodes {
idx[nodeIdentity(node, i)] = nodeKey{
Alias: node.Alias,
Token: node.Token,
AgentKind: node.AgentKind,
Adapters: node.Adapters,
Runtime: node.Runtime,
}
}
return idx
}
func nodeIdentity(node config.NodeDefinition, index int) string {
if node.ID != "" {
return node.ID
}
if node.Alias != "" {
return node.Alias
}
return fmt.Sprintf("#%d", index)
}
func buildModelIndex(cfg *config.EdgeConfig) map[string]config.ModelCatalogEntry {
idx := make(map[string]config.ModelCatalogEntry, len(cfg.Models))
for _, model := range cfg.Models {
idx[model.ID] = model
}
return idx
}
func appendIfChanged[T comparable](changes *[]Change, path string, class Status, previous, next T) {
if previous == next {
return
}
*changes = append(*changes, Change{
Path: path,
Class: class,
Previous: fmt.Sprint(previous),
Next: fmt.Sprint(next),
})
}
func appendDeepIfChanged(changes *[]Change, path string, class Status, previous, next interface{}) {
if reflect.DeepEqual(previous, next) {
return
}
*changes = append(*changes, Change{
Path: path,
Class: class,
Previous: fmt.Sprintf("%v", previous),
Next: fmt.Sprintf("%v", next),
})
}
// Classify computes the diff between current and candidate configs and returns
// the classified change list and an overall status.
func Classify(current, candidate *config.EdgeConfig) Result {
var changes []Change
// --- restart_required fields ---
appendIfChanged(&changes, "edge.id", StatusRestartRequired, current.Edge.ID, candidate.Edge.ID)
appendIfChanged(&changes, "edge.name", StatusRestartRequired, current.Edge.Name, candidate.Edge.Name)
appendIfChanged(&changes, "server.listen", StatusRestartRequired, current.Server.Listen, candidate.Server.Listen)
appendIfChanged(&changes, "server.advertise_host", StatusRestartRequired, current.Server.AdvertiseHost, candidate.Server.AdvertiseHost)
appendIfChanged(&changes, "bootstrap.listen", StatusRestartRequired, current.Bootstrap.Listen, candidate.Bootstrap.Listen)
appendIfChanged(&changes, "bootstrap.artifact_base_url", StatusRestartRequired, current.Bootstrap.ArtifactBaseURL, candidate.Bootstrap.ArtifactBaseURL)
appendIfChanged(&changes, "bootstrap.artifact_dir", StatusRestartRequired, current.Bootstrap.ArtifactDir, candidate.Bootstrap.ArtifactDir)
appendDeepIfChanged(&changes, "tls", StatusRestartRequired, current.TLS, candidate.TLS)
appendDeepIfChanged(&changes, "logging", StatusRestartRequired, current.Logging, candidate.Logging)
appendIfChanged(&changes, "metrics.port", StatusRestartRequired, current.Metrics.Port, candidate.Metrics.Port)
appendDeepIfChanged(&changes, "console", StatusRestartRequired, current.Console, candidate.Console)
appendDeepIfChanged(&changes, "control_plane", StatusRestartRequired, current.ControlPlane, candidate.ControlPlane)
appendDeepIfChanged(&changes, "refresh", StatusRestartRequired, current.Refresh, candidate.Refresh)
appendDeepIfChanged(&changes, "openai", StatusRestartRequired, current.OpenAI, candidate.OpenAI)
appendDeepIfChanged(&changes, "a2a", StatusRestartRequired, current.A2A, candidate.A2A)
currentNodes := buildNodeIndex(current)
candidateNodes := buildNodeIndex(candidate)
for key, cur := range currentNodes {
next, exists := candidateNodes[key]
if !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[%q]", key),
Class: StatusRestartRequired,
Previous: "present",
Next: "absent",
})
continue
}
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].alias", key), StatusRestartRequired, cur.Alias, next.Alias)
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].token", key), StatusRestartRequired, cur.Token, next.Token)
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].agent_kind", key), StatusRestartRequired, cur.AgentKind, next.AgentKind)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[%q].adapters", key), StatusRestartRequired, cur.Adapters, next.Adapters)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[%q].runtime", key), StatusRestartRequired, cur.Runtime, next.Runtime)
}
for key := range candidateNodes {
if _, exists := currentNodes[key]; !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[%q]", key),
Class: StatusRestartRequired,
Previous: "absent",
Next: "present",
})
}
}
// --- mutable applied fields: provider capacity ---
currentProviders := buildProviderIndex(current)
candidateProviders := buildProviderIndex(candidate)
for provID, cur := range currentProviders {
next, exists := candidateProviders[provID]
if !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q]", provID),
Class: StatusRestartRequired,
Previous: "present",
Next: "absent",
})
continue
}
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].node", provID), StatusRestartRequired, cur.NodeKey, next.NodeKey)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].type", provID), StatusRestartRequired, cur.Type, next.Type)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].category", provID), StatusRestartRequired, cur.Category, next.Category)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].adapter", provID), StatusRestartRequired, cur.Adapter, next.Adapter)
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)
}
for provID := range candidateProviders {
if _, exists := currentProviders[provID]; !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q]", provID),
Class: StatusRestartRequired,
Previous: "absent",
Next: "present",
})
}
}
for provID, cp := range candidateProviders {
cur, exists := currentProviders[provID]
if !exists {
continue
}
if cur.Capacity != cp.Capacity {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q].capacity", provID),
Class: StatusApplied,
Previous: fmt.Sprintf("%d", cur.Capacity),
Next: fmt.Sprintf("%d", cp.Capacity),
})
}
if cur.MaxQueue != cp.MaxQueue {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q].max_queue", provID),
Class: StatusApplied,
Previous: fmt.Sprintf("%d", cur.MaxQueue),
Next: fmt.Sprintf("%d", cp.MaxQueue),
})
}
if cur.QueueTimeoutMS != cp.QueueTimeoutMS {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q].queue_timeout_ms", provID),
Class: StatusApplied,
Previous: fmt.Sprintf("%d", cur.QueueTimeoutMS),
Next: fmt.Sprintf("%d", cp.QueueTimeoutMS),
})
}
}
currentModels := buildModelIndex(current)
candidateModels := buildModelIndex(candidate)
for modelID, cur := range currentModels {
next, exists := candidateModels[modelID]
if !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("models[%q]", modelID),
Class: StatusApplied,
Previous: "present",
Next: "absent",
})
continue
}
appendIfChanged(&changes, fmt.Sprintf("models[%q].display_name", modelID), StatusApplied, cur.DisplayName, next.DisplayName)
appendDeepIfChanged(&changes, fmt.Sprintf("models[%q].providers", modelID), StatusApplied, cur.Providers, next.Providers)
}
for modelID := range candidateModels {
if _, exists := currentModels[modelID]; !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("models[%q]", modelID),
Class: StatusApplied,
Previous: "absent",
Next: "present",
})
}
}
sort.SliceStable(changes, func(i, j int) bool {
if changes[i].Path == changes[j].Path {
return changes[i].Class < changes[j].Class
}
return changes[i].Path < changes[j].Path
})
// Determine overall status: any restart_required wins over applied.
hasRestart := false
for _, c := range changes {
if c.Class == StatusRestartRequired {
hasRestart = true
break
}
}
overallStatus := StatusApplied
summary := "no changes detected"
if hasRestart {
overallStatus = StatusRestartRequired
summary = "some changes require a process restart"
} else if len(changes) > 0 {
summary = "all changes can be applied without restart"
}
return Result{
Status: overallStatus,
Changes: changes,
Summary: summary,
}
}
// Evaluate loads the candidate config from req.ConfigPath, validates it, and
// classifies changes against current. On success it also returns the loaded
// candidate so the caller can apply mutable changes in apply mode.
func Evaluate(_ context.Context, current *config.EdgeConfig, req Request) (Result, *config.EdgeConfig, error) {
if req.Mode != ModeDryRun && req.Mode != ModeApply {
return Result{
Status: StatusRejected,
Mode: req.Mode,
RequestID: req.RequestID,
Summary: fmt.Sprintf("invalid mode %q: must be dry_run or apply", req.Mode),
Changes: []Change{},
}, nil, nil
}
candidate, err := LoadCandidate(req.ConfigPath)
if err != nil {
return Result{
Status: StatusRejected,
Mode: req.Mode,
RequestID: req.RequestID,
Summary: fmt.Sprintf("load candidate config: %v", err),
Changes: []Change{},
}, nil, nil
}
result := Classify(current, candidate)
result.Mode = req.Mode
result.RequestID = req.RequestID
if result.Changes == nil {
result.Changes = []Change{}
}
return result, candidate, nil
}

View file

@ -0,0 +1,712 @@
package configrefresh_test
import (
"context"
"os"
"path/filepath"
"testing"
"iop/apps/edge/internal/configrefresh"
"iop/packages/go/config"
)
func writeYAML(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return path
}
// buildNormalizedCurrent creates a current config that mimics what a running
// serve process holds after loadRuntimeConfig normalization. Empty logging
// paths are resolved, explicit logging paths are preserved, and bootstrap
// artifact_dir is resolved to the effective runtime path.
func buildNormalizedCurrent(t *testing.T, path string) *config.EdgeConfig {
t.Helper()
cfg, err := config.LoadEdge(path)
if err != nil {
t.Fatalf("load current: %v", err)
}
// Apply the same normalization that loadRuntimeConfig performs.
// NOTE: explicit relative logging.path is preserved exactly to match
// edgecmd.ResolveEdgeLogPath only empty paths are resolved.
if cfg.Logging.Path == "" {
cfg.Logging.Path = _normalizedLogPath()
}
// Explicit absolute / relative paths are intentionally left untouched.
cfg.Bootstrap.ArtifactDir = _normalizeArtifactDir(cfg.Bootstrap.ArtifactDir)
return cfg
}
func _normalizedLogPath() string {
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "logs", "edge.log")
}
if wd, err := os.Getwd(); err == nil {
return filepath.Join(wd, "logs", "edge.log")
}
return "logs/edge.log"
}
func _normalizeArtifactDir(dir string) string {
if dir == "" {
dir = "artifacts"
}
if filepath.IsAbs(dir) {
return dir
}
return filepath.Join(binaryDirForTesting(), dir)
}
func binaryDirForTesting() string {
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe)
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
const baseEdgeYAML = `
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"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
// TestClassifyProviderCapacityApplied verifies S07: provider capacity change is classified as applied.
func TestClassifyProviderCapacityApplied(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
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"
models: ["llama3.1"]
capacity: 8
max_queue: 4
queue_timeout_ms: 5000
`
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-s07",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusApplied {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
}
if len(result.Changes) == 0 {
t.Fatalf("expected at least one change")
}
found := false
for _, c := range result.Changes {
if c.Class == configrefresh.StatusApplied {
found = true
}
if c.Class == configrefresh.StatusRestartRequired {
t.Errorf("unexpected restart_required change: %s", c.Path)
}
}
if !found {
t.Errorf("no applied change found in: %+v", result.Changes)
}
}
// TestClassifyListenPortRestartRequired verifies S08: listen port change is classified as restart_required.
func TestClassifyListenPortRestartRequired(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
candidateYAML := `
server:
listen: "0.0.0.0:19090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-s08",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusRestartRequired {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRestartRequired, result.Status, result.Summary)
}
found := false
for _, c := range result.Changes {
if c.Path == "server.listen" && c.Class == configrefresh.StatusRestartRequired {
found = true
}
}
if !found {
t.Errorf("server.listen restart_required change not found in: %+v", result.Changes)
}
}
// TestRefreshInvalidYAMLRejected verifies S09: invalid YAML is classified as rejected.
func TestRefreshInvalidYAMLRejected(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
invalidPath := writeYAML(t, dir, "invalid.yaml", "nodes: [\n - bad: [unterminated")
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: invalidPath,
RequestID: "test-s09-yaml",
})
if err != nil {
t.Fatalf("Evaluate should not return error for rejected config: %v", err)
}
if result.Status != configrefresh.StatusRejected {
t.Errorf("expected status=%q, got %q", configrefresh.StatusRejected, result.Status)
}
if candidate != nil {
t.Errorf("expected nil candidate for rejected result")
}
}
func TestClassifyModelCatalogProviderMappingApplied(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"
models: ["llama3.1"]
capacity: 2
- id: "prov-b"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
models:
- id: "qwen3.6:35b"
display_name: "Qwen"
providers:
prov-a: "llama3.1"
`
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"
models: ["llama3.1"]
capacity: 2
- id: "prov-b"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
models:
- id: "qwen3.6:35b"
display_name: "Qwen"
providers:
prov-b: "llama3.1"
`
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-model-catalog",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusApplied {
t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusApplied, result.Status, result.Changes)
}
found := false
for _, change := range result.Changes {
if change.Path == `models["qwen3.6:35b"].providers` && change.Class == configrefresh.StatusApplied {
found = true
}
}
if !found {
t.Fatalf("model catalog providers change not found in %+v", result.Changes)
}
}
func TestClassifyProviderStructuralChangeRestartRequired(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
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: "vllm"
category: "api"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
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-provider-structural",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusRestartRequired {
t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusRestartRequired, result.Status, result.Changes)
}
found := false
for _, change := range result.Changes {
if change.Path == `nodes[].providers["prov-a"].type` && change.Class == configrefresh.StatusRestartRequired {
found = true
}
}
if !found {
t.Fatalf("provider structural type change not found in %+v", result.Changes)
}
}
// TestRefreshInvalidProviderRejected verifies S09: invalid provider config is classified as rejected.
func TestRefreshInvalidProviderRejected(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
// Invalid: shared validator rejects enabled openai_compat without endpoint.
invalidProviderYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
openai_compat:
enabled: true
endpoint: ""
`
invalidPath := writeYAML(t, dir, "invalid_provider.yaml", invalidProviderYAML)
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: invalidPath,
RequestID: "test-s09-provider",
})
if err != nil {
t.Fatalf("Evaluate should not return error for rejected config: %v", err)
}
if result.Status != configrefresh.StatusRejected {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRejected, result.Status, result.Summary)
}
if candidate != nil {
t.Errorf("expected nil candidate for rejected result")
}
}
// TestClassifyServeNormalizedRefreshApplied verifies the archived bug:
// when current config is serve-normalized (logging.path absolute,
// bootstrap.artifact_dir absolute) and candidate uses source-level defaults
// (empty logging.path, relative bootstrap.artifact_dir) with only provider
// capacity changes, the result must be applied, NOT restart_required.
func TestClassifyServeNormalizedRefreshApplied(t *testing.T) {
dir := t.TempDir()
// Source-level YAML with empty logging.path and relative artifact_dir.
// Only capacity differs (2 → 8).
sourceYAML := `
server:
listen: "0.0.0.0:9090"
bootstrap:
artifact_dir: artifacts
logging:
level: info
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
// Candidate: only capacity changes to 8; logging/artifact_dir are same source-level defaults.
candidateYAML := `
server:
listen: "0.0.0.0:9090"
bootstrap:
artifact_dir: artifacts
logging:
level: info
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 8
max_queue: 4
queue_timeout_ms: 5000
`
currentPath := writeYAML(t, dir, "current.yaml", sourceYAML)
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
// Build current as serve-normalized: explicit absolute paths.
current := buildNormalizedCurrent(t, currentPath)
// Verify that the current config actually has normalized paths.
if current.Logging.Path == "" {
t.Fatalf("expected normalized logging.path, got empty")
}
if current.Bootstrap.ArtifactDir == "" {
t.Fatalf("expected normalized bootstrap.artifact_dir, got empty")
}
// Evaluate the candidate via LoadCandidate (which now applies normalization).
ctx := context.Background()
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-serve-normalized",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
// Key assertion: capacity-only refresh must NOT be restart_required.
if result.Status != configrefresh.StatusApplied {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
}
// Ensure no restart_required changes for logging or bootstrap.artifact_dir.
for _, c := range result.Changes {
if c.Class == configrefresh.StatusRestartRequired {
t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
}
}
// Verify the capacity change is captured as applied.
foundCapacity := false
for _, c := range result.Changes {
if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
foundCapacity = true
if c.Previous != "2" || c.Next != "8" {
t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
}
}
}
if !foundCapacity {
t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
}
// Verify the candidate is also normalized.
if candidate == nil {
t.Fatalf("expected non-nil candidate")
}
if candidate.Logging.Path == "" {
t.Fatalf("expected normalized candidate logging.path, got empty")
}
}
// TestClassifyExplicitRelativeLoggingPathNoRestartRequired verifies that when
// both current and candidate use the same explicit relative logging.path
// (e.g. "./logs/edge.log") and only provider capacity differs, the refresh
// is classified as applied NOT restart_required.
//
// This is the regression test for the archived bug where applyRuntimeNormalization
// called filepath.Clean on explicit relative paths, producing a false diff
// against the serve-side value that preserves "./logs/edge.log" unchanged.
func TestClassifyExplicitRelativeLoggingPathNoRestartRequired(t *testing.T) {
dir := t.TempDir()
// Current config with explicit relative logging.path mimics what serve
// holds in memory after loading YAML with logging.path: "./logs/edge.log".
currentYAML := `
server:
listen: "0.0.0.0:9090"
logging:
path: "./logs/edge.log"
level: info
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
// Candidate: same explicit relative logging.path, only capacity changes.
candidateYAML := `
server:
listen: "0.0.0.0:9090"
logging:
path: "./logs/edge.log"
level: info
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 8
max_queue: 4
queue_timeout_ms: 5000
`
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
// buildNormalizedCurrent preserves the explicit relative logging.path.
current := buildNormalizedCurrent(t, currentPath)
// Verify the current config still has the explicit relative path.
if current.Logging.Path != "./logs/edge.log" {
t.Fatalf("expected current logging.path to remain './logs/edge.log', got %q", current.Logging.Path)
}
ctx := context.Background()
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-explicit-relative-logpath",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
// Key assertion: capacity-only refresh with same explicit relative path must be applied.
if result.Status != configrefresh.StatusApplied {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
}
// Ensure no restart_required changes at all.
for _, c := range result.Changes {
if c.Class == configrefresh.StatusRestartRequired {
t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
}
}
// Verify the capacity change is captured as applied.
foundCapacity := false
for _, c := range result.Changes {
if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
foundCapacity = true
if c.Previous != "2" || c.Next != "8" {
t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
}
}
}
if !foundCapacity {
t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
}
// Candidate should also preserve the explicit relative path.
if candidate == nil {
t.Fatalf("expected non-nil candidate")
}
if candidate.Logging.Path != "./logs/edge.log" {
t.Errorf("expected candidate logging.path to remain './logs/edge.log', got %q", candidate.Logging.Path)
}
}
// TestClassifyDryRunTwiceNoDiff verifies that after apply, re-running dry-run
// on the same candidate produces no diff.
func TestClassifyDryRunTwiceNoDiff(t *testing.T) {
dir := t.TempDir()
baseYAML := `
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"
models: ["llama3.1"]
capacity: 2
`
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"
models: ["llama3.1"]
capacity: 4
`
currentPath := writeYAML(t, dir, "current.yaml", baseYAML)
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
// First dry-run: should detect capacity change.
result1, cand1, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-first-dryrun",
})
if err != nil {
t.Fatalf("first Evaluate: %v", err)
}
if result1.Status != configrefresh.StatusApplied {
t.Fatalf("first dry-run: expected status=%q, got %q", configrefresh.StatusApplied, result1.Status)
}
// Second dry-run with the same candidate loaded again.
// The candidate path still exists, so LoadCandidate re-reads the same YAML.
result2, cand2, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-second-dryrun",
})
if err != nil {
t.Fatalf("second Evaluate: %v", err)
}
// Both results should be identical (same changes).
if len(result1.Changes) != len(result2.Changes) {
t.Fatalf("expected same number of changes, got %d vs %d: result1=%+v result2=%+v",
len(result1.Changes), len(result2.Changes), result1.Changes, result2.Changes)
}
// Both candidates should be normalized identically.
if cand1 == nil || cand2 == nil {
t.Fatalf("expected non-nil candidates")
}
if cand1.Logging.Path != cand2.Logging.Path {
t.Fatalf("candidate logging.path mismatch: %s vs %s", cand1.Logging.Path, cand2.Logging.Path)
}
}

View file

@ -0,0 +1,16 @@
package configrefresh
// Mode represents the refresh operation mode.
type Mode string
const (
ModeDryRun Mode = "dry_run"
ModeApply Mode = "apply"
)
// Request is the refresh operation request.
type Request struct {
Mode Mode `json:"mode"`
ConfigPath string `json:"config_path"`
RequestID string `json:"request_id,omitempty"`
}

View file

@ -0,0 +1,30 @@
package configrefresh
// Status is the overall classification of a refresh operation.
type Status string
const (
// StatusApplied means all detected changes are mutable and were (or would be) applied live.
StatusApplied Status = "applied"
// StatusRestartRequired means at least one change requires a process restart.
StatusRestartRequired Status = "restart_required"
// StatusRejected means the candidate config failed to load or validate.
StatusRejected Status = "rejected"
)
// Change describes one detected config diff and its classification.
type Change struct {
Path string `json:"path"`
Class Status `json:"class"`
Previous string `json:"previous,omitempty"`
Next string `json:"next,omitempty"`
}
// Result is the response from a refresh evaluate or apply operation.
type Result struct {
Status Status `json:"status"`
Mode Mode `json:"mode"`
RequestID string `json:"request_id,omitempty"`
Changes []Change `json:"changes"`
Summary string `json:"summary"`
}

View file

@ -1,15 +1,20 @@
package edgecmd
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"iop/apps/edge/internal/node"
"iop/apps/edge/internal/configrefresh"
"iop/apps/edge/internal/edgevalidate"
"iop/packages/go/config"
"iop/packages/go/hostsetup"
)
@ -43,7 +48,7 @@ Typical local flow:
iop-edge node register node-silicon-ollama --adapter ollama
iop-edge config check`,
}
c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd())
c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd(), configRefreshCmd())
return c
}
@ -125,90 +130,64 @@ func configCheckCmd() *cobra.Command {
}
}
func anyOllamaEnabled(a config.AdaptersConf) bool {
if a.Ollama.Enabled {
return true
func configRefreshCmd() *cobra.Command {
var mode string
var addr string
var cfgPath string
c := &cobra.Command{
Use: "refresh",
Short: "Send a config refresh request to a running Edge process",
Long: `Send a dry-run or apply config refresh request to the Edge-local admin API.
The running Edge process must have refresh.enabled=true in its config.`,
Example: ` iop-edge config refresh --mode dry-run
iop-edge config refresh --mode apply --config /path/to/edge.yaml`,
RunE: func(cmd *cobra.Command, _ []string) error {
var m configrefresh.Mode
switch mode {
case "dry-run", string(configrefresh.ModeDryRun):
m = configrefresh.ModeDryRun
case string(configrefresh.ModeApply):
m = configrefresh.ModeApply
default:
return fmt.Errorf("invalid --mode %q: must be dry-run or apply", mode)
}
// Resolve config path from flag or parent --config flag.
if cfgPath == "" {
resolved, err := ResolveConfigPath(cmd)
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}
cfgPath = resolved
}
req := configrefresh.Request{
Mode: m,
ConfigPath: cfgPath,
}
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post("http://"+addr+"/refresh", "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("POST http://%s/refresh: %w", addr, err)
}
defer resp.Body.Close()
var result configrefresh.Result
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(result)
},
}
for _, inst := range a.OllamaInstances {
if inst.Enabled {
return true
}
}
return false
}
func anyVllmEnabled(a config.AdaptersConf) bool {
if a.Vllm.Enabled {
return true
}
for _, inst := range a.VllmInstances {
if inst.Enabled {
return true
}
}
return false
}
func anyOpenAICompatEnabled(a config.AdaptersConf) bool {
if a.OpenAICompat.Enabled {
return true
}
for _, inst := range a.OpenAICompatInstances {
if inst.Enabled {
return true
}
}
return false
c.Flags().StringVar(&mode, "mode", "dry-run", "refresh mode: dry-run or apply")
c.Flags().StringVar(&addr, "addr", "127.0.0.1:19093", "refresh admin server address")
c.Flags().StringVar(&cfgPath, "config-path", "", "candidate config path (defaults to the resolved --config path)")
return c
}
func validateEdgeConfig(cfg *config.EdgeConfig) error {
_, err := node.LoadFromConfig(cfg.Nodes)
if err != nil {
return err
}
for i, n := range cfg.Nodes {
name := n.ID
if name == "" {
name = n.Alias
}
if name == "" {
name = fmt.Sprintf("index %d", i)
}
hasOllama := anyOllamaEnabled(n.Adapters)
hasVllm := anyVllmEnabled(n.Adapters)
hasOpenAICompat := anyOpenAICompatEnabled(n.Adapters)
if !hasOllama && !n.Adapters.CLI.Enabled && !hasVllm && !hasOpenAICompat {
return fmt.Errorf("node %q: at least one adapter must be enabled", name)
}
if n.Adapters.Ollama.Enabled && n.Adapters.Ollama.BaseURL == "" {
return fmt.Errorf("node %q: ollama adapter base_url must not be empty", name)
}
for j, inst := range n.Adapters.OllamaInstances {
if inst.Enabled && inst.BaseURL == "" {
return fmt.Errorf("node %q: ollama_instances[%d] %q: base_url must not be empty", name, j, inst.Name)
}
}
if n.Adapters.Vllm.Enabled && n.Adapters.Vllm.Endpoint == "" {
return fmt.Errorf("node %q: vllm adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.VllmInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: vllm_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Adapters.OpenAICompat.Enabled && n.Adapters.OpenAICompat.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.OpenAICompatInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Runtime.Concurrency < 0 {
return fmt.Errorf("node %q: runtime concurrency must be non-negative", name)
}
}
return nil
return edgevalidate.ValidateEdgeConfig(cfg)
}

View file

@ -2,6 +2,9 @@ package edgecmd
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@ -9,6 +12,7 @@ import (
"gopkg.in/yaml.v3"
"iop/apps/edge/internal/configrefresh"
"iop/packages/go/config"
)
@ -422,6 +426,83 @@ func TestValidateEdgeConfig_OpenAICompatInstanceEmptyEndpointRejected(t *testing
}
}
// TestConfigRefreshCommandPostsModeAndConfigPath verifies that the CLI refresh
// command sends a valid JSON request to the admin API and prints the JSON result.
func TestConfigRefreshCommandPostsModeAndConfigPath(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
writeMinimalEdgeYAML(t, cfgPath)
expectedResult := configrefresh.Result{
Status: configrefresh.StatusApplied,
Mode: configrefresh.ModeDryRun,
Summary: "no changes detected",
Changes: []configrefresh.Change{},
}
var capturedReq configrefresh.Request
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(expectedResult)
}))
defer ts.Close()
// Strip "http://" prefix for --addr flag.
addr := strings.TrimPrefix(ts.URL, "http://")
root := NewRoot(Options{})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "config", "refresh",
"--mode", "dry-run",
"--addr", addr,
"--config-path", cfgPath,
})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v\n%s", err, out.String())
}
if capturedReq.Mode != configrefresh.ModeDryRun {
t.Errorf("expected mode=%q, got %q", configrefresh.ModeDryRun, capturedReq.Mode)
}
if capturedReq.ConfigPath != cfgPath {
t.Errorf("expected config_path=%q, got %q", cfgPath, capturedReq.ConfigPath)
}
if !strings.Contains(out.String(), string(configrefresh.StatusApplied)) {
t.Errorf("expected status in output; got:\n%s", out.String())
}
}
// TestConfigRefreshCommandRejectsInvalidMode verifies that an invalid --mode
// flag returns an error before sending any HTTP request.
func TestConfigRefreshCommandRejectsInvalidMode(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
writeMinimalEdgeYAML(t, cfgPath)
root := NewRoot(Options{})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "config", "refresh",
"--mode", "invalid-mode",
"--addr", "127.0.0.1:0",
"--config-path", cfgPath,
})
err := root.Execute()
if err == nil {
t.Fatalf("expected error for invalid mode, got nil\n%s", out.String())
}
if !strings.Contains(err.Error(), "invalid --mode") {
t.Errorf("expected 'invalid --mode' in error, got: %v", err)
}
}
func TestValidateEdgeConfig_OpenAICompatLegacyEmptyEndpointRejected(t *testing.T) {
cfg := &config.EdgeConfig{
Nodes: []config.NodeDefinition{

View file

@ -0,0 +1,101 @@
package edgevalidate
import (
"fmt"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
)
// ValidateEdgeConfig validates Edge config fields that config.LoadEdge does
// not check: adapter endpoint presence, per-node adapter-enabled requirements,
// and node store consistency (token uniqueness). It is the shared validation
// path used by both `config check` and the refresh candidate loader so that
// the same ruleset applies regardless of the entry point.
func ValidateEdgeConfig(cfg *config.EdgeConfig) error {
_, err := edgenode.LoadFromConfig(cfg.Nodes)
if err != nil {
return err
}
for i, n := range cfg.Nodes {
name := n.ID
if name == "" {
name = n.Alias
}
if name == "" {
name = fmt.Sprintf("index %d", i)
}
hasOllama := anyOllamaEnabled(n.Adapters)
hasVllm := anyVllmEnabled(n.Adapters)
hasOpenAICompat := anyOpenAICompatEnabled(n.Adapters)
if !hasOllama && !n.Adapters.CLI.Enabled && !hasVllm && !hasOpenAICompat {
return fmt.Errorf("node %q: at least one adapter must be enabled", name)
}
if n.Adapters.Ollama.Enabled && n.Adapters.Ollama.BaseURL == "" {
return fmt.Errorf("node %q: ollama adapter base_url must not be empty", name)
}
for j, inst := range n.Adapters.OllamaInstances {
if inst.Enabled && inst.BaseURL == "" {
return fmt.Errorf("node %q: ollama_instances[%d] %q: base_url must not be empty", name, j, inst.Name)
}
}
if n.Adapters.Vllm.Enabled && n.Adapters.Vllm.Endpoint == "" {
return fmt.Errorf("node %q: vllm adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.VllmInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: vllm_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Adapters.OpenAICompat.Enabled && n.Adapters.OpenAICompat.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.OpenAICompatInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Runtime.Concurrency < 0 {
return fmt.Errorf("node %q: runtime concurrency must be non-negative", name)
}
}
return nil
}
func anyOllamaEnabled(a config.AdaptersConf) bool {
if a.Ollama.Enabled {
return true
}
for _, inst := range a.OllamaInstances {
if inst.Enabled {
return true
}
}
return false
}
func anyVllmEnabled(a config.AdaptersConf) bool {
if a.Vllm.Enabled {
return true
}
for _, inst := range a.VllmInstances {
if inst.Enabled {
return true
}
}
return false
}
func anyOpenAICompatEnabled(a config.AdaptersConf) bool {
if a.OpenAICompat.Enabled {
return true
}
for _, inst := range a.OpenAICompatInstances {
if inst.Enabled {
return true
}
}
return false
}

View file

@ -26,6 +26,13 @@ func NewManager(cfg config.EdgeConfig, svc *edgeservice.Service, logger *zap.Log
return &Manager{OpenAI: openaiServer, A2A: a2aServer}
}
func (m *Manager) SetModelCatalog(catalog []config.ModelCatalogEntry) {
if m == nil || m.OpenAI == nil {
return
}
m.OpenAI.SetModelCatalog(catalog)
}
func (m *Manager) Start(ctx context.Context) error {
if err := m.OpenAI.Start(ctx); err != nil {
return err

View file

@ -200,9 +200,11 @@ func (s *Server) findProviderPoolEntry(model string) *config.ModelCatalogEntry {
if model == "" {
return nil
}
for i := range s.modelCatalog {
if s.modelCatalog[i].ID == model {
return &s.modelCatalog[i]
modelCatalog := s.modelCatalogSnapshot()
for i := range modelCatalog {
if modelCatalog[i].ID == model {
entry := modelCatalog[i]
return &entry
}
}
return nil

View file

@ -26,9 +26,10 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
return
}
var modelIDs []string
if len(s.modelCatalog) > 0 {
modelCatalog := s.modelCatalogSnapshot()
if len(modelCatalog) > 0 {
// Provider pool catalog takes priority over legacy model_routes.
for _, entry := range s.modelCatalog {
for _, entry := range modelCatalog {
if id := strings.TrimSpace(entry.ID); id != "" {
modelIDs = append(modelIDs, id)
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"sync"
"time"
"go.uber.org/zap"
@ -20,6 +21,7 @@ type runService interface {
}
type Server struct {
mu sync.RWMutex
cfg config.EdgeOpenAIConf
modelCatalog []config.ModelCatalogEntry
service runService
@ -38,7 +40,32 @@ func NewServer(cfg config.EdgeOpenAIConf, svc runService, logger *zap.Logger) *S
// When set, /v1/models lists catalog IDs and requests matching catalog entries
// are dispatched via the provider pool instead of the legacy model_routes path.
func (s *Server) SetModelCatalog(catalog []config.ModelCatalogEntry) {
s.modelCatalog = catalog
s.mu.Lock()
s.modelCatalog = cloneModelCatalog(catalog)
s.mu.Unlock()
}
func (s *Server) modelCatalogSnapshot() []config.ModelCatalogEntry {
s.mu.RLock()
defer s.mu.RUnlock()
return cloneModelCatalog(s.modelCatalog)
}
func cloneModelCatalog(catalog []config.ModelCatalogEntry) []config.ModelCatalogEntry {
if len(catalog) == 0 {
return nil
}
out := make([]config.ModelCatalogEntry, len(catalog))
for i, entry := range catalog {
out[i] = entry
if entry.Providers != nil {
out[i].Providers = make(map[string]string, len(entry.Providers))
for k, v := range entry.Providers {
out[i].Providers[k] = v
}
}
}
return out
}
func (s *Server) Enabled() bool {

View file

@ -637,11 +637,11 @@ func TestResolveProviderPoolCandidatesFiltersInvalidProviders(t *testing.T) {
{
ID: "qwen3.6:35b",
Providers: map[string]string{
"prov-available": "served-qwen",
"prov-available": "served-qwen",
"prov-unavailable": "served-qwen",
"prov-mismatch": "served-qwen",
"prov-no-adapter": "served-qwen",
"prov-cap-zero": "served-qwen",
"prov-mismatch": "served-qwen",
"prov-no-adapter": "served-qwen",
"prov-cap-zero": "served-qwen",
"prov-cap-unknown": "served-qwen",
},
},
@ -761,7 +761,8 @@ func TestResolveProviderPoolCandidatesFiltersInvalidProviders(t *testing.T) {
ModelGroupKey: "qwen3.6:35b",
ProviderPool: true,
}
candidates, policy, err := svc.resolveProviderPoolCandidates(req)
storeSnapshot, catalogSnapshot := svc.runtimeConfigSnapshot()
candidates, policy, err := svc.resolveProviderPoolCandidates(req, storeSnapshot, catalogSnapshot)
if err != nil {
t.Fatalf("resolveProviderPoolCandidates: %v", err)
}

View file

@ -10,8 +10,8 @@ import (
"google.golang.org/protobuf/types/known/structpb"
edgenode "iop/apps/edge/internal/node"
eventpkg "iop/packages/go/events"
"iop/packages/go/config"
eventpkg "iop/packages/go/events"
iop "iop/proto/gen/iop"
)
@ -204,8 +204,9 @@ func (s *Service) submitRunQueued(ctx context.Context, req SubmitRunRequest) (Ru
// given request. Provider-pool requests are resolved via the model catalog;
// legacy requests are filtered by adapter/target capability.
func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode, groupPolicy, error) {
store, catalog := s.runtimeConfigSnapshot()
if req.ProviderPool {
return s.resolveProviderPoolCandidates(req)
return s.resolveProviderPoolCandidates(req, store, catalog)
}
if req.NodeRef != "" {
entry, err := s.ResolveNode(req.NodeRef)
@ -213,8 +214,8 @@ func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode,
return nil, groupPolicy{}, err
}
cap := defaultNodeCapacity
if s.nodeStore != nil {
if rec, ok := s.nodeStore.FindByID(entry.NodeID); ok {
if store != nil {
if rec, ok := store.FindByID(entry.NodeID); ok {
res := resolveAdapterForNode(rec, req.Adapter, req.Target)
if !res.supported {
msg := fmt.Sprintf("node %q does not support adapter %q target %q", entry.NodeID, req.Adapter, req.Target)
@ -226,7 +227,7 @@ func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode,
cap = res.capacity
}
}
policy := groupPolicyFromRequestOrStore(req, s.nodeStore, []*edgenode.NodeEntry{entry})
policy := groupPolicyFromRequestOrStore(req, store, []*edgenode.NodeEntry{entry})
return []candidateNode{{entry: entry, capacity: cap}}, policy, nil
}
@ -238,8 +239,8 @@ func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode,
var candidates []candidateNode
for _, entry := range all {
cap := defaultNodeCapacity
if s.nodeStore != nil {
rec, ok := s.nodeStore.FindByID(entry.NodeID)
if store != nil {
rec, ok := store.FindByID(entry.NodeID)
if ok {
res := resolveAdapterForNode(rec, req.Adapter, req.Target)
if !res.supported {
@ -258,7 +259,7 @@ func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode,
for i, c := range candidates {
entries[i] = c.entry
}
policy := groupPolicyFromRequestOrStore(req, s.nodeStore, entries)
policy := groupPolicyFromRequestOrStore(req, store, entries)
return candidates, policy, nil
}
@ -488,11 +489,11 @@ func isProviderAvailable(health string) bool {
// that matches req.ModelGroupKey, then assembles per-provider candidateNodes
// carrying the concrete served model name for target rewrite after admission.
// Filters: served-model membership, dispatch adapter presence, and available health.
func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest) ([]candidateNode, groupPolicy, error) {
func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest, store *edgenode.NodeStore, catalog []config.ModelCatalogEntry) ([]candidateNode, groupPolicy, error) {
var catalogEntry *config.ModelCatalogEntry
for i := range s.modelCatalog {
if s.modelCatalog[i].ID == req.ModelGroupKey {
catalogEntry = &s.modelCatalog[i]
for i := range catalog {
if catalog[i].ID == req.ModelGroupKey {
catalogEntry = &catalog[i]
break
}
}
@ -510,10 +511,10 @@ func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest) ([]candida
policySet := false
for _, entry := range all {
if s.nodeStore == nil {
if store == nil {
continue
}
rec, ok := s.nodeStore.FindByID(entry.NodeID)
rec, ok := store.FindByID(entry.NodeID)
if !ok {
continue
}

View file

@ -1,6 +1,8 @@
package service
import (
"sync"
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
@ -18,6 +20,7 @@ const (
// (node command transport), and control_command.go (Control Plane command
// execution).
type Service struct {
mu sync.RWMutex
registry *edgenode.Registry
events *edgeevents.Bus
nodeStore *edgenode.NodeStore
@ -36,7 +39,23 @@ func New(registry *edgenode.Registry, events *edgeevents.Bus) *Service {
}
func (s *Service) SetNodeStore(store *edgenode.NodeStore) {
s.mu.Lock()
s.nodeStore = store
s.mu.Unlock()
if s.queue != nil {
s.queue.setStore(store)
}
}
// SetRuntimeConfig atomically replaces the runtime config snapshot used by
// dispatch and status readers. A reader observes either the old node
// store/catalog pair or the new pair, never a mix.
func (s *Service) SetRuntimeConfig(store *edgenode.NodeStore, catalog []config.ModelCatalogEntry) {
catalog = cloneModelCatalog(catalog)
s.mu.Lock()
s.nodeStore = store
s.modelCatalog = catalog
s.mu.Unlock()
if s.queue != nil {
s.queue.setStore(store)
}
@ -45,7 +64,32 @@ func (s *Service) SetNodeStore(store *edgenode.NodeStore) {
// SetModelCatalog provides the top-level provider-pool model catalog to the
// service. Must be called before the first provider-pool SubmitRun.
func (s *Service) SetModelCatalog(catalog []config.ModelCatalogEntry) {
s.modelCatalog = catalog
s.mu.Lock()
s.modelCatalog = cloneModelCatalog(catalog)
s.mu.Unlock()
}
func (s *Service) runtimeConfigSnapshot() (*edgenode.NodeStore, []config.ModelCatalogEntry) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.nodeStore, cloneModelCatalog(s.modelCatalog)
}
func cloneModelCatalog(catalog []config.ModelCatalogEntry) []config.ModelCatalogEntry {
if len(catalog) == 0 {
return nil
}
out := make([]config.ModelCatalogEntry, len(catalog))
for i, entry := range catalog {
out[i] = entry
if entry.Providers != nil {
out[i].Providers = make(map[string]string, len(entry.Providers))
for k, v := range entry.Providers {
out[i].Providers[k] = v
}
}
}
return out
}
func (s *Service) ListNodes() []*edgenode.NodeEntry {

View file

@ -5,6 +5,7 @@ import (
"errors"
"net"
"strings"
"sync"
"testing"
"time"
@ -119,3 +120,94 @@ func TestSubmitRunModelQueueUsesRoutePolicyBeforeProviderInstancePolicy(t *testi
t.Error("timeout waiting for run2 goroutine to exit")
}
}
func TestRuntimeConfigSnapshotConcurrentReplace(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-refresh"})
svc := New(reg, nil)
newStore := func(providerID, servedModel string, capacity int) *edgenode.NodeStore {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-refresh",
Runtime: config.RuntimeConf{Concurrency: capacity},
Providers: []config.NodeProviderConf{
{
ID: providerID,
Type: "vllm",
Category: config.CategoryAPI,
Adapter: "vllm-gpu",
Models: []string{servedModel},
Health: "available",
Capacity: capacity,
},
},
})
return store
}
newCatalog := func(providerID, servedModel string) []config.ModelCatalogEntry {
return []config.ModelCatalogEntry{
{
ID: "qwen3.6:35b",
Providers: map[string]string{
providerID: servedModel,
},
},
}
}
storeA := newStore("prov-a", "served-a", 2)
catalogA := newCatalog("prov-a", "served-a")
storeB := newStore("prov-b", "served-b", 4)
catalogB := newCatalog("prov-b", "served-b")
svc.SetRuntimeConfig(storeA, catalogA)
const readers = 8
const iterations = 200
start := make(chan struct{})
errCh := make(chan error, readers)
var wg sync.WaitGroup
for i := 0; i < readers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for j := 0; j < iterations; j++ {
store, catalog := svc.runtimeConfigSnapshot()
candidates, _, err := svc.resolveProviderPoolCandidates(SubmitRunRequest{
ModelGroupKey: "qwen3.6:35b",
ProviderPool: true,
}, store, catalog)
if err != nil {
errCh <- err
return
}
if len(candidates) != 1 {
errCh <- errors.New("expected exactly one provider candidate")
return
}
_ = svc.ListNodeSnapshots()
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < iterations; i++ {
svc.SetRuntimeConfig(storeA, catalogA)
svc.SetRuntimeConfig(storeB, catalogB)
}
}()
close(start)
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("runtime config snapshot reader observed inconsistent state: %v", err)
}
}
}

View file

@ -23,6 +23,7 @@ type NodeSnapshot struct {
// current registry contents.
func (s *Service) ListNodeSnapshots() []NodeSnapshot {
entries := s.registry.All()
store, _ := s.runtimeConfigSnapshot()
out := make([]NodeSnapshot, 0, len(entries))
for _, entry := range entries {
snap := NodeSnapshot{
@ -33,8 +34,8 @@ func (s *Service) ListNodeSnapshots() []NodeSnapshot {
LifecycleState: entry.LifecycleState,
}
var rec *edgenode.NodeRecord
if s.nodeStore != nil {
if r, ok := s.nodeStore.FindByID(entry.NodeID); ok {
if store != nil {
if r, ok := store.FindByID(entry.NodeID); ok {
rec = r
if payload, err := edgenode.BuildConfigPayload(r); err == nil {
snap.Config = payload

View file

@ -49,6 +49,7 @@ type Server struct {
tcp *toki.TcpServer
listen string
registry *edgenode.Registry
nodeStoreMu sync.RWMutex
nodeStore *edgenode.NodeStore
logger *zap.Logger
handlerMu sync.RWMutex
@ -93,6 +94,18 @@ func (s *Server) Start(ctx context.Context) error {
return nil
}
func (s *Server) SetNodeStore(store *edgenode.NodeStore) {
s.nodeStoreMu.Lock()
s.nodeStore = store
s.nodeStoreMu.Unlock()
}
func (s *Server) nodeStoreSnapshot() *edgenode.NodeStore {
s.nodeStoreMu.RLock()
defer s.nodeStoreMu.RUnlock()
return s.nodeStore
}
func (s *Server) Stop() error {
s.stopping.Store(true)
return s.tcp.Stop()
@ -142,7 +155,11 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
func(req *iop.RegisterRequest) (*iop.RegisterResponse, error) {
rec, ok := s.nodeStore.FindByToken(req.GetToken())
nodeStore := s.nodeStoreSnapshot()
if nodeStore == nil {
return &iop.RegisterResponse{Accepted: false, Reason: "edge node store unavailable"}, nil
}
rec, ok := nodeStore.FindByToken(req.GetToken())
if !ok {
s.logger.Warn("unknown token", zap.String("token_prefix", safePrefix(req.GetToken())))
s.emitNodeEvent(events.NewEdgeNodeEvent(

View file

@ -36,6 +36,12 @@ control_plane:
wire_addr: ""
reconnect_interval_sec: 5
# refresh enables the Edge-local admin API used by `iop-edge config refresh`.
# Keep it loopback-only unless an operator-specific access control layer is added.
refresh:
enabled: false
listen: "127.0.0.1:19093"
a2a:
enabled: false
listen: "0.0.0.0:8081"

View file

@ -36,6 +36,14 @@ type ReconnectConf struct {
MaxAttempts int `mapstructure:"max_attempts" yaml:"max_attempts"`
}
// EdgeRefreshConf configures the Edge-local refresh admin HTTP server.
// When Enabled is false (default) the admin server is not started and
// config refresh is not available without a restart.
type EdgeRefreshConf struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
Listen string `mapstructure:"listen" yaml:"listen"`
}
type EdgeConfig struct {
Edge EdgeInfo `mapstructure:"edge" yaml:"edge"`
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
@ -47,6 +55,7 @@ type EdgeConfig struct {
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
Console EdgeConsoleConf `mapstructure:"console" yaml:"console"`
ControlPlane EdgeControlPlaneConf `mapstructure:"control_plane" yaml:"control_plane"`
Refresh EdgeRefreshConf `mapstructure:"refresh" yaml:"refresh,omitempty"`
Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"`
// Models is the top-level model catalog. Each entry defines a canonical
// routing key (ID) and its provider-pool mapping. When set it takes
@ -784,4 +793,6 @@ func setEdgeDefaults(v *viper.Viper) {
v.SetDefault("control_plane.enabled", false)
v.SetDefault("control_plane.wire_addr", "")
v.SetDefault("control_plane.reconnect_interval_sec", 5)
v.SetDefault("refresh.enabled", false)
v.SetDefault("refresh.listen", "127.0.0.1:19093")
}

View file

@ -43,6 +43,43 @@ func TestLoadEdge_EdgeIdentityEmptyByDefault(t *testing.T) {
}
}
func TestEdgeRefreshDefaults(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
if err := os.WriteFile(f, []byte("server:\n listen: \"0.0.0.0:9090\"\n"), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Refresh.Enabled {
t.Fatalf("expected refresh.enabled=false by default, got true")
}
if cfg.Refresh.Listen != "127.0.0.1:19093" {
t.Fatalf("expected refresh.listen=%q by default, got %q", "127.0.0.1:19093", cfg.Refresh.Listen)
}
}
func TestEdgeRefreshUnmarshal(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := "server:\n listen: \"0.0.0.0:9090\"\nrefresh:\n enabled: true\n listen: \"127.0.0.1:0\"\n"
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if !cfg.Refresh.Enabled {
t.Fatalf("expected refresh.enabled=true")
}
if cfg.Refresh.Listen != "127.0.0.1:0" {
t.Fatalf("expected refresh.listen=%q, got %q", "127.0.0.1:0", cfg.Refresh.Listen)
}
}
func TestLoadEdge_ConsoleTimeoutDefault(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")

View file

@ -332,6 +332,9 @@ func TestEdgeSpecConfigTemplateUsesServiceWritableLogPath(t *testing.T) {
"control_plane:",
"wire_addr:",
"reconnect_interval_sec:",
"refresh:",
"enabled: false",
"listen: \"127.0.0.1:19093\"",
"metrics:",
"port: 19092",
}
@ -352,6 +355,9 @@ func TestEdgeBundleConfigTemplateKeepsEmptyLogPath(t *testing.T) {
"control_plane:",
"wire_addr:",
"reconnect_interval_sec:",
"refresh:",
"enabled: false",
"listen: \"127.0.0.1:19093\"",
"metrics:",
"port: 19092",
}

View file

@ -34,6 +34,10 @@ control_plane:
wire_addr: ""
reconnect_interval_sec: 5
refresh:
enabled: false
listen: "127.0.0.1:19093"
nodes: []
`
@ -70,6 +74,10 @@ control_plane:
wire_addr: ""
reconnect_interval_sec: 5
refresh:
enabled: false
listen: "127.0.0.1:19093"
nodes: []
`