From 445716cc3e816f364490ebc525a6db4272585917 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 21 Jun 2026 21:35:50 +0900 Subject: [PATCH] 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 --- agent-roadmap/ROADMAP.md | 8 +- .../PHASE.md | 4 +- .../runtime-reconnect-config-refresh.md | 14 +- .../code_review_cloud_G07_0.log | 265 +++++++ .../code_review_cloud_G07_1.log | 259 +++++++ .../code_review_local_G04_3.log | 238 ++++++ .../code_review_local_G05_2.log | 243 ++++++ .../02_refresh_contract/complete.log | 50 ++ .../02_refresh_contract/plan_cloud_G07_0.log | 374 +++++++++ .../02_refresh_contract/plan_cloud_G07_1.log | 161 ++++ .../02_refresh_contract/plan_local_G04_3.log | 133 ++++ .../02_refresh_contract/plan_local_G05_2.log | 131 ++++ .../CODE_REVIEW-cloud-G08.md | 190 +++++ .../PLAN-cloud-G08.md | 450 +++++++++++ apps/edge/internal/bootstrap/refresh_admin.go | 114 +++ apps/edge/internal/bootstrap/runtime.go | 70 +- apps/edge/internal/bootstrap/runtime_test.go | 351 +++++++++ apps/edge/internal/configrefresh/classify.go | 387 ++++++++++ .../internal/configrefresh/classify_test.go | 712 ++++++++++++++++++ apps/edge/internal/configrefresh/request.go | 16 + apps/edge/internal/configrefresh/result.go | 30 + apps/edge/internal/edgecmd/config.go | 147 ++-- apps/edge/internal/edgecmd/edgecmd_test.go | 81 ++ apps/edge/internal/edgevalidate/validate.go | 101 +++ apps/edge/internal/input/manager.go | 7 + apps/edge/internal/openai/chat_handler.go | 8 +- apps/edge/internal/openai/routes.go | 5 +- apps/edge/internal/openai/server.go | 29 +- .../edge/internal/service/model_queue_test.go | 11 +- apps/edge/internal/service/run_dispatch.go | 29 +- apps/edge/internal/service/service.go | 46 +- .../internal/service/service_internal_test.go | 92 +++ apps/edge/internal/service/status_provider.go | 5 +- apps/edge/internal/transport/server.go | 19 +- configs/edge.yaml | 6 + packages/go/config/config.go | 11 + packages/go/config/config_test.go | 37 + packages/go/hostsetup/setup_test.go | 6 + packages/go/hostsetup/templates.go | 8 + 39 files changed, 4720 insertions(+), 128 deletions(-) create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G04_3.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G05_2.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G04_3.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G05_2.log create mode 100644 agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/CODE_REVIEW-cloud-G08.md create mode 100644 agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/PLAN-cloud-G08.md create mode 100644 apps/edge/internal/bootstrap/refresh_admin.go create mode 100644 apps/edge/internal/configrefresh/classify.go create mode 100644 apps/edge/internal/configrefresh/classify_test.go create mode 100644 apps/edge/internal/configrefresh/request.go create mode 100644 apps/edge/internal/configrefresh/result.go create mode 100644 apps/edge/internal/edgevalidate/validate.go diff --git a/agent-roadmap/ROADMAP.md b/agent-roadmap/ROADMAP.md index 6aef52b..7645eaf 100644 --- a/agent-roadmap/ROADMAP.md +++ b/agent-roadmap/ROADMAP.md @@ -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차로 분리한다. diff --git a/agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md b/agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md index ae8f09e..58a1d6a 100644 --- a/agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md +++ b/agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md @@ -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 경계를 구현 가능한 계획으로 정리한다. diff --git a/agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md b/agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md index 542a929..d49c67a 100644 --- a/agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md +++ b/agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md @@ -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는 완료 근거가 없어 미완료로 유지한다. ## 범위 제외 diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log new file mode 100644 index 0000000..61b4c11 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log @@ -0,0 +1,265 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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는 트리거하지 않는다. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log new file mode 100644 index 0000000..c62459c --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log @@ -0,0 +1,259 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 `/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는 트리거하지 않는다. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G04_3.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G04_3.log new file mode 100644 index 0000000..508e400 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G04_3.log @@ -0,0 +1,238 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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을 진행한다. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G05_2.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G05_2.log new file mode 100644 index 0000000..a14fbd6 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G05_2.log @@ -0,0 +1,243 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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는 트리거하지 않는다. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log new file mode 100644 index 0000000..2afc2e6 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log new file mode 100644 index 0000000..8ab360e --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log @@ -0,0 +1,374 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log new file mode 100644 index 0000000..146ce7a --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log @@ -0,0 +1,161 @@ + + +# 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. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G04_3.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G04_3.log new file mode 100644 index 0000000..68b0346 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G04_3.log @@ -0,0 +1,133 @@ + + +# 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 `/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. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G05_2.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G05_2.log new file mode 100644 index 0000000..469d8e4 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G05_2.log @@ -0,0 +1,131 @@ + + +# 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. diff --git a/agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/CODE_REVIEW-cloud-G08.md b/agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/CODE_REVIEW-cloud-G08.md new file mode 100644 index 0000000..6b39ead --- /dev/null +++ b/agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/CODE_REVIEW-cloud-G08.md @@ -0,0 +1,190 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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. | diff --git a/agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/PLAN-cloud-G08.md b/agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/PLAN-cloud-G08.md new file mode 100644 index 0000000..5b613ed --- /dev/null +++ b/agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/PLAN-cloud-G08.md @@ -0,0 +1,450 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/bootstrap/refresh_admin.go b/apps/edge/internal/bootstrap/refresh_admin.go new file mode 100644 index 0000000..653d8d8 --- /dev/null +++ b/apps/edge/internal/bootstrap/refresh_admin.go @@ -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) +} diff --git a/apps/edge/internal/bootstrap/runtime.go b/apps/edge/internal/bootstrap/runtime.go index 3eeca9c..e72acf3 100644 --- a/apps/edge/internal/bootstrap/runtime.go +++ b/apps/edge/internal/bootstrap/runtime.go @@ -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() diff --git a/apps/edge/internal/bootstrap/runtime_test.go b/apps/edge/internal/bootstrap/runtime_test.go index c8dd4bf..016060a 100644 --- a/apps/edge/internal/bootstrap/runtime_test.go +++ b/apps/edge/internal/bootstrap/runtime_test.go @@ -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") diff --git a/apps/edge/internal/configrefresh/classify.go b/apps/edge/internal/configrefresh/classify.go new file mode 100644 index 0000000..3af862d --- /dev/null +++ b/apps/edge/internal/configrefresh/classify.go @@ -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 +} diff --git a/apps/edge/internal/configrefresh/classify_test.go b/apps/edge/internal/configrefresh/classify_test.go new file mode 100644 index 0000000..4c5a8a9 --- /dev/null +++ b/apps/edge/internal/configrefresh/classify_test.go @@ -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) + } +} diff --git a/apps/edge/internal/configrefresh/request.go b/apps/edge/internal/configrefresh/request.go new file mode 100644 index 0000000..f254115 --- /dev/null +++ b/apps/edge/internal/configrefresh/request.go @@ -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"` +} diff --git a/apps/edge/internal/configrefresh/result.go b/apps/edge/internal/configrefresh/result.go new file mode 100644 index 0000000..008cb23 --- /dev/null +++ b/apps/edge/internal/configrefresh/result.go @@ -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"` +} diff --git a/apps/edge/internal/edgecmd/config.go b/apps/edge/internal/edgecmd/config.go index 9050f81..3fb5b5a 100644 --- a/apps/edge/internal/edgecmd/config.go +++ b/apps/edge/internal/edgecmd/config.go @@ -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) } diff --git a/apps/edge/internal/edgecmd/edgecmd_test.go b/apps/edge/internal/edgecmd/edgecmd_test.go index efb2fd2..e55fdb6 100644 --- a/apps/edge/internal/edgecmd/edgecmd_test.go +++ b/apps/edge/internal/edgecmd/edgecmd_test.go @@ -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{ diff --git a/apps/edge/internal/edgevalidate/validate.go b/apps/edge/internal/edgevalidate/validate.go new file mode 100644 index 0000000..238a3a2 --- /dev/null +++ b/apps/edge/internal/edgevalidate/validate.go @@ -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 +} diff --git a/apps/edge/internal/input/manager.go b/apps/edge/internal/input/manager.go index 53b787f..aa8af52 100644 --- a/apps/edge/internal/input/manager.go +++ b/apps/edge/internal/input/manager.go @@ -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 diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 67a7714..cb4f291 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -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 diff --git a/apps/edge/internal/openai/routes.go b/apps/edge/internal/openai/routes.go index cff5e01..0210c58 100644 --- a/apps/edge/internal/openai/routes.go +++ b/apps/edge/internal/openai/routes.go @@ -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) } diff --git a/apps/edge/internal/openai/server.go b/apps/edge/internal/openai/server.go index 5834b51..0e1d82a 100644 --- a/apps/edge/internal/openai/server.go +++ b/apps/edge/internal/openai/server.go @@ -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 { diff --git a/apps/edge/internal/service/model_queue_test.go b/apps/edge/internal/service/model_queue_test.go index f42eadb..2079f9c 100644 --- a/apps/edge/internal/service/model_queue_test.go +++ b/apps/edge/internal/service/model_queue_test.go @@ -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) } diff --git a/apps/edge/internal/service/run_dispatch.go b/apps/edge/internal/service/run_dispatch.go index 70817c0..3a02aa5 100644 --- a/apps/edge/internal/service/run_dispatch.go +++ b/apps/edge/internal/service/run_dispatch.go @@ -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 } diff --git a/apps/edge/internal/service/service.go b/apps/edge/internal/service/service.go index e50412b..6304627 100644 --- a/apps/edge/internal/service/service.go +++ b/apps/edge/internal/service/service.go @@ -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 { diff --git a/apps/edge/internal/service/service_internal_test.go b/apps/edge/internal/service/service_internal_test.go index fef964d..89b1dc2 100644 --- a/apps/edge/internal/service/service_internal_test.go +++ b/apps/edge/internal/service/service_internal_test.go @@ -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) + } + } +} diff --git a/apps/edge/internal/service/status_provider.go b/apps/edge/internal/service/status_provider.go index 120c546..19ccace 100644 --- a/apps/edge/internal/service/status_provider.go +++ b/apps/edge/internal/service/status_provider.go @@ -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 diff --git a/apps/edge/internal/transport/server.go b/apps/edge/internal/transport/server.go index 60e6d95..ccc5719 100644 --- a/apps/edge/internal/transport/server.go +++ b/apps/edge/internal/transport/server.go @@ -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( diff --git a/configs/edge.yaml b/configs/edge.yaml index 405484f..066827e 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -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" diff --git a/packages/go/config/config.go b/packages/go/config/config.go index b15d176..2a920af 100644 --- a/packages/go/config/config.go +++ b/packages/go/config/config.go @@ -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") } diff --git a/packages/go/config/config_test.go b/packages/go/config/config_test.go index 8d008f7..8e58723 100644 --- a/packages/go/config/config_test.go +++ b/packages/go/config/config_test.go @@ -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") diff --git a/packages/go/hostsetup/setup_test.go b/packages/go/hostsetup/setup_test.go index 38acb59..3dab47e 100644 --- a/packages/go/hostsetup/setup_test.go +++ b/packages/go/hostsetup/setup_test.go @@ -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", } diff --git a/packages/go/hostsetup/templates.go b/packages/go/hostsetup/templates.go index a7cd5a5..aa43bb8 100644 --- a/packages/go/hostsetup/templates.go +++ b/packages/go/hostsetup/templates.go @@ -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: [] `