feat: runtime reconnect config refresh 구현

- edge node의 runtime 재연결 시 config refresh機制 구현
- node adapter에 config set 및 diff 기능 추가
- proto runtime에 config refresh 관련 스키마 추가
- edge transport server에 config refresh 핸들러 추가
- 관련 테스트 코드 및 archive 작업 기록 추가
This commit is contained in:
toki 2026-06-23 01:53:36 +09:00
parent 6706b5e1da
commit bf6b7de354
46 changed files with 7291 additions and 169 deletions

View file

@ -57,10 +57,10 @@ Node가 Edge 단절을 운영 가능한 transient failure로 처리하되, 무
운영 중 변경 가능한 설정과 재시작이 필요한 설정을 구분하고, refresh entrypoint가 validation과 diff 결과를 명확히 반환한다.
- [ ] [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`로 변경을 분류한다. 검증: provider capacity 변경은 applied, listen port 변경은 restart_required, invalid YAML은 rejected로 테스트된다.
- [ ] [atomic-apply] validation 또는 apply 실패 시 기존 routing/runtime 상태가 유지된다. 검증: 일부 invalid provider 설정을 포함한 refresh가 기존 `/v1/models`와 provider dispatch 상태를 깨지 않는다.
- [ ] [ops-report] refresh 결과가 CLI/API 응답, 로그, event 중 최소 한 표면에 남고 변경된 node/provider/model 항목을 확인할 수 있다.
- [x] [entrypoint] refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- [x] [diff-classify] config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다. 검증: provider capacity 변경은 applied, listen port 변경은 restart_required, invalid YAML은 rejected로 테스트된다.
- [x] [atomic-apply] validation 또는 apply 실패 시 기존 routing/runtime 상태가 유지된다. 검증: 일부 invalid provider 설정을 포함한 refresh가 기존 `/v1/models`와 provider dispatch 상태를 깨지 않는다.
- [x] [ops-report] refresh 결과가 CLI/API 응답, 로그, event 중 최소 한 표면에 남고 변경된 node/provider/model 항목을 확인할 수 있다.
### Epic: [node-config] Node config 재적용
@ -83,12 +83,12 @@ Edge가 refresh된 NodeConfigPayload를 연결 Node에 전달하고, Node는 변
- 상태: 없음
- 요청일: 없음
- 완료 근거: `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로 확인되었다.
- 완료 근거: `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로 확인되었다. `02_refresh_contract/complete.log``Roadmap Completion`에 따라 `entrypoint`, `diff-classify`가 PASS로 확인되었고, `03+02_edge_refresh_apply/complete.log``Roadmap Completion`에 따라 `atomic-apply`, `ops-report`가 PASS로 확인되었다.
- 검토 항목:
- [x] SDD gate가 승인되어 구현 잠금이 해제되었다
- [ ] 모든 기능 Task와 Task 안의 검증이 충족되었다
- [ ] 사용자가 field dev 환경 결과를 확인했다
- 리뷰 코멘트: refresh/node-config/field Task는 완료 근거가 없어 미완료로 유지한다.
- 리뷰 코멘트: refresh Epic Task는 완료 근거가 확인되어 완료 처리했다. node-config Task는 active plan `04+02_config_push_contract`, `05+04_node_adapter_diff`, `06+05_node_drain_runtime_config`으로 분할했으며 완료 근거가 없어 미완료로 유지한다. field Task도 완료 근거가 없어 미완료로 유지한다.
## 범위 제외
@ -105,5 +105,5 @@ Edge가 refresh된 NodeConfigPayload를 연결 Node에 전달하고, Node는 변
- 표준선(선택): Node는 Edge가 사라지는 상황을 transient disconnect로 보고 10초 간격 10회까지만 재접속한다. Edge config refresh는 validate-before-apply, diff reporting, restart_required 분류, previous-state preservation을 기본으로 한다.
- 선행 작업: Model Alias Provider Pool과 Provider Catalog
- 후속 작업: Update Plane 안정 프로토콜, Host-local Manager 기반 자체 업데이트, Edge/Node 롤아웃과 복구 정책
- 구현 분할 권고: `01_reconnect_supervisor`, `02_refresh_contract`, `03+02_edge_refresh_apply`, `04+02_node_config_apply`, `05+01,03,04_field_docs_smoke`
- 구현 분할 권고: `01_reconnect_supervisor`, `02_refresh_contract`, `03+02_edge_refresh_apply`, `04+02_config_push_contract`, `05+04_node_adapter_diff`, `06+05_node_drain_runtime_config`, `07+01,03,04,05,06_field_docs_smoke`
- 확인 필요: 없음

View file

@ -0,0 +1,256 @@
<!-- task=m-runtime-reconnect-config-refresh/04+02_config_push_contract plan=0 tag=NCPUSH -->
# Code Review Reference - NCPUSH
> **[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-22
task=m-runtime-reconnect-config-refresh/04+02_config_push_contract, plan=0, tag=NCPUSH
## Archive Evidence Snapshot
- `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log`: PASS. `entrypoint`, `diff-classify`만 Roadmap Completion으로 확인되었고, `push-contract`는 계획에서 제외되어 완료 근거가 없다.
- `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/complete.log`: PASS. `atomic-apply`, `ops-report`만 완료되었고, connected Node refresh push/ack는 제외되었다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `push-contract`: Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- 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/04+02_config_push_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [NCPUSH-1] Refresh Wire Contract | [x] |
| [NCPUSH-2] Edge Push And Result Reporting | [x] |
| [NCPUSH-3] Node Handler Ack/Failure Plumbing | [x] |
## 구현 체크리스트
- [x] `proto/iop/runtime.proto`에 backward-compatible refresh request/response 계약을 추가하고 `make proto`로 Go generated code를 갱신한다. 검증: generated proto와 `go test -count=1 ./packages/go/... ./proto/gen/...`가 통과한다.
- [x] Edge transport parser와 Server에 connected Node refresh payload push 메서드를 추가하고 node별 `applied|restart_required|failed|skipped` 결과를 `configrefresh.Result`에 담는다. 검증: Edge handler 테스트가 request/response 수집과 disconnected node skip을 확인한다.
- [x] Node transport session에 config refresh request listener를 추가하고 `transport.Handler`/`node.Node`가 ack/failure response를 반환하게 한다. 검증: Node session handler 테스트가 handler-ready, handler-missing, handler-error 응답을 확인한다.
- [x] 전체 계약 회귀로 `make proto`, `go test -count=1 ./apps/edge/...`, `go test -count=1 ./apps/node/...`, `go test -count=1 ./packages/go/... ./proto/gen/...`를 실행한다.
- [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/04+02_config_push_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/04+02_config_push_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- **`applyMutableConfig` 시그니처 변경**: 계획은 `ctx context.Context`, `candidate *config.EdgeConfig`, `changes []configrefresh.Change` 세 파라미터를 제안했고 그대로 구현했다. 반환 타입은 계획과 동일하게 `([]configrefresh.NodeResult, error)`.
- **`buildNodeConfigRefreshRequest` 헬퍼 추가**: `runtime.go` 내에 `buildNodeConfigRefreshRequest(candidate *config.EdgeConfig, changedPaths []string) *iop.NodeConfigRefreshRequest`를 추가했다. 이 plan 범위에서는 Node가 어차피 apply manager가 없으므로 `config` 필드를 채우지 않고 `changed_paths`만 전달한다. per-node config push는 S13-S15에서 다룬다.
- **`export_test.go` 추가**: `session_test.go` (package transport_test)가 unexported `newSession`을 테스트하려면 export 진입점이 필요하다. `apps/node/internal/transport/export_test.go`에 `ExportNewSession`을 추가했다. 계획에는 명시 없었으나 black-box test 구조에서 필수적이다.
- **`apps/node/internal/transport/parser.go`에 `NodeConfigRefreshRequest` parser 추가**: 계획에 명시되지 않았으나 `AddRequestListenerTyped`가 올바르게 동작하려면 parser map에 요청 타입이 등록되어야 한다. 누락 시 Edge가 push해도 Node가 요청을 파싱하지 못한다.
## 주요 설계 결정
- **`PushConfigRefresh` 시그니처**: `Server.PushConfigRefresh(ctx context.Context, req *iop.NodeConfigRefreshRequest) []configrefresh.NodeResult`. 하나의 공통 요청 객체를 모든 connected Node에 전송한다. per-node config 빌드는 후속 S13 플랜에서 담당한다.
- **Node가 nil client일 때 skipped**: registry에 등록된 entry라도 `Client == nil` 또는 `!Client.IsAlive()`이면 transport 시도 없이 `skipped`로 기록한다. Transport push 실패도 local config rollback 사유가 아니며 result로만 보고한다.
- **`OnConfigRefresh` 기본 응답**: 이 plan 범위에서는 apply manager가 없으므로 `changed_paths`가 있으면 `restart_required`, 없으면 `applied`(no-op)로 응답한다. 실제 adapter diff apply는 S13-S15에서 구현한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 기존 `RegisterResponse`/`NodeConfigPayload` field number가 변경되지 않았는지 확인한다.
- Edge refresh apply가 local config commit과 node result reporting을 구분하는지 확인한다.
- Node handler가 adapter diff 완료를 이 계획에서 과장해 `applied`로 보고하지 않는지 확인한다.
- `configrefresh.Result` rejected path의 slices가 nil/omitted 문제 없이 안정적인지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### NCPUSH-1 중간 검증
```text
$ make proto
protoc \
--go_out=. \
--go_opt=module=iop \
--proto_path=. \
proto/iop/runtime.proto \
proto/iop/node.proto \
proto/iop/control.proto \
proto/iop/job.proto
$ go test -count=1 ./packages/go/... ./proto/gen/...
ok iop/packages/go/audit 0.002s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.019s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.004s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.009s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
```
### NCPUSH-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/internal/transport
ok iop/apps/edge/internal/configrefresh 0.009s
ok iop/apps/edge/internal/bootstrap 0.253s
ok iop/apps/edge/internal/transport 2.039s
```
### NCPUSH-3 중간 검증
```text
$ go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node
ok iop/apps/node/internal/transport 5.345s
ok iop/apps/node/internal/node 0.119s
```
### 최종 검증
```text
$ make proto
protoc \
--go_out=. \
--go_opt=module=iop \
--proto_path=. \
proto/iop/runtime.proto \
proto/iop/node.proto \
proto/iop/control.proto \
proto/iop/job.proto
$ go test -count=1 ./packages/go/... ./proto/gen/...
ok iop/packages/go/audit 0.002s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.018s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.004s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.010s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
$ go test -count=1 ./apps/edge/...
ok iop/apps/edge/cmd/edge 0.032s
ok iop/apps/edge/internal/bootstrap 0.265s
ok iop/apps/edge/internal/configrefresh 0.010s
ok iop/apps/edge/internal/controlplane 4.450s
ok iop/apps/edge/internal/edgecmd 0.014s
? iop/apps/edge/internal/edgevalidate [no test files]
ok iop/apps/edge/internal/events 0.003s
ok iop/apps/edge/internal/input 0.005s
ok iop/apps/edge/internal/input/a2a 0.007s
ok iop/apps/edge/internal/node 0.006s
ok iop/apps/edge/internal/openai 1.508s
ok iop/apps/edge/internal/opsconsole 0.006s
ok iop/apps/edge/internal/service 0.441s
ok iop/apps/edge/internal/transport 2.040s
$ go test -count=1 ./apps/node/...
ok iop/apps/node/cmd/node 0.009s
ok iop/apps/node/internal/adapters 0.009s
ok iop/apps/node/internal/adapters/cli 46.804s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 39.866s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.008s
ok iop/apps/node/internal/adapters/openai_compat 0.007s
ok iop/apps/node/internal/adapters/vllm 0.007s
ok iop/apps/node/internal/bootstrap 0.706s
ok iop/apps/node/internal/node 0.118s
ok iop/apps/node/internal/router 0.004s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.036s
ok iop/apps/node/internal/terminal 0.550s
ok iop/apps/node/internal/transport 5.343s
```
---
> **[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 | Read only cited archive files if more detail is required. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check items as completed; final item is mandatory. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
| 검증 결과 | Implementing agent | Paste actual command output. |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Implementation deviation: Fail
- Verification trust: Pass
- Spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/transport/server.go:301`의 `PushConfigRefresh`가 `registry.All()`만 순회해서 실제로 연결되지 않은 configured node는 `node_results`에 `skipped`로 기록되지 않습니다. Edge disconnect listener는 registry entry를 제거하므로, 계획의 “Node가 연결되지 않았거나 registry entry/client가 없으면 `skipped`”와 SDD S12의 node별 결과 계약을 실제 disconnected 상태에서 만족하지 못합니다. `NodeStore`의 전체 record를 기준으로 결과를 만들고, registry에 live client가 없는 record는 `skipped`로 남기도록 고치며 해당 케이스를 테스트해야 합니다.
- Required: `apps/edge/internal/bootstrap/runtime.go:185`와 `apps/edge/internal/bootstrap/runtime.go:222`가 `configrefresh.Request.RequestID`를 refresh push로 전달하지 않고, `buildNodeConfigRefreshRequest`도 `apps/edge/internal/bootstrap/runtime.go:226`에서 `ChangedPaths`만 채웁니다. 새 proto 계약의 `request_id`/`config` 필드와 SDD S12의 “refresh payload ack/failure contract”를 Edge가 실제로 사용하지 않아서 Node ack correlation과 payload push 계약이 비어 있습니다. `req.RequestID`를 `NodeConfigRefreshRequest.RequestId`로 전파하고, refreshed `NodeStore` record에서 node별 `NodeConfigPayload`를 만들어 `Config`에 채우며, fake node test가 request id와 payload 내용을 검증하게 해야 합니다.
- 다음 단계: FAIL follow-up으로 동일 task 디렉터리에 다음 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다.

View file

@ -0,0 +1,235 @@
<!-- task=m-runtime-reconnect-config-refresh/04+02_config_push_contract plan=1 tag=REVIEW_NCPUSH -->
# Code Review Reference - REVIEW_NCPUSH
> **[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.
## 개요
date=2026-06-22
task=m-runtime-reconnect-config-refresh/04+02_config_push_contract, plan=1, tag=REVIEW_NCPUSH
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_cloud_G07_0.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/transport/server.go:301` only iterates `registry.All()`, so configured-but-disconnected nodes are omitted instead of reported as `skipped`.
- `apps/edge/internal/bootstrap/runtime.go:185`, `apps/edge/internal/bootstrap/runtime.go:222`, and `apps/edge/internal/bootstrap/runtime.go:226` do not propagate `configrefresh.Request.RequestID` or a node-specific `NodeConfigPayload` into `NodeConfigRefreshRequest`.
- Affected files: `apps/edge/internal/bootstrap/runtime.go`, `apps/edge/internal/bootstrap/runtime_test.go`, `apps/edge/internal/transport/server.go`, `apps/edge/internal/transport/server_test.go`, `apps/edge/internal/node/mapper.go`, `apps/edge/internal/node/store.go`.
- Verification evidence from failed loop: `make proto`, `go test -count=1 ./packages/go/... ./proto/gen/...`, `go test -count=1 ./apps/edge/...`, and `go test -count=1 ./apps/node/...` passed, but tests did not assert disconnected configured-node reporting, request id propagation, or payload contents.
- Narrow reread allowed: the two current archived log files listed above.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `push-contract`: Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- 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/04+02_config_push_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_NCPUSH-1] Configured Disconnected Node Results | [x] |
| [REVIEW_NCPUSH-2] Request ID And Payload Propagation | [x] |
## 구현 체크리스트
- [x] `Server.PushConfigRefresh`가 refreshed `NodeStore` record 전체를 기준으로 `node_results`를 만들고, registry에 없거나 live client가 없는 configured node를 `skipped`로 보고한다. 검증: Edge transport/runtime 테스트가 registry에 없는 configured node의 skipped result를 확인한다.
- [x] `configrefresh.Request.RequestID`와 node별 `NodeConfigPayload`를 `NodeConfigRefreshRequest`에 채워 connected node로 보내고, fake node 테스트가 request id와 updated payload 내용을 검증한다. 검증: Edge runtime test가 fake node에서 `request_id`와 payload capacity/config를 assert한다.
- [x] 전체 계약 회귀로 `make proto`, `go test -count=1 ./apps/edge/...`, `go test -count=1 ./apps/node/...`, `go test -count=1 ./packages/go/... ./proto/gen/...`를 실행한다.
- [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/04+02_config_push_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/04+02_config_push_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- **`TestRefreshConfigApplyIncludesNodeResults` 기대 status 변경**: 계획은 "applied"를 기대했으나, fake node의 현재 Node behavior가 non-empty payload/change에 대해 `restart_required`를 반환하도록 수정했다. 이는 PLAN의 "Node에는 아직 live apply manager가 없으므로 non-empty payload/change에 대해 `restart_required`로 응답하는 현 Node behavior는 유지" 기준에 부합한다. fake node responder가 `RESTART_REQUIRED`를 반환하고 result에서 `restart_required`를 assert하는 방식으로 변경했다.
- **`buildNodeConfigRefreshRequest` 시그니처에서 `candidate *config.EdgeConfig` 제거**: Plan은 `candidate`를 전달하되 내부에서 payload를 구성한다고 명시했지만, `PushConfigRefresh` 내부에서 `nodeStoreSnapshot()`을 통해 이미 최신 store에 접근 가능하므로 `candidate`를 넘길 필요가 없었다. `requestID`와 `changedPaths`만 넘기고 payload 구성은 `server.go`의 `PushConfigRefresh` 내부에서 record별로 수행한다.
- **`TestServerPushConfigRefreshSkippedWhenNodeDisconnected` 리팩터**: 기존 테스트는 registry에 nil client entry를 등록하는 방식이었으나, 실제 disconnect 경로(registry.Unregister 후 entry 없음)를 반영하도록 nodeStore에 node-1을 추가하고 registry에는 아무것도 등록하지 않는 방식으로 수정했다.
## 주요 설계 결정
- **PushConfigRefresh의 순회 기준을 `nodeStore.All()`로 전환**: `registry.All()`은 현재 연결된 node만 포함하므로, disconnect listener가 registry entry를 제거한 후 configured-but-disconnected node가 결과에서 사라지는 문제가 있었다. `nodeStore.All()`은 설정 파일에서 seed된 전체 configured node를 반환하므로, 각 record에 대해 `registry.Get(rec.ID)`로 live entry 존재 여부를 확인하는 방식으로 변경했다.
- **node별 payload를 `PushConfigRefresh` 내부에서 구성**: `PushConfigRefresh`가 이미 nodeStore snapshot에 접근하고 record 단위로 순회하므로, connected entry에만 `BuildConfigPayload(rec)`를 호출해 node-specific `NodeConfigRefreshRequest`를 구성하는 것이 자연스럽다. `PushConfigRefresh`의 외부 시그니처(`req *iop.NodeConfigRefreshRequest`)는 유지하면서 내부에서 `nodeReq.Config`를 채운다.
- **`TestRefreshConfigApplySkipsDisconnectedConfiguredNode` 추가**: PLAN REVIEW_NCPUSH-1이 요구하는 "connected + skipped 혼합 케이스"를 runtime integration level에서 검증하는 테스트를 추가했다. 두 node를 NodeStore에 설정하되 하나만 실제 연결하고, apply 결과에서 disconnected node가 `skipped`로, connected node가 `restart_required`로 보고되는지 assert한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 실제 disconnected configured node가 `node_results`에 `skipped`로 남는지 확인한다.
- connected node가 받는 `NodeConfigRefreshRequest`에 `request_id`와 node별 `config` payload가 채워지는지 확인한다.
- Node는 아직 live apply manager가 없으므로 non-empty refresh payload/change를 `restart_required`로 보고하는 현재 경계를 유지하는지 확인한다.
- `push-contract` Roadmap Completion은 S12 evidence인 proto generated code와 Edge/Node handler tests가 모두 충족될 때만 PASS할 수 있다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_NCPUSH-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/transport 2.037s
ok iop/apps/edge/internal/bootstrap 0.254s
```
### REVIEW_NCPUSH-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/transport 2.037s
ok iop/apps/edge/internal/bootstrap 0.254s
```
### 최종 검증
```text
$ make proto
protoc \
--go_out=. \
--go_opt=module=iop \
--proto_path=. \
proto/iop/runtime.proto \
proto/iop/node.proto \
proto/iop/control.proto \
proto/iop/job.proto
$ go test -count=1 ./apps/edge/...
ok iop/apps/edge/cmd/edge 0.041s
ok iop/apps/edge/internal/bootstrap 0.270s
ok iop/apps/edge/internal/configrefresh 0.029s
ok iop/apps/edge/internal/controlplane 4.449s
ok iop/apps/edge/internal/edgecmd 0.012s
? iop/apps/edge/internal/edgevalidate [no test files]
ok iop/apps/edge/internal/events 0.006s
ok iop/apps/edge/internal/input 0.006s
ok iop/apps/edge/internal/input/a2a 0.010s
ok iop/apps/edge/internal/node 0.005s
ok iop/apps/edge/internal/openai 1.508s
ok iop/apps/edge/internal/opsconsole 0.006s
ok iop/apps/edge/internal/service 0.441s
ok iop/apps/edge/internal/transport 2.043s
$ go test -count=1 ./apps/node/...
ok iop/apps/node/cmd/node 0.009s
ok iop/apps/node/internal/adapters 0.010s
ok iop/apps/node/internal/adapters/cli 46.697s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 39.803s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.009s
ok iop/apps/node/internal/adapters/openai_compat 0.006s
ok iop/apps/node/internal/adapters/vllm 0.007s
ok iop/apps/node/internal/bootstrap 0.687s
ok iop/apps/node/internal/node 0.121s
ok iop/apps/node/internal/router 0.005s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.030s
ok iop/apps/node/internal/terminal 0.547s
ok iop/apps/node/internal/transport 5.340s
$ go test -count=1 ./packages/go/... ./proto/gen/...
ok iop/packages/go/audit 0.002s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.021s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.004s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.008s
? 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 | Read only cited archive files if more detail is required. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check items as completed; final item is mandatory. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
| 검증 결과 | Implementing agent | Paste actual command output. |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Pass
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Implementation deviation: Fail
- Verification trust: Fail
- Spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/bootstrap/runtime_test.go:1004`는 "Payload must be present and contain the updated provider capacity (8)"라고 설명하지만, 실제 assertion은 `payload != nil`과 `len(payload.GetAdapters()) > 0`뿐입니다. 또한 이 테스트의 candidate 변경은 `nodes[].providers[].capacity`이고 현재 `NodeConfigPayload`에는 `NodeRecord.Providers`가 포함되지 않으므로, captured refresh request가 refreshed node record의 구체 config를 담았는지 또는 node별 payload construction이 맞는지 검증하지 못합니다. 이 상태로는 PLAN의 "fake node 테스트가 request id와 updated payload 내용을 검증한다" 체크리스트와 SDD S12의 refresh payload push evidence가 충족되지 않습니다. fake node/runtime test 또는 별도 `PushConfigRefresh` transport test에서 captured `NodeConfigRefreshRequest.Config`의 구체 필드(예: typed adapter config의 type/name/provider/endpoint/capacity, runtime workspace/concurrency 등)를 expected refreshed `NodeStore` record와 대조하고, `changed_paths`와 `request_id`도 함께 검증해야 합니다.
- 다음 단계: FAIL follow-up으로 동일 task 디렉터리에 다음 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다.

View file

@ -0,0 +1,201 @@
<!-- task=m-runtime-reconnect-config-refresh/04+02_config_push_contract plan=2 tag=REVIEW_REVIEW_NCPUSH -->
# Code Review Reference - REVIEW_REVIEW_NCPUSH
> **[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.
## 개요
date=2026-06-22
task=m-runtime-reconnect-config-refresh/04+02_config_push_contract, plan=2, tag=REVIEW_REVIEW_NCPUSH
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `push-contract`: Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_cloud_G07_1.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/bootstrap/runtime_test.go:1004` says the payload should contain updated provider capacity, but the test only asserts a non-nil payload with at least one adapter.
- The runtime test changes `nodes[].providers[].capacity`, while `NodeConfigPayload` currently carries adapter/runtime config, not `NodeRecord.Providers`, so the test does not prove refreshed node-specific payload contents.
- Affected files: `apps/edge/internal/bootstrap/runtime_test.go`; optionally `apps/edge/internal/transport/server_test.go` if a more focused `PushConfigRefresh` payload-capture test is clearer.
- Verification evidence from failed loop: `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap ./apps/node/internal/transport ./apps/node/internal/node` passed, and `make proto && go test -count=1 ./packages/go/... ./proto/gen/...` passed, but payload-content evidence remains incomplete.
- Narrow reread allowed: the two current archived log files listed above.
## 이 파일을 읽는 리뷰 에이전트에게
> **[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/04+02_config_push_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_NCPUSH-1] Payload Content Evidence | [x] |
## 구현 체크리스트
- [x] `TestRefreshConfigApplyIncludesNodeResults` 또는 별도 `PushConfigRefresh` 테스트가 captured `NodeConfigRefreshRequest.Config`의 구체 필드를 expected refreshed `NodeStore` record와 대조한다. 최소한 typed adapter config의 type/name/provider/endpoint/capacity 또는 runtime workspace/concurrency처럼 `NodeConfigPayload`에 실제 존재하는 필드를 assert한다.
- [x] 기존 runtime integration test의 `request_id`, `changed_paths`, connected node result, skipped disconnected node result 검증을 유지하고, `nodes[].providers[].capacity`가 `NodeConfigPayload` 필드인 것처럼 보이는 주석/기대 문구를 실제 계약에 맞게 고친다.
- [x] 회귀 검증으로 `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node`, `make proto`, `go test -count=1 ./packages/go/... ./proto/gen/...`를 실행한다.
- [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/04+02_config_push_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/04+02_config_push_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- 계획이 제시한 두 가지 옵션(runtime integration test 보강 vs. `server_test.go`에 focused 단위 테스트 추가) 중 전자를 택했다. 기존 `TestRefreshConfigApplyIncludesNodeResults`가 이미 fake node가 받은 `NodeConfigRefreshRequest`를 capture하고 있어, 같은 fixture에 payload-bearing 필드를 추가하면 `request_id`/`changed_paths`/node result 검증과 payload 내용 검증이 한 곳에서 end-to-end로 묶인다. 따라서 `server_test.go` 추가 테스트(선택 사항)는 만들지 않았다.
- 그 외 계획과의 차이 없음. 검증 명령은 계획에 명시된 고정 명령 그대로 실행했다.
## 주요 설계 결정
- 리뷰 FAIL의 핵심 원인은 fixture가 refresh trigger(`nodes[].providers[].capacity`)만 바꾸고, 정작 `NodeConfigPayload`에 실제로 담기는 필드는 검증하지 않은 점이었다. `NodeConfigPayload`는 `mapper.go:BuildConfigPayload`에서 보듯 `adapters`(mock + 활성 ollama/vllm/openai_compat 인스턴스 + cli)와 `runtime`(concurrency/workspace_root)만 담고, `NodeRecord.Providers`는 담지 않는다. 즉 provider capacity는 payload 필드가 아니다.
- 그래서 fixture의 node 블록에 payload에 실제로 들어가는 명시적 값을 추가했다: `openai_compat_instances`(name=`primary`, provider=`vllm`, endpoint=`http://127.0.0.1:9000/v1`, capacity=5)와 `runtime`(concurrency=7, workspace_root=`/tmp/iop-refresh-ws`). 기존 cli 어댑터와 provider capacity 변경(2→8)은 refresh trigger로 유지했다.
- 검증을 "non-empty payload"에서 "expected node-specific payload"로 격상했다: captured `NodeConfigRefreshRequest.Config`에서 runtime concurrency/workspace_root, openai_compat 어댑터의 provider/endpoint/capacity, cli 어댑터 존재를 concrete 값으로 assert한다. provider capacity가 payload 필드인 것처럼 오해시키던 주석(`updated provider capacity (8)`)은 "providers[].capacity는 changed_paths를 유발하는 trigger이며 payload 필드가 아니다"로 정정했다.
- `request_id`, node result(restart_required), disconnected node skip 검증은 기존 그대로 유지했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- captured `NodeConfigRefreshRequest.Config`가 non-empty 수준이 아니라 expected node-specific payload fields를 검증하는지 확인한다.
- `nodes[].providers[].capacity`가 `NodeConfigPayload` 자체에 들어가는 것처럼 오해시키는 주석이나 assertion이 남지 않았는지 확인한다.
- `request_id`, `changed_paths`, connected node result, disconnected configured node `skipped` result 검증이 유지되는지 확인한다.
- `push-contract` Roadmap Completion은 S12 evidence인 proto generated code와 Edge/Node handler tests가 모두 충족될 때만 PASS할 수 있다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_NCPUSH-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/transport 2.042s
ok iop/apps/edge/internal/bootstrap 0.253s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/transport 2.042s
ok iop/apps/edge/internal/bootstrap 0.253s
$ go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node
ok iop/apps/node/internal/transport 5.359s
ok iop/apps/node/internal/node 0.120s
$ make proto
protoc \
--go_out=. \
--go_opt=module=iop \
--proto_path=. \
proto/iop/runtime.proto \
proto/iop/node.proto \
proto/iop/control.proto \
proto/iop/job.proto
$ go test -count=1 ./packages/go/... ./proto/gen/...
ok iop/packages/go/audit 0.001s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.020s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.004s
? 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]
? 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 | Read only cited archive files if more detail is required.                              |
| 구현 항목별 완료 여부          | Implementing agent | Check `[ ]` to `[x]` only.                                             |
| 구현 체크리스트             | Implementing agent | Check items as completed; final item is mandatory.                                 |
| 계획 대비 변경 사항, 주요 설계 결정   | Implementing agent | Replace placeholder text with actual content.                                    |
| 사용자 리뷰 요청             | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation.                 |
| 검증 결과                | Implementing agent | Paste actual command output.                                            |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit.                                          |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Implementation deviation: Fail
- Verification trust: Pass
- Spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/bootstrap/runtime.go:184` treats every `StatusApplied` result as a reason to push node config, but `configrefresh.Classify` also returns `StatusApplied` when there are zero changes (`apps/edge/internal/configrefresh/classify.go:340`). `PushConfigRefresh` then always attaches a full `NodeConfigPayload` (`apps/edge/internal/transport/server.go:320`), and current Node behavior reports any non-nil config as `restart_required` (`apps/node/internal/node/node.go:274`). As a result, an apply refresh with an identical config and a connected node can report node `restart_required` even though no config changed and no node payload update is needed. Gate the node push so no-change apply returns no node push/results, or otherwise send a true no-op request that Node reports `applied`; add a connected-node regression test for identical candidate config.
- 다음 단계: FAIL follow-up으로 동일 task 디렉터리에 다음 `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성한다.

View file

@ -0,0 +1,206 @@
<!-- task=m-runtime-reconnect-config-refresh/04+02_config_push_contract plan=3 tag=REVIEW_REVIEW_REVIEW_NCPUSH -->
# Code Review Reference - REVIEW_REVIEW_REVIEW_NCPUSH
> **[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-22
task=m-runtime-reconnect-config-refresh/04+02_config_push_contract, plan=3, tag=REVIEW_REVIEW_REVIEW_NCPUSH
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `push-contract`: Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_cloud_G07_2.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_cloud_G07_2.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/bootstrap/runtime.go:184` treats every `StatusApplied` result as a node-push trigger.
- `configrefresh.Classify` returns `StatusApplied` even when there are zero changes (`apps/edge/internal/configrefresh/classify.go:340`).
- `apps/edge/internal/transport/server.go:320` always attaches a full `NodeConfigPayload`, and current Node behavior reports any non-nil config as `restart_required` (`apps/node/internal/node/node.go:274`).
- Therefore an apply refresh with an identical config and a connected node can report node `restart_required` even though no config changed and no node payload update is needed.
- Affected files: `apps/edge/internal/bootstrap/runtime.go`, `apps/edge/internal/bootstrap/runtime_test.go`; optionally `apps/edge/internal/transport/server.go` only if the no-op behavior is better handled at the transport request builder.
- Verification evidence from this review: `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node`, `make proto`, and `go test -count=1 ./packages/go/... ./proto/gen/...` all passed, so the blocker is an uncovered edge case rather than an existing test failure.
- Narrow reread allowed: the two current archived log files listed above.
## 이 파일을 읽는 리뷰 에이전트에게
> **[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/04+02_config_push_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_REVIEW_NCPUSH-1] No-Change Refresh Push Gate | [x] |
## 구현 체크리스트
- [x] no-change apply refresh가 connected node에 full `NodeConfigPayload`를 push해 `restart_required` node_result를 만들지 않도록 gate를 추가한다. 권장 기준: `len(result.Changes)==0`이면 local runtime snapshot은 유지/동일화하더라도 `PushConfigRefresh`를 호출하지 않고 `NodeResults`는 empty로 둔다.
- [x] connected fake node를 둔 regression test를 추가하거나 기존 runtime refresh test를 확장해, identical candidate config apply에서 fake node가 `NodeConfigRefreshRequest`를 받지 않거나 node result가 no-op/applied로 남는지 확인한다. 테스트는 기존 provider capacity refresh push 테스트의 request_id/payload assertion을 약화하지 않는다.
- [x] 회귀 검증으로 `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node`, `make proto`, `go test -count=1 ./packages/go/... ./proto/gen/...`를 실행한다.
- [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하는지 확인한다.
- [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/04+02_config_push_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/04+02_config_push_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획에서 제시한 `applyMutableConfig` 또는 호출부에 gate를 두는 방식과 동일하게 구현했다. 계획의 권장 기준(`len(result.Changes)==0`이면 `PushConfigRefresh` 호출 건너뛰기)을 그대로 따랐다.
## 주요 설계 결정
1. **Gate 위치**: `applyMutableConfig` 함수 내에서 local snapshot commit 이후, `PushConfigRefresh` 호출 이전에 gate를 뒀다. 노 변경 사항이 없을 때만 노 푸시를 건너뛰고, 로컬 런타임 스냅샷은 항상 갱신된다.
2. **반환값**: no-change 시 `nil, nil`을 반환해 `NodeResults`가 empty가 되도록 했다. 이는 `RefreshConfig` 호출부에서 `result.NodeResults = nodeResults`를 통해 empty slice 또는 nil로 설정된다.
3. **로깅**: no-change apply에서 `r.Logger.Debug("no-change apply refresh: skipping node push", ...)`로 디버깅 정보를 남겼다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- no-change apply refresh가 connected node에 full config payload를 보내 `restart_required` node_result를 만들지 않는지 확인한다.
- provider capacity refresh push 테스트의 request_id, concrete payload field assertion, connected/skipped node result 검증이 유지되는지 확인한다.
- `push-contract` Roadmap Completion은 S12 evidence인 proto generated code와 Edge/Node handler tests가 모두 충족될 때만 PASS할 수 있다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_REVIEW_NCPUSH-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/transport 2.043s
ok iop/apps/edge/internal/bootstrap 0.264s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/transport 2.043s
ok iop/apps/edge/internal/bootstrap 0.264s
$ go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node
ok iop/apps/node/internal/transport 5.350s
ok iop/apps/node/internal/node 0.121s
$ make proto
protoc \
--go_out=. \
--go_opt=module=iop \
--proto_path=. \
proto/iop/runtime.proto \
proto/iop/node.proto \
proto/iop/control.proto \
proto/iop/job.proto
$ go test -count=1 ./packages/go/... ./proto/gen/...
ok iop/packages/go/audit 0.004s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.021s
? 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.010s
? 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 | Read only cited archive files if more detail is required. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check items as completed; final item is mandatory. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
| 검증 결과 | Implementing agent | Paste actual command output. |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Implementation deviation: Pass
- Verification trust: Pass
- Spec conformance: Pass
- 발견된 문제: 없음
- 검증 확인:
- `make proto` PASS
- `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap` PASS
- `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node` PASS
- `go test -count=1 ./packages/go/... ./proto/gen/...` PASS
- `git diff --check` PASS
- 다음 단계: PASS이므로 active plan/review를 로그로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,48 @@
# Complete - m-runtime-reconnect-config-refresh/04+02_config_push_contract
## 완료 일시
2026-06-22
## 요약
Edge-to-Node config refresh push contract completed after 4 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_cloud_G07_0.log` | `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_cloud_G07_0.log` | FAIL | Disconnected configured nodes were not reported as skipped, and request_id/config payload were not pushed to Node. |
| `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_cloud_G07_1.log` | `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_cloud_G07_1.log` | FAIL | Payload evidence only checked non-empty config, not concrete node-specific config fields. |
| `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_cloud_G07_2.log` | `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_cloud_G07_2.log` | FAIL | No-change apply still pushed full node config and could report spurious restart_required. |
| `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_local_G05_3.log` | `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_local_G05_3.log` | PASS | No-change push gate, connected fake-node regression, proto generation, and Edge/Node handler tests verified. |
## 구현/정리 내용
- Added `NodeConfigRefreshRequest`, `NodeConfigRefreshResponse`, and `NodeConfigRefreshStatus` to the runtime proto contract and regenerated Go protobuf output.
- Wired Edge refresh apply to push node-specific config refresh requests with request id, changed paths, per-node payload, and per-node result reporting.
- Added Node transport/parser/session handling and Node fallback response semantics for config refresh requests.
- Added the final no-change apply gate so identical config apply commits locally but does not push a full node payload or produce spurious `restart_required` node results.
## 최종 검증
- `make proto` - PASS; protobuf Go generation completed without additional errors.
- `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap` - PASS; Edge transport/bootstrap refresh push tests passed.
- `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node` - PASS; Node transport/session and handler tests passed.
- `go test -count=1 ./packages/go/... ./proto/gen/...` - PASS; platform-common and generated proto packages passed.
- `git diff --check` - PASS; no whitespace errors.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Completed task ids:
- `push-contract`: PASS; evidence=`agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_local_G05_3.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_local_G05_3.log`; verification=`make proto`, `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node`, `go test -count=1 ./packages/go/... ./proto/gen/...`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,359 @@
<!-- task=m-runtime-reconnect-config-refresh/04+02_config_push_contract plan=0 tag=NCPUSH -->
# Plan - NCPUSH
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
현재 `NodeConfigPayload`는 최초 registration 응답에만 실리고, Edge refresh apply가 연결 Node에 새 payload를 보내는 request/ack 계약이 없다. Milestone의 `push-contract`와 SDD S12는 Edge가 refresh payload를 보내고 Node가 ack/failure를 protocol response로 돌려주는 경로를 요구한다. 이 계획은 wire contract와 Edge/Node transport plumbing까지만 완료하고, 실제 adapter diff/live apply는 후속 split plan에서 처리한다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Archive Evidence Snapshot
- `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log`: PASS. `entrypoint`, `diff-classify`만 Roadmap Completion으로 확인되었고, `push-contract`는 계획에서 제외되어 완료 근거가 없다.
- `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/complete.log`: PASS. `atomic-apply`, `ops-report`만 완료되었고, connected Node refresh push/ack는 제외되었다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `push-contract`: Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `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-ops/rules/project/domain/node/rules.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/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.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`
- `proto/iop/runtime.proto`
- `apps/edge/internal/transport/server.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/configrefresh/result.go`
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/node/registry.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/service/service.go`
- `apps/node/internal/transport/session.go`
- `apps/node/internal/transport/client.go`
- `apps/node/internal/bootstrap/module.go`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/router/router.go`
- `apps/node/internal/adapters/registry.go`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/runtime/types.go`
- `apps/edge/internal/transport/server_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/node/internal/transport/session_test.go`
- `apps/node/internal/transport/integration_test.go`
- `apps/node/internal/adapters/factory_internal_test.go`
- `apps/node/internal/adapters/adapters_blackbox_test.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/node/node_concurrency_integration_test.go`
- `Makefile`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- 상태: 승인됨, SDD 잠금 해제.
- 대상 Acceptance Scenario: S12 / Milestone Task `push-contract`.
- Evidence Map: S12는 "proto generated code plus Edge/Node handler tests"와 `push-contract`: refresh payload ack/failure contract verified를 요구한다.
- 구현 checklist는 S12를 만족하도록 proto request/response, generated Go code, Edge push method, Node request handler, handler tests를 포함한다. Adapter registry diff, drain, runtime config live apply는 S13-S15에 속하므로 이 계획에서 완료로 주장하지 않는다.
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md`를 읽었고, 경로상 `node-smoke`, `edge-smoke`, `platform-common-smoke`를 모두 매칭했다.
- 적용 규칙: Node 변경은 `go test ./apps/node/...`, Edge 변경은 `go test ./apps/edge/...`, proto 변경은 `make proto` 후 `go test ./packages/go/... ./proto/gen/...`.
- Makefile에서 `make proto`가 `proto/iop/runtime.proto`를 포함해 Go generated code를 갱신하는 것을 확인했다.
- full-cycle field 검증은 후속 field task 범위다. 이 계획은 unit/transport handler evidence로 종료한다.
### 테스트 커버리지 공백
- Proto refresh request/response: 현재 테스트 없음. `make proto`와 generated package compile을 추가 검증한다.
- Edge-to-Node refresh push: 현재 `apps/edge/internal/transport/server_test.go`는 registration 중심이고 refresh push 테스트가 없다. 연결 Node fake client 또는 transport integration test를 추가한다.
- Node refresh request handler: 현재 `apps/node/internal/transport/session_test.go`는 run/cancel/command 중심이고 config refresh request listener가 없다. request/response handler 테스트를 추가한다.
- 실제 adapter diff/live apply: 이 계획에서 의도적으로 제외하며 후속 `05+04_node_adapter_diff`가 테스트한다.
### 심볼 참조
- rename/remove 없음.
- 새 proto symbols: `NodeConfigRefreshRequest`, `NodeConfigRefreshResponse`, `NodeConfigRefreshStatus`.
- 새 handler method가 `transport.Handler`에 추가되면 call site는 `apps/node/internal/node/node.go`, tests의 fake handler들, session construction tests 전체를 갱신해야 한다.
### 분할 판단
- split decision policy를 먼저 평가했다. Protocol/API contract와 runtime apply/drain은 변경 소유권과 위험이 달라 다중 plan이 필요하다.
- 공유 task group: `m-runtime-reconnect-config-refresh`.
- sibling subtask:
- `04+02_config_push_contract`: predecessor `02`는 archive `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log`로 충족.
- `05+04_node_adapter_diff`: predecessor `04`는 현재 missing이며 이 plan PASS 후 진행.
- `06+05_node_drain_runtime_config`: predecessor `05`는 현재 missing이며 adapter diff PASS 후 진행.
### 범위 결정 근거
- 포함: protobuf wire contract, generated Go code, Edge connected-node push/response collection, Node transport handler plumbing, S12 handler tests.
- 제외: adapter registry diff application, old adapter drain, runtime concurrency/workspace live apply, field smoke, docs. 이들은 S13-S18이며 후속 split plan 대상이다.
- Edge refresh classifier의 node adapter/runtime restart_required 정책은 이 plan에서 최소 변경만 허용한다. 실제 applied 분류 전환은 Node apply가 구현되는 plan에서 다룬다.
### 빌드 등급
- lane/GNN: `cloud-G07`.
- 이유: protobuf public contract, Edge/Node transport interface, generated code, tests가 동시에 바뀌는 cross-domain 작업이다.
## 구현 체크리스트
- [ ] `proto/iop/runtime.proto`에 backward-compatible refresh request/response 계약을 추가하고 `make proto`로 Go generated code를 갱신한다. 검증: generated proto와 `go test -count=1 ./packages/go/... ./proto/gen/...`가 통과한다.
- [ ] Edge transport parser와 Server에 connected Node refresh payload push 메서드를 추가하고 node별 `applied|restart_required|failed|skipped` 결과를 `configrefresh.Result`에 담는다. 검증: Edge handler 테스트가 request/response 수집과 disconnected node skip을 확인한다.
- [ ] Node transport session에 config refresh request listener를 추가하고 `transport.Handler`/`node.Node`가 ack/failure response를 반환하게 한다. 검증: Node session handler 테스트가 handler-ready, handler-missing, handler-error 응답을 확인한다.
- [ ] 전체 계약 회귀로 `make proto`, `go test -count=1 ./apps/edge/...`, `go test -count=1 ./apps/node/...`, `go test -count=1 ./packages/go/... ./proto/gen/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 subtask는 `04+02_config_push_contract`이므로 predecessor `02`만 요구한다.
- predecessor 충족: `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log`.
- 이 plan PASS 전에는 `05+04_node_adapter_diff`와 `06+05_node_drain_runtime_config` 구현을 시작하지 않는다.
### [NCPUSH-1] Refresh Wire Contract
#### 문제
`proto/iop/runtime.proto:165`의 `RegisterResponse`만 `NodeConfigPayload`를 담고, `proto/iop/runtime.proto:174`의 `NodeConfigPayload`는 "edge pushes"라고 설명하지만 실제 refresh request/ack message가 없다.
Before `proto/iop/runtime.proto:165`:
```proto
message RegisterResponse {
bool accepted = 1;
string node_id = 2;
string alias = 3;
string reason = 4; // rejection reason
NodeConfigPayload config = 5;
}
```
#### 해결 방법
`RegisterResponse`와 `NodeConfigPayload` field number는 유지하고, 새 enum/message를 뒤에 추가한다. Response는 ack/failure/restart_required를 모두 표현해 S12를 만족시키고 후속 S13-S15가 같은 계약을 확장 없이 사용하게 한다.
After:
```proto
enum NodeConfigRefreshStatus {
NODE_CONFIG_REFRESH_STATUS_UNSPECIFIED = 0;
NODE_CONFIG_REFRESH_STATUS_APPLIED = 1;
NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED = 2;
NODE_CONFIG_REFRESH_STATUS_FAILED = 3;
NODE_CONFIG_REFRESH_STATUS_SKIPPED = 4;
}
message NodeConfigRefreshRequest {
string request_id = 1;
NodeConfigPayload config = 2;
repeated string changed_paths = 3;
}
message NodeConfigRefreshResponse {
string request_id = 1;
NodeConfigRefreshStatus status = 2;
repeated string restart_required_paths = 3;
string error = 4;
}
```
#### 수정 파일 및 체크리스트
- [ ] `proto/iop/runtime.proto`: 새 enum/message 추가, 기존 field number 변경 금지.
- [ ] `proto/gen/iop/runtime.pb.go`: `make proto` 결과만 반영.
- [ ] `Makefile` 수정 없음.
#### 테스트 작성
- 별도 unit test 대신 generated package compile을 검증한다. `platform-common-smoke`가 protobuf 원본 변경 시 `make proto`와 generated diff 확인을 요구한다.
#### 중간 검증
```bash
make proto
go test -count=1 ./packages/go/... ./proto/gen/...
```
기대 결과: protoc generation 성공, generated package compile 성공.
### [NCPUSH-2] Edge Push And Result Reporting
#### 문제
`apps/edge/internal/transport/server.go:26`의 parser map은 refresh response를 모른다. `apps/edge/internal/bootstrap/runtime.go:197`의 `applyMutableConfig`는 `Server.SetNodeStore`, service/input swap만 하고 connected Node에 새 payload를 보내지 않는다. `apps/edge/internal/configrefresh/result.go:40`의 `Result`에는 SDD의 `refresh.node_results` 필드가 없다.
Before `apps/edge/internal/bootstrap/runtime.go:197`:
```go
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
}
```
#### 해결 방법
`configrefresh.Result`에 `NodeResults []NodeResult`를 추가하고, apply mode에서 local copy-on-write commit 뒤 connected entries에 `NodeConfigRefreshRequest`를 보낸다. Transport push 실패는 local config rollback 사유로 삼지 않고 node result로 보고한다. Node가 연결되지 않았거나 registry entry/client가 없으면 `skipped`를 기록한다.
After:
```go
type NodeResult struct {
NodeID string `json:"node_id"`
Alias string `json:"alias,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
RestartRequiredPaths []string `json:"restart_required_paths,omitempty"`
}
result, candidate, err := configrefresh.Evaluate(ctx, current, req)
...
if req.Mode == configrefresh.ModeApply && result.Status == configrefresh.StatusApplied && candidate != nil {
nodeResults, err := r.applyMutableConfig(ctx, candidate, result.Changes)
if err != nil {
return configrefresh.RejectedResult(req, "apply candidate config: "+err.Error()), nil
}
result.NodeResults = nodeResults
}
```
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/configrefresh/result.go`: `NodeResult`, `Result.NodeResults`, rejected result non-nil slice 추가.
- [ ] `apps/edge/internal/transport/server.go`: parser map에 `NodeConfigRefreshResponse` 추가, `PushConfigRefresh` 메서드 추가.
- [ ] `apps/edge/internal/bootstrap/runtime.go`: apply mode에서 node push 결과를 result에 병합.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: apply result에 node results가 포함되는지 확인.
- [ ] `apps/edge/internal/transport/server_test.go` 또는 integration test: response/skip/failure 경로 확인.
#### 테스트 작성
- `TestRefreshConfigApplyIncludesNodeResults`: connected fake node가 ack하면 result JSON 구조에 status가 남는지 확인.
- `TestServerPushConfigRefreshSkippedWhenNodeDisconnected`: registry/client가 없으면 `skipped`를 반환하는지 확인.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/internal/transport
```
기대 결과: refresh result와 transport push tests PASS.
### [NCPUSH-3] Node Handler Ack/Failure Plumbing
#### 문제
`apps/node/internal/transport/session.go:15`의 `Handler`는 run/cancel/command만 처리한다. `apps/node/internal/transport/session.go:71`의 request listener도 `NodeCommandRequest`뿐이라 Edge가 config refresh request를 보내도 Node가 response를 만들 수 없다.
Before `apps/node/internal/transport/session.go:15`:
```go
type Handler interface {
OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
OnCommandRequest(ctx context.Context, sess *Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error)
}
```
#### 해결 방법
`Handler`에 `OnConfigRefresh`를 추가하고 session에 typed request listener를 등록한다. `node.Node`의 초기 구현은 payload nil/invalid는 `failed`, 현재 apply manager가 없는 변경은 `restart_required`, no-op 또는 후속 apply manager가 처리한 경우 `applied`로 응답할 수 있는 구조로 둔다. 실제 adapter registry update는 NCPUSH에서 하지 않는다.
After:
```go
type Handler interface {
OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
OnCommandRequest(ctx context.Context, sess *Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error)
OnConfigRefresh(ctx context.Context, sess *Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error)
}
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/transport/session.go`: parser map/listener/handler error response 추가.
- [ ] `apps/node/internal/node/node.go`: `OnConfigRefresh` method 추가. Adapter diff 적용은 TODO가 아니라 explicit `restart_required` response로 제한한다.
- [ ] `apps/node/internal/transport/session_test.go`: handler success/missing/error cases 추가.
- [ ] handler fake를 가진 기존 Node/transport tests compile 갱신.
#### 테스트 작성
- `TestSessionConfigRefreshRequestReturnsHandlerResponse`: request가 handler까지 도달하고 response가 반환된다.
- `TestSessionConfigRefreshRequestHandlerErrorReturnsFailure`: handler error가 protocol failure response로 변환된다.
- `TestNodeConfigRefreshWithoutApplyManagerReportsRestartRequired`: 실제 apply 미구현 변경은 restart_required로 보고된다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node
```
기대 결과: session request/response와 Node handler tests PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `proto/iop/runtime.proto` | NCPUSH-1 |
| `proto/gen/iop/runtime.pb.go` | NCPUSH-1 |
| `apps/edge/internal/configrefresh/result.go` | NCPUSH-2 |
| `apps/edge/internal/transport/server.go` | NCPUSH-2 |
| `apps/edge/internal/bootstrap/runtime.go` | NCPUSH-2 |
| `apps/edge/internal/bootstrap/runtime_test.go` | NCPUSH-2 |
| `apps/edge/internal/transport/server_test.go` | NCPUSH-2 |
| `apps/node/internal/transport/session.go` | NCPUSH-3 |
| `apps/node/internal/node/node.go` | NCPUSH-3 |
| `apps/node/internal/transport/session_test.go` | NCPUSH-3 |
| `apps/node/internal/node/node_test.go` | NCPUSH-3 |
## 최종 검증
```bash
make proto
go test -count=1 ./packages/go/... ./proto/gen/...
go test -count=1 ./apps/edge/...
go test -count=1 ./apps/node/...
```
기대 결과: proto generation 성공, platform-common/edge/node package tests PASS. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,129 @@
<!-- task=m-runtime-reconnect-config-refresh/04+02_config_push_contract plan=1 tag=REVIEW_NCPUSH -->
# Plan - REVIEW_NCPUSH
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
이 follow-up은 `NCPUSH` 1차 구현 리뷰에서 발견된 Edge refresh push 계약 누락만 보완한다. Proto 메시지와 Node handler plumbing은 유지하되, Edge가 실제 refresh apply 시 configured node 전체에 대해 결과를 만들고, connected node에는 `request_id`와 node별 `NodeConfigPayload`를 채운 request를 보내도록 한다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_cloud_G07_0.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/transport/server.go:301` only iterates `registry.All()`, so configured-but-disconnected nodes are omitted instead of reported as `skipped`.
- `apps/edge/internal/bootstrap/runtime.go:185`, `apps/edge/internal/bootstrap/runtime.go:222`, and `apps/edge/internal/bootstrap/runtime.go:226` do not propagate `configrefresh.Request.RequestID` or a node-specific `NodeConfigPayload` into `NodeConfigRefreshRequest`.
- Affected files: `apps/edge/internal/bootstrap/runtime.go`, `apps/edge/internal/bootstrap/runtime_test.go`, `apps/edge/internal/transport/server.go`, `apps/edge/internal/transport/server_test.go`, `apps/edge/internal/node/mapper.go`, `apps/edge/internal/node/store.go`.
- Verification evidence from failed loop: `make proto`, `go test -count=1 ./packages/go/... ./proto/gen/...`, `go test -count=1 ./apps/edge/...`, and `go test -count=1 ./apps/node/...` passed, but tests did not assert disconnected configured-node reporting, request id propagation, or payload contents.
- Narrow reread allowed: the two current archived log files listed above.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `push-contract`: Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- Completion mode: check-on-pass
## 범위 결정 근거
- 포함: Edge refresh push result enumeration, `NodeConfigRefreshRequest.RequestId` propagation, node별 `NodeConfigPayload` construction, Edge tests that prove skipped disconnected nodes and payload/request-id delivery.
- 제외: Node adapter registry live diff, in-flight drain, runtime concurrency/workspace live apply. Node가 payload를 받았을 때 `restart_required`로 응답하는 현재 경계는 유지한다.
- lane/GNN: `cloud-G07`. Public protobuf contract, Edge runtime, Edge transport, node payload mapping, SDD S12 evidence가 함께 걸려 있어 cloud review가 필요하다.
## 구현 체크리스트
- [ ] `Server.PushConfigRefresh`가 refreshed `NodeStore` record 전체를 기준으로 `node_results`를 만들고, registry에 없거나 live client가 없는 configured node를 `skipped`로 보고한다. 검증: Edge transport/runtime 테스트가 registry에 없는 configured node의 skipped result를 확인한다.
- [ ] `configrefresh.Request.RequestID`와 node별 `NodeConfigPayload`를 `NodeConfigRefreshRequest`에 채워 connected node로 보내고, fake node 테스트가 request id와 updated payload 내용을 검증한다. 검증: Edge runtime test가 fake node에서 `request_id`와 payload capacity/config를 assert한다.
- [ ] 전체 계약 회귀로 `make proto`, `go test -count=1 ./apps/edge/...`, `go test -count=1 ./apps/node/...`, `go test -count=1 ./packages/go/... ./proto/gen/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 follow-up은 동일 split subtask `04+02_config_push_contract` 안의 FAIL 보완이다.
- 새 archive evidence는 현재 task 디렉터리의 `plan_cloud_G07_0.log`와 `code_review_cloud_G07_0.log`만 좁게 참조한다.
- PASS 전에는 후속 `05+04_node_adapter_diff`와 `06+05_node_drain_runtime_config`를 진행하지 않는다.
### [REVIEW_NCPUSH-1] Configured Disconnected Node Results
#### 문제
`PushConfigRefresh`가 `registry.All()`만 순회하면 Edge config에 존재하지만 현재 연결되지 않은 node는 결과에서 사라진다. 실제 disconnect listener는 registry entry를 제거하므로, 현재 테스트의 nil client entry 케이스는 실제 disconnected node 경로를 대표하지 못한다.
Before:
```go
entries := s.registry.All()
results := make([]configrefresh.NodeResult, 0, len(entries))
for _, entry := range entries {
...
}
```
#### 해결 방법
`NodeStore` snapshot의 `All()` record를 기준으로 결과를 만들고, 각 record ID로 registry live entry를 찾는다. live entry가 없거나 client가 nil/dead이면 `NodeResult{NodeID, Alias, Status:"skipped"}`를 append한다. connected entry에만 transport request를 보낸다. `PushConfigRefresh` 시그니처는 필요하면 `requestID`, `changedPaths`, `nodeStore`를 명시적으로 받도록 정리한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/transport/server.go`: configured node 전체를 기준으로 result를 생성한다.
- [ ] `apps/edge/internal/transport/server_test.go`: NodeStore에는 있지만 registry에는 없는 node가 `skipped`로 반환되는 테스트를 추가한다.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: apply 결과가 connected node와 skipped node를 모두 포함하는지 확인한다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
```
### [REVIEW_NCPUSH-2] Request ID And Payload Propagation
#### 문제
`RefreshConfig`가 받은 `RequestID`가 node refresh request로 전달되지 않고, `buildNodeConfigRefreshRequest`가 `ChangedPaths`만 채워 `Config`를 비워 둔다. 이 상태에서는 Node response가 어떤 refresh operation에 대한 ack인지 correlation할 수 없고, SDD S12가 요구하는 refresh payload push 계약도 실제 transport 경로에서 검증되지 않는다.
Before:
```go
nodeResults := r.Server.PushConfigRefresh(ctx, buildNodeConfigRefreshRequest(candidate, changedPaths))
func buildNodeConfigRefreshRequest(candidate *config.EdgeConfig, changedPaths []string) *iop.NodeConfigRefreshRequest {
return &iop.NodeConfigRefreshRequest{ChangedPaths: changedPaths}
}
```
#### 해결 방법
`configrefresh.Request.RequestID`를 `applyMutableConfig`와 request builder까지 전달해 `NodeConfigRefreshRequest.RequestId`에 설정한다. connected node별로 refreshed `NodeStore` record를 찾아 `edgenode.BuildConfigPayload(record)` 결과를 `Config`에 채운다. Node에는 아직 live apply manager가 없으므로 non-empty payload/change에 대해 `restart_required`로 응답하는 현 Node behavior는 유지한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/bootstrap/runtime.go`: `req.RequestID`를 push request 생성 경로로 전달한다.
- [ ] `apps/edge/internal/transport/server.go`: connected node별 `NodeConfigPayload`를 request에 포함한다.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: fake node listener가 `req.GetRequestId()=="node-result-test"`와 candidate payload의 updated config를 assert한다.
- [ ] `apps/edge/internal/transport/server_test.go`: request id와 payload가 transport request에 들어가는 단위 테스트를 추가하거나 runtime integration test로 충분히 검증한다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
```
## 최종 검증
```bash
make proto
go test -count=1 ./apps/edge/...
go test -count=1 ./apps/node/...
go test -count=1 ./packages/go/... ./proto/gen/...
```
기대 결과: proto generation 성공, Edge/Node/platform-common package tests PASS. Go test cache output은 허용하지 않는다.

View file

@ -0,0 +1,99 @@
<!-- task=m-runtime-reconnect-config-refresh/04+02_config_push_contract plan=2 tag=REVIEW_REVIEW_NCPUSH -->
# Plan - REVIEW_REVIEW_NCPUSH
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
이 follow-up은 `REVIEW_NCPUSH` 리뷰에서 발견된 검증 공백만 보완한다. Edge는 `NodeConfigRefreshRequest`에 `request_id`와 node별 `NodeConfigPayload`를 넣도록 구현되었고 대상 패키지 테스트도 통과했다. 하지만 fake node runtime test는 payload가 non-nil/non-empty인지까지만 확인해서, 계획이 요구한 "updated payload 내용"과 SDD S12의 refresh payload push evidence를 충분히 증명하지 못한다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_cloud_G07_1.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/bootstrap/runtime_test.go:1004` says the payload should contain updated provider capacity, but the test only asserts a non-nil payload with at least one adapter.
- The runtime test changes `nodes[].providers[].capacity`, while `NodeConfigPayload` currently carries adapter/runtime config, not `NodeRecord.Providers`, so the test does not prove refreshed node-specific payload contents.
- Affected files: `apps/edge/internal/bootstrap/runtime_test.go`; optionally `apps/edge/internal/transport/server_test.go` if a more focused `PushConfigRefresh` payload-capture test is clearer.
- Verification evidence from failed loop: `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap ./apps/node/internal/transport ./apps/node/internal/node` passed, and `make proto && go test -count=1 ./packages/go/... ./proto/gen/...` passed, but payload-content evidence remains incomplete.
- Narrow reread allowed: the two current archived log files listed above.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `push-contract`: Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- Completion mode: check-on-pass
## 범위 결정 근거
- 포함: captured `NodeConfigRefreshRequest.Config`의 구체 payload assertion, misleading capacity comment 정리, request id / changed paths / node result 기존 assertion 유지.
- 제외: `NodeConfigPayload` schema 확장, provider catalog를 payload에 싣는 설계 변경, Node adapter diff/live apply, drain/runtime-config 구현. 이들은 후속 `adapter-diff`, `drain`, `runtime-config` 범위다.
- lane/GNN: `cloud-G07`. 작업은 작지만 SDD S12 evidence trust와 Edge/Node proto transport 계약 검증을 닫는 리뷰 루프다.
## 구현 체크리스트
- [ ] `TestRefreshConfigApplyIncludesNodeResults` 또는 별도 `PushConfigRefresh` 테스트가 captured `NodeConfigRefreshRequest.Config`의 구체 필드를 expected refreshed `NodeStore` record와 대조한다. 최소한 typed adapter config의 type/name/provider/endpoint/capacity 또는 runtime workspace/concurrency처럼 `NodeConfigPayload`에 실제 존재하는 필드를 assert한다.
- [ ] 기존 runtime integration test의 `request_id`, `changed_paths`, connected node result, skipped disconnected node result 검증을 유지하고, `nodes[].providers[].capacity`가 `NodeConfigPayload` 필드인 것처럼 보이는 주석/기대 문구를 실제 계약에 맞게 고친다.
- [ ] 회귀 검증으로 `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node`, `make proto`, `go test -count=1 ./packages/go/... ./proto/gen/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 follow-up은 동일 split subtask `04+02_config_push_contract` 안의 FAIL 보완이다.
- 새 archive evidence는 현재 task 디렉터리의 `plan_cloud_G07_1.log`와 `code_review_cloud_G07_1.log`만 좁게 참조한다.
- PASS 전에는 후속 `05+04_node_adapter_diff`와 `06+05_node_drain_runtime_config`를 진행하지 않는다.
### [REVIEW_REVIEW_NCPUSH-1] Payload Content Evidence
#### 문제
`apps/edge/internal/bootstrap/runtime_test.go:1004`는 payload가 updated provider capacity를 담는다고 설명하지만 실제 assertion은 다음 수준에 머문다.
```go
payload := captured.GetConfig()
if payload == nil {
t.Fatal("expected non-nil NodeConfigPayload in refresh request")
}
if len(payload.GetAdapters()) == 0 {
t.Errorf("expected at least one adapter in refresh payload, got none")
}
```
이 테스트는 `request_id` 전파와 request 전송은 확인하지만, Edge가 refreshed `NodeStore` record로 node별 payload를 구성한다는 핵심 계약은 증명하지 못한다.
#### 해결 방법
테스트 fixture에 `NodeConfigPayload`로 표현되는 명시적 config를 넣고 captured payload의 구체 필드를 검증한다. 예를 들어 current/candidate config에 명시적 `openai_compat_instances` 또는 CLI profile/runtime 값을 넣고, refresh trigger는 기존처럼 mutable provider capacity 또는 model catalog change로 유지한다. 그런 뒤 fake node가 받은 `captured.GetConfig()`에서 해당 typed adapter/runtime 필드가 기대값과 일치하는지 확인한다.
만약 runtime integration test 하나에 넣으면 의미가 흐려진다면, `apps/edge/internal/transport/server_test.go`에 focused test를 추가해 `Server.SetNodeStore(candidateStore)` 후 connected fake client가 받은 `NodeConfigRefreshRequest.Config`를 직접 검증한다. 어느 방식이든 "non-empty payload"가 아니라 "expected node-specific payload"를 검증해야 한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: captured payload의 concrete typed adapter/runtime field assertion을 추가하고 misleading capacity comment를 수정한다.
- [ ] 선택 사항: `apps/edge/internal/transport/server_test.go`: runtime fixture보다 명확하면 `PushConfigRefresh` payload-capture 단위 테스트를 추가한다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
```
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node
make proto
go test -count=1 ./packages/go/... ./proto/gen/...
```
기대 결과: targeted Edge/Node tests와 proto/platform-common 검증이 모두 PASS하고, 검증 출력은 `CODE_REVIEW-cloud-G07.md`에 실제 stdout/stderr로 기록된다.

View file

@ -0,0 +1,96 @@
<!-- task=m-runtime-reconnect-config-refresh/04+02_config_push_contract plan=3 tag=REVIEW_REVIEW_REVIEW_NCPUSH -->
# Plan - REVIEW_REVIEW_REVIEW_NCPUSH
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
이 follow-up은 `REVIEW_REVIEW_NCPUSH` 리뷰에서 발견된 no-change refresh push 계약 오류만 보완한다. 현재 구현은 `StatusApplied`인 apply refresh마다 node refresh push를 수행한다. 그런데 `StatusApplied`는 실제 변경이 있는 applied 결과뿐 아니라 no-change 결과도 포함한다. 연결된 Node는 non-nil `NodeConfigPayload`를 받으면 현재 phase에서 `restart_required`로 응답하므로, 동일 config apply가 node `restart_required`로 보고될 수 있다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/plan_cloud_G07_2.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/code_review_cloud_G07_2.log`
- Verdict: FAIL
- Required summary:
- `apps/edge/internal/bootstrap/runtime.go:184` treats every `StatusApplied` result as a node-push trigger.
- `configrefresh.Classify` returns `StatusApplied` even when there are zero changes (`apps/edge/internal/configrefresh/classify.go:340`).
- `apps/edge/internal/transport/server.go:320` always attaches a full `NodeConfigPayload`, and current Node behavior reports any non-nil config as `restart_required` (`apps/node/internal/node/node.go:274`).
- Therefore an apply refresh with an identical config and a connected node can report node `restart_required` even though no config changed and no node payload update is needed.
- Affected files: `apps/edge/internal/bootstrap/runtime.go`, `apps/edge/internal/bootstrap/runtime_test.go`; optionally `apps/edge/internal/transport/server.go` only if the no-op behavior is better handled at the transport request builder.
- Verification evidence from this review: `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node`, `make proto`, and `go test -count=1 ./packages/go/... ./proto/gen/...` all passed, so the blocker is an uncovered edge case rather than an existing test failure.
- Narrow reread allowed: the two current archived log files listed above.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `push-contract`: Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- Completion mode: check-on-pass
## 범위 결정 근거
- 포함: no-change apply refresh에서 node push를 하지 않거나 true no-op response가 나오도록 gate를 추가하고, connected-node regression test로 확인한다.
- 제외: provider capacity change에 대한 current phase node result 정책, Node adapter live diff, in-flight drain, runtime concurrency/workspace live apply, proto schema 변경. 이 follow-up은 이미 추가된 push contract를 좁게 보정한다.
- lane/GNN: `local-G05`. 수정 범위가 Edge refresh apply gate와 targeted regression test로 한정되고, 정확성은 deterministic Go tests로 검증할 수 있다.
## 구현 체크리스트
- [ ] no-change apply refresh가 connected node에 full `NodeConfigPayload`를 push해 `restart_required` node_result를 만들지 않도록 gate를 추가한다. 권장 기준: `len(result.Changes)==0`이면 local runtime snapshot은 유지/동일화하더라도 `PushConfigRefresh`를 호출하지 않고 `NodeResults`는 empty로 둔다.
- [ ] connected fake node를 둔 regression test를 추가하거나 기존 runtime refresh test를 확장해, identical candidate config apply에서 fake node가 `NodeConfigRefreshRequest`를 받지 않거나 node result가 no-op/applied로 남는지 확인한다. 테스트는 기존 provider capacity refresh push 테스트의 request_id/payload assertion을 약화하지 않는다.
- [ ] 회귀 검증으로 `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node`, `make proto`, `go test -count=1 ./packages/go/... ./proto/gen/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 follow-up은 동일 split subtask `04+02_config_push_contract` 안의 FAIL 보완이다.
- 새 archive evidence는 현재 task 디렉터리의 `plan_cloud_G07_2.log`와 `code_review_cloud_G07_2.log`만 좁게 참조한다.
- PASS 전에는 후속 `05+04_node_adapter_diff`와 `06+05_node_drain_runtime_config`를 진행하지 않는다.
### [REVIEW_REVIEW_REVIEW_NCPUSH-1] No-Change Refresh Push Gate
#### 문제
`Runtime.RefreshConfig`는 apply mode에서 `result.Status == StatusApplied`이면 `applyMutableConfig`를 호출한다. 하지만 no-change config도 `StatusApplied`다. 이후 `PushConfigRefresh`는 full config payload를 붙이고, Node는 non-nil config를 current phase에서 `restart_required`로 응답한다.
Before:
```go
if req.Mode == configrefresh.ModeApply && result.Status == configrefresh.StatusApplied && candidate != nil {
nodeResults, err := r.applyMutableConfig(ctx, candidate, result.Changes, req.RequestID)
...
}
```
#### 해결 방법
no-change apply를 node push 대상에서 제외한다. 가장 작은 수정은 `len(result.Changes)==0`일 때 `PushConfigRefresh`를 호출하지 않도록 `applyMutableConfig` 또는 호출부에 gate를 두는 것이다. Runtime config snapshot을 candidate로 갱신할지 여부는 기존 no-change semantics와 일관되게 처리하되, connected Node에 full payload를 보내 restart_required를 만들면 안 된다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/bootstrap/runtime.go`: no-change apply에서 node refresh push를 건너뛰도록 gate를 추가한다.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: connected fake node가 있는 identical candidate apply regression을 추가한다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
```
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap
go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node
make proto
go test -count=1 ./packages/go/... ./proto/gen/...
```
기대 결과: targeted Edge/Node tests와 proto/platform-common 검증이 모두 PASS하고, no-change apply regression이 node restart-required false positive를 막는 증거를 제공한다.

View file

@ -0,0 +1,204 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=0 tag=NCADAPT -->
# Code Review Reference - NCADAPT
> **[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-22
task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff, plan=0, tag=NCADAPT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- 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/05+04_node_adapter_diff/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [NCADAPT-1] Adapter Config Diff Model | [x] |
| [NCADAPT-2] Mutable Router And Node Apply | [x] |
| [NCADAPT-3] OpenAI Compat Endpoint/Header Verification | [x] |
## 구현 체크리스트
- [x] `04+02_config_push_contract`의 `complete.log`가 active/archive 중 하나에 있는지 확인하고, 없으면 구현하지 말고 review stub `검증 결과`에 dependency blocker로 기록한다.
- [x] Node adapter config set/diff 모델을 추가해 adapter instance 추가/수정/삭제와 openai_compat endpoint/header/capacity 변경을 식별한다. 검증: registry diff unit test가 added/updated/removed/unchanged를 확인한다.
- [x] Router를 live registry swap 가능하게 만들고 Node `OnConfigRefresh`가 payload build, diff, registry start/swap, ack response를 수행하게 한다. 검증: Node handler test가 refresh 후 후속 resolve/capabilities에서 새 openai_compat capacity를 확인한다.
- [x] openai_compat endpoint/header 변경이 새 adapter instance에 반영되는지 httptest provider로 확인한다. 검증: 기존 endpoint와 새 endpoint 중 refresh 후 새 endpoint만 호출된다.
- [x] 전체 Node 회귀로 `go test -count=1 ./apps/node/...`를 실행한다.
- [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/05+04_node_adapter_diff/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- `OnConfigRefresh`에서 `req.GetConfig() == nil` 일 때 `ChangedPaths`가 존재하는 케이스의 하위 호환성을 위해, `nil` payload가 전달되면 이전 스펙처럼 `RESTART_REQUIRED`를 반환하도록 예외 분리함.
- `TestNodeConfigRefreshUsesUpdatedOpenAICompatEndpoint` 검증 시 `handleCapabilitiesCommand`에서 capabilities 조회와 prober 체크가 동시에 돌며 endpoint 쿼리가 총 2번 발생하므로, assertion을 2회 호출로 변경하여 통과함.
## 주요 설계 결정
- **ConfigSet 모델과 JSON 지문(Fingerprint) 기반 Diff**: adapter 설정을 Canonical하게 비교하기 위해 각 typed config struct 및 structpb settings를 JSON으로 marshaling하여 문자열 자체를 fingerprint로 사용함. 이를 통해 추가/수정/삭제/유지를 정확히 식별(Added, Updated, Removed, Unchanged)할 수 있게 됨.
- **RWMutex 동기화를 적용한 MutableRouter**: `router.New`가 `MutableRouter`를 반환하도록 수정하고, `SetRegistry` 메소드 추가 및 내부 Lock 처리를 함으로써 live swap 시 런타임에 안전하게 어댑터 라우팅 정보가 갱신되도록 보장함.
- **OnConfigRefresh의 생명주기 관리**: config refresh 요청 시 새 registry를 만들고 `Start`에 성공한 경우에만 router에 swap하며, swap 후 이전 registry의 stop을 수행하여 리소스 누수를 방지함.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- predecessor `04+02_config_push_contract` 완료 근거가 확인됐는지 본다.
- Registry diff가 add/update/remove/no-op을 실제 payload fingerprint로 구분하는지 확인한다.
- Router swap 뒤 후속 resolve만 새 registry를 보고 기존 in-flight 보장은 이 plan에서 과장하지 않았는지 확인한다.
- openai_compat endpoint/header/capacity 변경 테스트가 실제 새 instance 사용을 관측하는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### NCADAPT-1 중간 검증
```text
$ go test -count=1 ./apps/node/internal/adapters
ok iop/apps/node/internal/adapters 0.007s
```
### NCADAPT-2 중간 검증
```text
$ go test -count=1 ./apps/node/internal/router ./apps/node/internal/node
ok iop/apps/node/internal/router 0.004s
ok iop/apps/node/internal/node 0.123s
```
### NCADAPT-3 중간 검증
```text
$ go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/node
ok iop/apps/node/internal/adapters/openai_compat 0.009s
ok iop/apps/node/internal/node 0.123s
```
### 최종 검증
```text
$ go test -count=1 ./apps/node/internal/adapters
ok iop/apps/node/internal/adapters 0.009s
$ go test -count=1 ./apps/node/internal/router ./apps/node/internal/node
ok iop/apps/node/internal/router 0.004s
ok iop/apps/node/internal/node 0.123s
$ go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/node
ok iop/apps/node/internal/adapters/openai_compat 0.009s
ok iop/apps/node/internal/node 0.123s
$ go test -count=1 ./apps/node/...
ok iop/apps/node/cmd/node 0.010s
ok iop/apps/node/internal/adapters 0.009s
ok iop/apps/node/internal/adapters/cli 46.673s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 39.785s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.010s
ok iop/apps/node/internal/adapters/openai_compat 0.009s
ok iop/apps/node/internal/adapters/vllm 0.014s
ok iop/apps/node/internal/bootstrap 0.690s
ok iop/apps/node/internal/node 0.123s
ok iop/apps/node/internal/router 0.004s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.042s
ok iop/apps/node/internal/terminal 0.538s
ok iop/apps/node/internal/transport 5.350s
```
---
> **[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 `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check items as completed; final item is mandatory. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
| 검증 결과 | Implementing agent | Paste actual command output. |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Warn
- implementation deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/node/internal/node/node.go:188`에서 per-adapter admission gate를 adapter key별로 최초 1회만 만들고 이후 capacity를 갱신하지 않는다. refresh 전에 해당 adapter로 실행이 한 번이라도 발생하면 old `MaxConcurrency`가 `fifoGate.capacity`에 고정되어, refresh 후 capabilities는 새 capacity를 보여도 후속 run admission은 예전 capacity로 reject/allow된다. `OnConfigRefresh`가 updated/added adapter의 새 capabilities 기준으로 기존 gate capacity를 in-flight count 보존 방식으로 갱신하고, capacity 감소 케이스도 현재 active run은 완료시키되 신규 admission은 새 limit을 따르는 회귀 테스트를 추가해야 한다.
- Required: `apps/node/internal/router/router.go:71`의 `ResolveAdapter`가 `RLock`을 잡은 채 `Resolve`를 호출하고, `Resolve`가 다시 `RLock`을 잡는다. Go `sync.RWMutex`는 writer가 대기 중이면 새 reader를 막을 수 있어 `SetRegistry`가 대기하는 refresh와 `ResolveAdapter`가 겹칠 때 같은 goroutine이 두 번째 `RLock`에서 교착될 수 있다. lock을 한 번만 잡는 `resolveLocked` helper나 registry snapshot 방식으로 바꾸고, registry swap 중 resolve가 멈추지 않는 회귀 테스트를 추가해야 한다.
- Required: `apps/node/internal/node/node.go:339`에서 refresh swap 직후 old registry를 무조건 `Stop`한다. 이번 plan은 in-flight drain을 제외했고, active run이 있는 changed/removed adapter는 old snapshot으로 완료되어야 할 후속 task 경계를 남겨야 하는데, lifecycle adapter(CLI 등)는 즉시 `Stop`으로 기존 실행/session을 끊을 수 있다. old registry stop은 active run이 없는 adapter로 한정하거나 drain task로 defer되는 상태로 남기고, in-flight run 중 refresh가 old lifecycle adapter를 즉시 stop하지 않는 테스트를 추가해야 한다.
- Suggested: `apps/node/internal/adapters/config_set.go:52`의 `adapter` 변수와 `_ = adapter`는 실제로 사용되지 않는 죽은 코드다. 각 case에서 registry 등록만 수행하고 불필요한 변수/주석을 제거해 diff model을 단순화하는 편이 좋다.
- 다음 단계:
- FAIL: active plan/review를 로그로 아카이브하고, 위 Required 항목을 좁게 다루는 후속 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다.

View file

@ -0,0 +1,212 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=1 tag=REVIEW_NCADAPT -->
# Code Review Reference - REVIEW_NCADAPT
> **[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-22
task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff, plan=1, tag=REVIEW_NCADAPT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior active task: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff`
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/node/node.go:188` keeps old per-adapter gate capacity after refresh.
- `apps/node/internal/router/router.go:71` nests `RLock` through `ResolveAdapter` -> `Resolve`.
- `apps/node/internal/node/node.go:339` stops the old registry immediately despite the drain boundary.
- Suggested summary:
- `apps/node/internal/adapters/config_set.go:52` has an unused `adapter` variable and `_ = adapter` cleanup.
- Verification evidence from review:
- `go test -count=1 ./apps/node/...` PASS.
- `git diff --check` PASS.
- Roadmap carryover: keep `adapter-diff` target open; no `complete.log` was written.
- Additional archive reread allowed: only the two archived files listed above.
## 이 파일을 읽는 리뷰 에이전트에게
> **[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/05+04_node_adapter_diff/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_NCADAPT-1] Refresh Admission Gate Capacity | [x] |
| [REVIEW_NCADAPT-2] Router Swap Lock Safety | [x] |
| [REVIEW_NCADAPT-3] Defer Old Registry Stop Before Drain | [x] |
## 구현 체크리스트
- [x] Refresh 후 기존 per-adapter gate capacity를 새 adapter capabilities 기준으로 갱신한다. 검증: refresh 전에 old capacity gate를 생성한 뒤 capacity 증가/감소가 후속 run admission에 반영되는 regression test를 추가한다.
- [x] Router `ResolveAdapter`가 registry lock을 중첩 획득하지 않도록 refactor한다. 검증: registry swap과 resolve가 동시에 반복되어도 timeout/deadlock 없이 완료되는 router test를 추가한다.
- [x] `OnConfigRefresh`가 active run이 있는 old registry/lifecycle adapter를 즉시 stop하지 않도록 한다. 검증: active run 중 refresh가 old adapter `Stop`을 호출하지 않고 후속 request는 새 registry로 라우팅되는 test를 추가한다.
- [x] `go test -count=1 ./apps/node/internal/adapters ./apps/node/internal/router ./apps/node/internal/node`, `go test -count=1 ./apps/node/...`, `git diff --check`를 실행한다.
- [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/05+04_node_adapter_diff/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-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로 이동한다.
## 계획 대비 변경 사항
- `OnRunRequest`에 `*transport.Session`을 nil로 전달할 때 `Emit`에서 발생하는 nil pointer panic을 안전하게 방지하기 위해 `noopSender` 구조체를 추가하고 `sess == nil || !sess.IsAlive()` 일 때 이를 사용하도록 `node.go`를 개선하였습니다.
- `TestNodeConfigRefreshUpdatesExistingAdapterGateCapacity` 테스트 케이스에서 `openai_compat` 어댑터가 SSE 스트림 형식의 응답을 기대하므로 mock HTTP server의 응답 데이터를 SSE 형식(`data: ...\n\ndata: [DONE]\n\n`)으로 맞추고 `prompt`를 인풋으로 전달하여 테스트 성공을 보장하였습니다.
## 주요 설계 결정
- `router.go`의 nested RLock 문제를 해결하기 위해 `resolveWithRegistry` 내부 헬퍼 함수를 추가하고, Lock/Unlock 구간을 registry pointer 획득 단계(CS)로만 좁혀 slice/lookup 작업이 락 밖에서 수행되도록 리팩토링하였습니다.
- `runManager`에 `hasActiveRunsForAdapter` 헬퍼 메서드를 추가하여 config refresh 발생 시 변경되거나 삭제되는 어댑터 중 active run이 남아 있는 것들이 존재한다면 이전 registry의 `Stop`을 호출하지 않고 안전하게 연기(defer)하도록 하여 in-flight drain 시나리오의 안전성을 확보하였습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Capacity refresh가 capabilities 출력뿐 아니라 후속 run admission gate에도 반영되는지 본다.
- Router lock이 한 번만 획득되고 swap 대기 writer와 resolve가 교착될 여지가 없는지 본다.
- Drain task 전에는 active old adapter lifecycle이 즉시 stop되지 않는지 본다.
- `Archive Evidence Snapshot`의 Required 세 가지가 모두 regression test로 닫혔는지 본다.
## 검증 결과
### REVIEW_NCADAPT-1 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
ok iop/apps/node/internal/node 0.227s
ok iop/apps/node/internal/adapters 0.009s
```
### REVIEW_NCADAPT-2 중간 검증
```text
$ go test -count=1 ./apps/node/internal/router
ok iop/apps/node/internal/router 0.505s
```
### REVIEW_NCADAPT-3 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node
ok iop/apps/node/internal/node 0.227s
```
### 최종 검증
```text
$ go test -count=1 ./apps/node/internal/adapters ./apps/node/internal/router ./apps/node/internal/node
ok iop/apps/node/internal/adapters 0.009s
ok iop/apps/node/internal/router 0.505s
ok iop/apps/node/internal/node 0.227s
$ go test -count=1 ./apps/node/...
ok iop/apps/node/cmd/node 0.008s
ok iop/apps/node/internal/adapters 0.009s
ok iop/apps/node/internal/adapters/cli 46.653s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 39.758s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.012s
ok iop/apps/node/internal/adapters/openai_compat 0.006s
ok iop/apps/node/internal/adapters/vllm 0.009s
ok iop/apps/node/internal/bootstrap 0.694s
ok iop/apps/node/internal/node 0.226s
ok iop/apps/node/internal/router 0.505s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.042s
ok iop/apps/node/internal/terminal 0.544s
ok iop/apps/node/internal/transport 5.348s
$ git diff --check
(no 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. |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check items as completed; final item is mandatory. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
| 검증 결과 | Implementing agent | Paste actual command output. |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- implementation deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/node/internal/node/node.go:372`에서 updated/removed adapter에 active run이 없다는 이유로 `n.currentConfigSet.Registry.Stop`을 호출하지만, `Registry.Stop`은 old registry 안의 모든 lifecycle adapter를 멈춘다. 따라서 unchanged adapter에 foreground/persistent active run이 있고 다른 adapter만 변경된 refresh에서도 unchanged old adapter가 같이 stop되어 실행/session을 끊을 수 있다. Fix: old registry 전체 stop을 유지하려면 old registry의 모든 active run을 확인해 하나라도 있으면 defer하고, 더 나은 방향은 old registry 전체가 아니라 changed/removed key만 stop하는 helper를 추가한다. `TestConfigRefreshDoesNotStopUnchangedActiveAdapterWhenAnotherAdapterChanges` 같은 regression을 추가한다.
- Required: `apps/node/internal/node/node.go:80`의 `ResolveAdapter` 이후 `apps/node/internal/node/node.go:149`의 `n.runs.register` 전에 refresh가 들어오면, `apps/node/internal/node/node.go:355`의 active-run 검사에는 아직 보이지 않는 old adapter 실행이 존재한다. 이 race에서는 refresh가 old registry를 stop한 뒤 기존 request가 old adapter로 실행될 수 있다. Fix: `OnRunRequest`가 router snapshot 획득부터 run handle 등록까지 `configSetMu.RLock` 같은 refresh barrier를 잡거나, refresh stop 판단이 pre-swap/admission snapshot을 포함하도록 한다. resolve-but-not-registered run을 재현하는 ordering test를 추가한다.
- Required: `apps/node/internal/node/node_test.go:1886`의 `TestNodeConfigRefreshUpdatesExistingAdapterGateCapacity`는 capacity 1 -> 2 증가만 검증한다. Plan checklist는 증가/감소 모두 후속 run admission에 반영되는 regression test를 요구했고, S13의 provider capacity 변경 evidence도 감소 경계를 포함해야 stale gate 회귀를 잡을 수 있다. Fix: 같은 테스트나 별도 테스트에서 capacity 2 -> 1 refresh 후 기존 admitted run은 유지하되 새 추가 run이 `ErrConcurrencyLimitExceeded`로 거부되는지 검증한다.
- 리뷰어 재검증:
- `go test -count=1 ./apps/node/internal/adapters ./apps/node/internal/router ./apps/node/internal/node` PASS
- `go test -count=1 ./apps/node/...` PASS
- `git diff --check` PASS
- 다음 단계:
- FAIL follow-up plan/review를 생성한다. User-review gate는 트리거하지 않는다.

View file

@ -0,0 +1,192 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=4 tag=REVIEW_REVIEW_REVIEW_REVIEW_NCADAPT -->
# Code Review Reference - REVIEW_REVIEW_REVIEW_REVIEW_NCADAPT
> **[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-23
task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff, plan=4, tag=REVIEW_REVIEW_REVIEW_REVIEW_NCADAPT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior active task: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff`
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_local_G07_3.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_local_G07_3.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/node/node_test.go:2179` and `apps/node/internal/node/node_test.go:2421` create unbuffered `startedChan` values while the httptest handlers use nonblocking sends. A handler can reach the send before the test goroutine starts receiving, drop the signal, then block on `blockChan` while the test blocks forever on `<-startedChan`.
- Suggested summary: 없음.
- Verification evidence from review:
- `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router` PASS.
- `go test -count=20 -timeout=10s -run 'TestNodeConfigRefresh(DoesNotStopUnchangedActiveAdapterWhenSiblingChanges|DecreasesExistingAdapterGateCapacity)$' ./apps/node/internal/node` PASS.
- `go test -count=1 ./apps/node/...` PASS.
- `git diff --check` PASS.
- Affected files: `apps/node/internal/node/node_test.go`.
- Roadmap carryover: keep `adapter-diff` target open; no `complete.log` was written.
- Additional archive reread allowed: only the two archived files listed above.
## 이 파일을 읽는 리뷰 에이전트에게
> **[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/05+04_node_adapter_diff/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_REVIEW_REVIEW_NCADAPT-1] Lossless Request-Start Signal | [x] |
## 구현 체크리스트
- [x] `TestNodeConfigRefreshDoesNotStopUnchangedActiveAdapterWhenSiblingChanges`의 request-start signal이 handler 선도착 시에도 유실되지 않도록 buffered channel 또는 equivalent latch로 수정한다.
- [x] `TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity`의 request-start signal이 handler 선도착 시에도 유실되지 않도록 buffered channel 또는 equivalent latch로 수정한다.
- [x] focused 반복 검증, targeted Node packages, full Node regression, `git diff --check`를 실행한다.
- [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하는지 확인한다.
- [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/05+04_node_adapter_diff/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-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로 이동한다.
## 계획 대비 변경 사항
계획과 상이한 부분은 없으며, 두 테스트의 `startedChan`을 버퍼 크기가 1인 채널로 만들어 신호 유실을 없앴고, `TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity`에서 두 번째 OnRunRequest 호출 시 타임아웃 컨텍스트를 도입해 혹시 모를 교착상태 시 즉시 실패하도록 보강하였습니다.
## 주요 설계 결정
1. **Lossless Signal Latch**: 테스트의 `startedChan`을 `make(chan struct{}, 1)`로 정의하여, 수신 고루틴이 대기 상태에 들어가기 전 송신 고루틴이 먼저 데이터를 보내더라도 버퍼를 통해 정상 보관되도록 조치함으로써 race condition에 의한 영구 대기(hang)를 원천 차단했습니다.
2. **Fast-Fail Context Timeout**: 만약 동시성 제한 하향 조정이 적용되지 않는 회귀 버그 발생 시 테스트가 무기한 hang되지 않고 `200ms` 내에 즉각 실패(context deadline exceeded)하도록 timeout context를 사용하여 검증 안정성을 향상시켰습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 두 테스트의 request-start signal이 handler 선도착에도 유실되지 않는지 본다.
- `time.Sleep` 기반 active-run 추정으로 되돌아가지 않았는지 본다.
- Production behavior 변경 없이 test evidence만 보강했는지 본다.
- S13 `adapter-diff` evidence가 여전히 openai_compat capacity/endpoint reflected 조건을 뒷받침하는지 본다.
## 검증 결과
각 중간 검증 및 최종 검증 명령을 수행하여 안정적으로 테스트가 통과하는 것을 확인했습니다.
### REVIEW_REVIEW_REVIEW_REVIEW_NCADAPT-1 중간 검증
```text
$ go test -count=20 -timeout=10s -run 'TestNodeConfigRefresh(DoesNotStopUnchangedActiveAdapterWhenSiblingChanges|DecreasesExistingAdapterGateCapacity)$' ./apps/node/internal/node
ok iop/apps/node/internal/node 0.041s
```
### 최종 검증
```text
$ go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router
ok iop/apps/node/internal/node 0.286s
ok iop/apps/node/internal/adapters 0.008s
ok iop/apps/node/internal/router 0.506s
$ go test -count=1 ./apps/node/...
ok iop/apps/node/cmd/node 0.013s
ok iop/apps/node/internal/adapters 0.017s
ok iop/apps/node/internal/adapters/cli 46.665s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 39.767s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.014s
ok iop/apps/node/internal/adapters/openai_compat 0.017s
ok iop/apps/node/internal/adapters/vllm 0.011s
ok iop/apps/node/internal/bootstrap 0.696s
ok iop/apps/node/internal/node 0.277s
ok iop/apps/node/internal/router 0.505s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.033s
ok iop/apps/node/internal/terminal 0.542s
ok iop/apps/node/internal/transport 5.339s
$ git diff --check
(출력 없음 - 정상 통과)
```
---
> **[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 uses it as default prior-loop context; read only the specific archive files cited there when more detail is required. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check items as completed; final item is mandatory. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
| 검증 결과 | Implementing agent | Paste actual command output. |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Implementation deviation: Pass
- Verification trust: Pass
- Spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계:
- PASS: active plan/review를 로그로 아카이브하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.

View file

@ -0,0 +1,211 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=2 tag=REVIEW_REVIEW_NCADAPT -->
# Code Review Reference - REVIEW_REVIEW_NCADAPT
> **[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-23
task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff, plan=2, tag=REVIEW_REVIEW_NCADAPT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior active task: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff`
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_cloud_G07_1.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/node/node.go:372` can stop the whole old registry while an unchanged adapter in that registry still has an active run.
- `apps/node/internal/node/node.go:80` through `apps/node/internal/node/node.go:149` has a resolve-to-register race where refresh can stop an old adapter before the run becomes visible to `runManager`.
- `apps/node/internal/node/node_test.go:1886` only tests adapter gate capacity increase, not decrease.
- Suggested summary: 없음.
- Verification evidence from review:
- `go test -count=1 ./apps/node/internal/adapters ./apps/node/internal/router ./apps/node/internal/node` PASS.
- `go test -count=1 ./apps/node/...` PASS.
- `git diff --check` PASS.
- Roadmap carryover: keep `adapter-diff` target open; no `complete.log` was written.
- Additional archive reread allowed: only the two archived files listed above.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_NCADAPT-1] Old Registry Stop Scope | [x] |
| [REVIEW_REVIEW_NCADAPT-2] Resolve To Register Refresh Barrier | [x] |
| [REVIEW_REVIEW_NCADAPT-3] Adapter Gate Capacity Decrease Evidence | [x] |
## 구현 체크리스트
- [x] Old registry 전체 stop이 unchanged active adapter를 멈추지 않도록 refresh stop 판단을 보수화한다. 검증: unchanged active lifecycle adapter와 changed/removed sibling adapter를 함께 둔 regression test를 추가한다.
- [x] `ResolveAdapter` 이후 `runManager.register` 전 refresh가 old registry를 stop하지 못하도록 run admission/register와 refresh stop 판단 사이에 ordering barrier를 추가한다. 검증: capability/registration 지연 test로 refresh가 미등록 old run을 stop하지 않는지 확인한다.
- [x] Existing adapter gate capacity decrease regression을 추가한다. 검증: capacity 2 -> 1 refresh 후 기존 admitted run은 유지되고 새 초과 run은 `ErrConcurrencyLimitExceeded`로 거부된다.
- [x] `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router`, `go test -count=1 ./apps/node/...`, `git diff --check`를 실행한다.
- [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_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md` and `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/05+04_node_adapter_diff/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-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로 이동한다.
## 계획 대비 변경 사항
없음 (계획된 대로 정확히 구현 완료)
## 주요 설계 결정
1. `hasAnyActiveRuns()` 메소드를 `runManager`에 추가하여, Registry 중단(Stop) 시점의 판단 기준을 개별 어댑터가 아닌 "레지스트리 내 하나라도 활성 런이 존재하는지"로 보수화함.
2. `OnRunRequest` 시점에 `configSetMu.RLock()`을 획득하고, `ResolveAdapter`를 거쳐 `runManager.register` 완료된 이후 `configSetMu.RUnlock()` 하도록 하여, run 정보가 register에 등록되기 전 refresh가 발생하여 old registry가 종료되는 race condition을 ordering barrier(뮤텍스 락)을 통해 예방함.
3. `TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity` 및 타 regression 테스트들을 추가하여 capacity 감소 시 concurrent run이 정상 거절되는지 검증함.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Old registry 전체 stop이 unchanged active lifecycle adapter를 멈추지 않는지 본다.
- `OnRunRequest`가 old adapter를 resolve한 뒤 register 전 refresh와 경합해도 old registry stop이 먼저 진행되지 않는지 본다.
- Capacity decrease가 existing adapter gate에 반영되고, 기존 admitted run은 취소하지 않으며, 후속 초과 run만 거부하는지 본다.
- `06+05_node_drain_runtime_config` 범위인 full deferred stop scheduling, runtime concurrency, workspace root 변경으로 scope가 확장되지 않았는지 본다.
## 검증 결과
### REVIEW_REVIEW_NCADAPT-1 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
ok iop/apps/node/internal/node 0.334s
ok iop/apps/node/internal/adapters 0.010s
```
### REVIEW_REVIEW_NCADAPT-2 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node
ok iop/apps/node/internal/node 0.334s
```
### REVIEW_REVIEW_NCADAPT-3 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node
ok iop/apps/node/internal/node 0.334s
```
### 최종 검증
```text
$ go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router
ok iop/apps/node/internal/node 0.334s
ok iop/apps/node/internal/adapters 0.010s
ok iop/apps/node/internal/router 0.508s
$ go test -count=1 ./apps/node/...
ok iop/apps/node/cmd/node 0.010s
ok iop/apps/node/internal/adapters 0.010s
ok iop/apps/node/internal/adapters/cli 46.761s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 39.724s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.009s
ok iop/apps/node/internal/adapters/openai_compat 0.008s
ok iop/apps/node/internal/adapters/vllm 0.008s
ok iop/apps/node/internal/bootstrap 0.693s
ok iop/apps/node/internal/node 0.331s
ok iop/apps/node/internal/router 0.505s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.033s
ok iop/apps/node/internal/terminal 0.547s
ok iop/apps/node/internal/transport 5.343s
$ git diff --check
(아무런 공백 에러 없이 정상 통과)
```
---
> **[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 uses it as default prior-loop context; read only the specific archive files cited there when more detail is required. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check items as completed; final item is mandatory. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
| 검증 결과 | Implementing agent | Paste actual command output. |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- implementation deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/node/internal/node/node_test.go:2220`의 `TestNodeConfigRefreshDoesNotStopUnchangedActiveAdapterWhenSiblingChanges`는 이름과 plan checklist가 요구한 "unchanged active adapter + changed/removed sibling" 케이스를 실제로 만들지 않는다. refresh payload가 `adapter-A`를 `lifecycle`에서 `mock`으로 바꾸고 `adapter-B`를 제거하므로 active `adapter-A`는 `Updated` 대상이다. 이전 실패 구현처럼 updated/removed adapter만 active-run 검사해도 이 테스트는 통과할 수 있어, 원래 결함인 unchanged active adapter가 있는 old registry 전체 stop 회귀를 잡지 못한다. Fix: active run은 refresh 후에도 같은 fingerprint/type으로 남는 adapter에 걸고, 변경 또는 삭제는 sibling adapter에만 발생시키며, old registry `Stop`이 호출되지 않는지 검증한다.
- Required: `apps/node/internal/node/node_test.go:2364`의 capacity decrease regression은 capacity 2 상태에서 두 run을 이미 점유한 뒤 capacity 1로 refresh하고 세 번째 run 거부만 확인한다. stale gate가 여전히 capacity 2여도 in-flight가 2라서 세 번째 run은 똑같이 거부되므로, 이 테스트는 `fifoGate.updateCapacity(1)`이 실제로 적용됐는지 증명하지 못한다. Fix: capacity 2에서 기존 gate를 만들고 active run을 하나만 유지한 상태로 capacity 1 refresh를 적용한 뒤, 두 번째 run이 `ErrConcurrencyLimitExceeded`로 거부되는지 확인한다. 기존 admitted run 정상 완료도 함께 검증한다.
- 리뷰어 재검증:
- `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router` PASS
- `go test -count=1 ./apps/node/...` PASS
- `git diff --check` PASS
- 다음 단계:
- FAIL follow-up plan/review를 생성한다. User-review gate는 트리거하지 않는다.

View file

@ -0,0 +1,202 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=3 tag=REVIEW_REVIEW_REVIEW_NCADAPT -->
# Code Review Reference - REVIEW_REVIEW_REVIEW_NCADAPT
> **[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-23
task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff, plan=3, tag=REVIEW_REVIEW_REVIEW_NCADAPT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior active task: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff`
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_local_G07_2.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_local_G07_2.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/node/node_test.go:2220` does not actually create the unchanged active adapter + changed/removed sibling case; active `adapter-A` is updated to `mock`.
- `apps/node/internal/node/node_test.go:2364` rejects a third run while two runs are already active, so stale capacity 2 and refreshed capacity 1 both pass.
- Suggested summary: 없음.
- Verification evidence from review:
- `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router` PASS.
- `go test -count=1 ./apps/node/...` PASS.
- `git diff --check` PASS.
- Affected files: `apps/node/internal/node/node_test.go`.
- Roadmap carryover: keep `adapter-diff` target open; no `complete.log` was written.
- Additional archive reread allowed: only the two archived files listed above.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_REVIEW_NCADAPT-1] Unchanged Active Sibling Stop Regression | [x] |
| [REVIEW_REVIEW_REVIEW_NCADAPT-2] Capacity Decrease Evidence | [x] |
## 구현 체크리스트
- [x] `TestNodeConfigRefreshDoesNotStopUnchangedActiveAdapterWhenSiblingChanges`가 active adapter를 refresh 후에도 unchanged로 유지하고, changed/removed sibling 때문에 old registry `Stop`이 호출되지 않는지 검증하도록 수정한다.
- [x] `TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity`가 capacity 2에서 active run 하나만 유지한 뒤 capacity 1 refresh 후 두 번째 run이 `ErrConcurrencyLimitExceeded`로 거부되는지 검증하도록 수정한다.
- [x] `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router`, `go test -count=1 ./apps/node/...`, `git diff --check`를 실행한다.
- [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_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_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/05+04_node_adapter_diff/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- 없음. 계획에서 제시된 설계 가이드라인에 맞추어 `node_test.go` 상의 두 가지 regression test의 테스트 증거력을 보강하였습니다.
## 주요 설계 결정
- 비결정적인 `time.Sleep` 타이밍 문제를 해결하기 위해 httptest.NewServer 내부 핸들러 진입 시점을 채널(`startedChan`)로 알리는 동기화 방식을 두 테스트 모두에 도입하여 deterministic하게 검증이 완료되도록 설계했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Sibling-change regression에서 active adapter가 실제로 `Unchanged`에 해당하는지 본다.
- Capacity decrease regression이 stale capacity 2와 refreshed capacity 1을 구분하는지 본다.
- Sleep-only timing 추정 대신 deterministic request-start signal이 있는지 본다.
- Production behavior 변경 없이 test evidence만 보강했는지 본다.
## 검증 결과
### REVIEW_REVIEW_REVIEW_NCADAPT-1 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node
ok iop/apps/node/internal/node 0.281s
```
### REVIEW_REVIEW_REVIEW_NCADAPT-2 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node
ok iop/apps/node/internal/node 0.281s
```
### 최종 검증
```text
$ go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router
ok iop/apps/node/internal/node 0.281s
ok iop/apps/node/internal/adapters 0.008s
ok iop/apps/node/internal/router 0.505s
$ go test -count=1 ./apps/node/...
ok iop/apps/node/cmd/node 0.011s
ok iop/apps/node/internal/adapters 0.011s
ok iop/apps/node/internal/adapters/cli 46.672s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 39.781s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.009s
ok iop/apps/node/internal/adapters/openai_compat 0.009s
ok iop/apps/node/internal/adapters/vllm 0.009s
ok iop/apps/node/internal/bootstrap 0.690s
ok iop/apps/node/internal/node 0.281s
ok iop/apps/node/internal/router 0.505s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.037s
ok iop/apps/node/internal/terminal 0.546s
ok iop/apps/node/internal/transport 5.355s
$ git diff --check
(no output, exit code 0)
```
---
> **[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 uses it as default prior-loop context; read only the specific archive files cited there when more detail is required. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check items as completed; final item is mandatory. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
| 검증 결과 | Implementing agent | Paste actual command output. |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- implementation deviation: Fail
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/node/internal/node/node_test.go:2179`와 `apps/node/internal/node/node_test.go:2421`의 `startedChan`은 unbuffered channel인데, httptest handler는 `select { case startedChan <- struct{}{}: default: }`로 nonblocking send를 수행한다. handler가 test goroutine의 `<-startedChan` 대기보다 먼저 도착하면 start signal이 default branch로 드롭되고, handler는 `blockChan`에서 대기하며 test는 `startedChan`에서 영구 대기할 수 있다. 이번 follow-up의 핵심 요구는 `time.Sleep` 추정 대신 deterministic request-start signal을 남기는 것이므로, 현재 테스트는 검증 신뢰성을 완전히 충족하지 못한다. Fix: 두 테스트의 `startedChan`을 buffered channel(`make(chan struct{}, 1)`)로 만들거나 equivalent latch/helper를 사용해 요청 시작 신호가 유실되지 않게 한다. 필요하면 stale capacity 2 회귀 시 두 번째 run이 장시간 block하지 않도록 짧은 context timeout assertion도 추가한다.
- 리뷰어 재검증:
- `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router` PASS
- `go test -count=20 -timeout=10s -run 'TestNodeConfigRefresh(DoesNotStopUnchangedActiveAdapterWhenSiblingChanges|DecreasesExistingAdapterGateCapacity)$' ./apps/node/internal/node` PASS
- `go test -count=1 ./apps/node/...` PASS
- `git diff --check` PASS
- 다음 단계:
- FAIL follow-up plan/review를 생성한다. User-review gate는 트리거하지 않는다.

View file

@ -0,0 +1,48 @@
# Complete - m-runtime-reconnect-config-refresh/05+04_node_adapter_diff
## 완료 일시
2026-06-23
## 요약
Node adapter diff refresh subtask completed after 5 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | adapter gate capacity refresh, router registry swap deadlock risk, and active-run old registry stop handling were incomplete |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | unchanged active adapter stop, resolve-before-register race, and capacity decrease evidence remained incomplete |
| `plan_local_G07_2.log` | `code_review_local_G07_2.log` | FAIL | sibling-change and capacity-decrease regression tests did not prove the intended stale-registry and stale-gate failures |
| `plan_local_G07_3.log` | `code_review_local_G07_3.log` | FAIL | request-start synchronization in two regression tests could drop nonblocking handler signals |
| `plan_local_G05_4.log` | `code_review_local_G05_4.log` | PASS | buffered request-start latches and reviewer reruns closed the remaining verification trust issue |
## 구현/정리 내용
- Added Node config refresh apply support for `NodeConfigPayload` adapter registry diffs, including openai_compat capacity, endpoint, and header changes.
- Updated mutable router and per-adapter gate handling so refreshed registry snapshots and provider capacity changes affect subsequent requests.
- Preserved active pre-swap runs through refresh barriers and regression coverage for unchanged active adapters, resolve-before-register ordering, and capacity decrease.
- Replaced lossy request-start signals in the final two regression tests with buffered one-shot latches.
## 최종 검증
- `go test -count=20 -timeout=10s -run 'TestNodeConfigRefresh(DoesNotStopUnchangedActiveAdapterWhenSiblingChanges|DecreasesExistingAdapterGateCapacity)$' ./apps/node/internal/node` - PASS; reviewer rerun succeeded in 20 repeated executions.
- `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router` - PASS; reviewer rerun succeeded for targeted Node packages.
- `go test -count=1 ./apps/node/...` - PASS; reviewer rerun succeeded for full Node regression.
- `git diff --check` - PASS; no whitespace errors.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Completed task ids:
- `adapter-diff`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G07_0.log`, `plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`, `plan_local_G07_2.log`, `code_review_local_G07_2.log`, `plan_local_G07_3.log`, `code_review_local_G07_3.log`, `plan_local_G05_4.log`, `code_review_local_G05_4.log`; verification=`go test -count=20 -timeout=10s -run 'TestNodeConfigRefresh(DoesNotStopUnchangedActiveAdapterWhenSiblingChanges|DecreasesExistingAdapterGateCapacity)$' ./apps/node/internal/node`, `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router`, `go test -count=1 ./apps/node/...`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,322 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=0 tag=NCADAPT -->
# Plan - NCADAPT
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
Edge가 refresh payload를 보낼 계약이 생겨도 Node가 adapter registry를 갱신하지 못하면 provider capacity/endpoint 변경은 running Node에 반영되지 않는다. 현재 Node bootstrap은 initial payload로 registry/router/gates를 한 번 만들고 이후 교체 경로가 없다. 이 계획은 in-flight drain 정책을 완성하지 않고, 변경분을 계산해 새 registry snapshot을 라우팅에 반영하는 `adapter-diff`만 완료한다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `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-ops/rules/project/domain/node/rules.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/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.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`
- `proto/iop/runtime.proto`
- `apps/node/internal/bootstrap/module.go`
- `apps/node/internal/transport/session.go`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/router/router.go`
- `apps/node/internal/adapters/registry.go`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`
- `apps/node/internal/runtime/types.go`
- `apps/node/internal/adapters/factory_internal_test.go`
- `apps/node/internal/adapters/adapters_blackbox_test.go`
- `apps/node/internal/adapters/openai_compat/openai_compat_test.go`
- `apps/node/internal/router/router_test.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/node/node_concurrency_integration_test.go`
- `apps/node/internal/bootstrap/module_test.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- 상태: 승인됨, SDD 잠금 해제.
- 대상 Acceptance Scenario: S13 / Milestone Task `adapter-diff`.
- Evidence Map: S13은 `agent-task/m-runtime-reconnect-config-refresh/04+02_node_config_apply`에 연결되어 있으며 "Node adapter registry diff test for openai_compat"와 `adapter-diff`: capacity/endpoint changes reflected를 요구한다.
- 구현 checklist는 Node refresh payload 수신 이후 fresh registry build, diff summary, mutable router swap, openai_compat capacity/endpoint assertion을 중심으로 구성한다.
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/node-smoke.md`는 Node 변경 시 `go test ./apps/node/...`를 요구한다.
- `agent-test/local/platform-common-smoke.md`는 proto/generated 직접 수정 금지를 요구한다. 이 plan은 proto 계약을 새로 바꾸지 않고 NCPUSH generated types를 소비한다.
- full-cycle 실제 구동은 field task 범위이므로 이 plan의 PASS 조건에는 포함하지 않는다.
### 테스트 커버리지 공백
- Registry diff: 현재 `BuildFromPayload` tests는 fresh registry 생성만 검증하고, 기존 registry와 next payload의 diff/swap은 검증하지 않는다.
- Router live swap: 현재 `router.New`는 registry pointer를 고정해 live update test가 없다.
- openai_compat capacity/endpoint: factory tests는 payload parse를 보지만 refresh 후 registry에 반영되는지 확인하지 않는다.
- Drain: in-flight run 중 old adapter snapshot 유지와 stop 지연은 이 plan에서 제외하고 `06+05_node_drain_runtime_config`가 검증한다.
### 심볼 참조
- rename/remove 없음.
- `transport.Handler.OnConfigRefresh`는 predecessor NCPUSH에서 추가될 예정이며 이 plan은 그 method body를 adapter apply로 확장한다.
- `router.New` 반환 타입 또는 interface 확장이 필요하면 call sites는 `apps/node/internal/bootstrap/module.go`, `apps/node/internal/node/node_test.go`, `apps/node/internal/router/router_test.go`를 갱신한다.
### 분할 판단
- split decision policy를 먼저 평가했다. Adapter registry diff는 protocol push 계약 뒤에 구현 가능하고, drain/runtime-config는 concurrency와 persisted store boundary가 달라 후속 plan으로 분리한다.
- 공유 task group: `m-runtime-reconnect-config-refresh`.
- sibling subtask:
- `04+02_config_push_contract`: predecessor `02` 충족 후 작성된 active plan. 이 subtask의 direct predecessor다.
- `05+04_node_adapter_diff`: predecessor `04`는 현재 missing이다. 구현 시작 전 `agent-task/archive/**/m-runtime-reconnect-config-refresh/04+02_config_push_contract/complete.log` 또는 active `complete.log`가 필요하다.
- `06+05_node_drain_runtime_config`: predecessor `05` missing.
### 범위 결정 근거
- 포함: Node-side payload apply model, registry diff calculation, mutable router registry swap, openai_compat capacity/endpoint/header change application, tests.
- 제외: Edge push contract/proto generation(NCPUSH), in-flight run drain and deferred stop(NCDRAIN), runtime concurrency/workspace root policy(NCDRAIN), field validation.
- Adapter 변경은 fresh adapter instance build + atomic router swap으로 처리한다. Per-adapter in-flight stop 지연은 이 plan에서 구현하지 않으며, changed/removed adapter stop은 active run이 없을 때만 즉시 수행하거나 NCDRAIN으로 넘길 수 있게 결과에 표시한다.
### 빌드 등급
- lane/GNN: `cloud-G07`.
- 이유: Node runtime shared state, router interface, adapter lifecycle, tests가 함께 바뀌며 후속 drain plan과 의존성이 있다.
## 구현 체크리스트
- [ ] `04+02_config_push_contract`의 `complete.log`가 active/archive 중 하나에 있는지 확인하고, 없으면 구현하지 말고 review stub `검증 결과`에 dependency blocker로 기록한다.
- [ ] Node adapter config set/diff 모델을 추가해 adapter instance 추가/수정/삭제와 openai_compat endpoint/header/capacity 변경을 식별한다. 검증: registry diff unit test가 added/updated/removed/unchanged를 확인한다.
- [ ] Router를 live registry swap 가능하게 만들고 Node `OnConfigRefresh`가 payload build, diff, registry start/swap, ack response를 수행하게 한다. 검증: Node handler test가 refresh 후 후속 resolve/capabilities에서 새 openai_compat capacity를 확인한다.
- [ ] openai_compat endpoint/header 변경이 새 adapter instance에 반영되는지 httptest provider로 확인한다. 검증: 기존 endpoint와 새 endpoint 중 refresh 후 새 endpoint만 호출된다.
- [ ] 전체 Node 회귀로 `go test -count=1 ./apps/node/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 subtask는 `05+04_node_adapter_diff`이므로 predecessor `04`만 요구한다.
- predecessor 상태: missing. 구현 시작 전 `agent-task/m-runtime-reconnect-config-refresh/04+02_config_push_contract/complete.log` 또는 `agent-task/archive/**/m-runtime-reconnect-config-refresh/04+02_config_push_contract/complete.log`가 있어야 한다.
- predecessor가 없으면 소스 코드를 수정하지 말고 active `CODE_REVIEW-cloud-G07.md`에 dependency blocker를 기록해 code-review로 넘긴다.
### [NCADAPT-1] Adapter Config Diff Model
#### 문제
`apps/node/internal/adapters/factory.go:18`은 payload에서 fresh `Registry`만 만든다. `apps/node/internal/adapters/registry.go:21`의 `Registry`는 entries/order만 보유하고 payload fingerprint나 diff API가 없다.
Before `apps/node/internal/adapters/factory.go:18`:
```go
// BuildFromPayload creates a Registry from a NodeConfigPayload received from edge.
func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Registry, error) {
reg := NewRegistry()
```
#### 해결 방법
`adapters` package에 config set builder를 추가해 payload adapter별 stable key, type, normalized config fingerprint, constructed adapter를 함께 반환한다. Diff는 old set과 next set을 비교해 `Added`, `Updated`, `Removed`, `Unchanged`를 만든다. Existing `BuildFromPayload`는 새 builder를 호출하도록 유지해 backward compatibility를 지킨다.
After:
```go
type ConfigSet struct {
Registry *Registry
Items map[string]ConfigItem
}
type ConfigItem struct {
Key string
Type string
Fingerprint string
}
func BuildConfigSet(payload *iop.NodeConfigPayload, logger *zap.Logger) (*ConfigSet, error) {
// normalizes typed oneof and legacy settings before fingerprinting.
}
func DiffConfigSets(current, next *ConfigSet) ConfigDiff {
// returns added/updated/removed/unchanged keys.
}
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/adapters/factory.go`: `BuildFromPayload`를 `BuildConfigSet` 기반으로 유지.
- [ ] `apps/node/internal/adapters/registry.go`: needed read-only metadata accessors만 추가.
- [ ] `apps/node/internal/adapters/config_set.go` 또는 기존 파일 내 helper: diff model 구현.
- [ ] `apps/node/internal/adapters/factory_internal_test.go`/`adapters_blackbox_test.go`: diff tests 추가.
#### 테스트 작성
- `TestDiffConfigSetsOpenAICompatCapacityEndpoint`: capacity/endpoint/header 변경이 updated로 잡히는지 확인.
- `TestDiffConfigSetsAddedRemovedUnchanged`: add/remove/no-op 분류 확인.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/adapters
```
기대 결과: adapter factory/diff tests PASS.
### [NCADAPT-2] Mutable Router And Node Apply
#### 문제
`apps/node/internal/router/router.go:13`의 `defaultRouter`는 registry pointer를 고정한다. `apps/node/internal/bootstrap/module.go:103`은 initial registry로 router를 만들고, `apps/node/internal/node/node.go:93`은 그 router를 통해 adapter를 resolve한다. Refresh 후 새 registry를 route path에 반영할 방법이 없다.
Before `apps/node/internal/router/router.go:13`:
```go
type defaultRouter struct {
registry *adapters.Registry
logger *zap.Logger
}
```
#### 해결 방법
Router에 synchronized registry holder를 추가한다. `Resolve`, `ResolveAdapter`, `LookupAdapter`, `GetAdapter`는 현재 registry snapshot을 읽고, `SetRegistry`는 fresh registry가 start된 뒤 atomic하게 교체한다. Node는 current `ConfigSet`을 보유하고 `OnConfigRefresh`에서 next set build/diff/start/swap/response를 수행한다.
After:
```go
type MutableRouter interface {
runtime.Router
SetRegistry(*adapters.Registry)
}
type defaultRouter struct {
mu sync.RWMutex
registry *adapters.Registry
logger *zap.Logger
}
func (r *defaultRouter) SetRegistry(reg *adapters.Registry) {
r.mu.Lock()
defer r.mu.Unlock()
r.registry = reg
}
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/router/router.go`: mutable registry holder 추가.
- [ ] `apps/node/internal/bootstrap/module.go`: concrete mutable router와 initial config set을 Node에 전달.
- [ ] `apps/node/internal/node/node.go`: `OnConfigRefresh`가 config set diff를 적용하고 response status를 반환.
- [ ] `apps/node/internal/router/router_test.go`: swap 후 resolve가 새 registry를 보는지 확인.
- [ ] `apps/node/internal/node/node_test.go`: refresh 후 capabilities/resolve가 새 capacity를 보는지 확인.
#### 테스트 작성
- `TestRouterSetRegistryAffectsSubsequentResolve`: old registry에는 없는 adapter가 swap 후 resolve된다.
- `TestNodeConfigRefreshAppliesOpenAICompatCapacity`: refresh 후 command capabilities가 새 capacity를 반환한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/router ./apps/node/internal/node
```
기대 결과: router swap과 Node apply tests PASS.
### [NCADAPT-3] OpenAI Compat Endpoint/Header Verification
#### 문제
`apps/node/internal/adapters/factory.go:93`는 openai_compat proto config를 `config.OpenAICompatConf`로 변환하지만, refresh 후 새 endpoint/header가 실제 adapter instance에 들어갔는지 확인하는 테스트가 없다.
Before `apps/node/internal/adapters/factory.go:93`:
```go
func openAICompatConfFromProto(m *iop.OpenAICompatAdapterConfig) config.OpenAICompatConf {
return config.OpenAICompatConf{
Enabled: true,
Provider: m.GetProvider(),
Endpoint: m.GetEndpoint(),
Headers: m.GetHeaders(),
```
#### 해결 방법
`httptest.Server` 두 개를 만들고 refresh 전/후 openai_compat adapter command/probe/execute 중 가장 좁은 기존 public path로 endpoint 호출을 관측한다. Header 변경은 server-side request assertion으로 검증한다. 새 adapter가 router에 반영된 뒤 후속 request만 새 endpoint로 가야 한다.
After:
```go
oldCalls := atomic.Int32{}
newCalls := atomic.Int32{}
// initial payload uses old server, refresh payload uses new server.
// after refresh, a follow-up capability/probe/execute path hits only new server.
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/adapters/openai_compat/openai_compat_test.go` 또는 Node integration test: endpoint/header refresh assertion 추가.
- [ ] `apps/node/internal/node/node_test.go`: Node refresh response가 updated adapter keys를 result metadata에 담는다면 assertion 추가.
#### 테스트 작성
- `TestNodeConfigRefreshUsesUpdatedOpenAICompatEndpoint`: refresh 후 후속 provider interaction이 새 endpoint로 간다.
- `TestNodeConfigRefreshUsesUpdatedOpenAICompatHeaders`: configured header가 새 endpoint request에 포함된다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/node
```
기대 결과: endpoint/header refresh assertions PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/factory.go` | NCADAPT-1 |
| `apps/node/internal/adapters/registry.go` | NCADAPT-1 |
| `apps/node/internal/adapters/config_set.go` | NCADAPT-1 |
| `apps/node/internal/adapters/factory_internal_test.go` | NCADAPT-1 |
| `apps/node/internal/adapters/adapters_blackbox_test.go` | NCADAPT-1 |
| `apps/node/internal/router/router.go` | NCADAPT-2 |
| `apps/node/internal/bootstrap/module.go` | NCADAPT-2 |
| `apps/node/internal/node/node.go` | NCADAPT-2 |
| `apps/node/internal/router/router_test.go` | NCADAPT-2 |
| `apps/node/internal/node/node_test.go` | NCADAPT-2, NCADAPT-3 |
| `apps/node/internal/adapters/openai_compat/openai_compat_test.go` | NCADAPT-3 |
## 최종 검증
```bash
go test -count=1 ./apps/node/internal/adapters
go test -count=1 ./apps/node/internal/router ./apps/node/internal/node
go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/node
go test -count=1 ./apps/node/...
```
기대 결과: adapter diff, router swap, Node refresh apply, openai_compat endpoint/header assertions PASS. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,279 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=1 tag=REVIEW_NCADAPT -->
# Plan - REVIEW_NCADAPT
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
첫 NCADAPT 구현은 adapter registry diff와 router swap 기본 경로를 추가했지만, refresh 이후 실제 run admission과 lifecycle 경계에 세 가지 동작 문제가 남았다. 특히 capacity 변경은 capabilities에는 반영되지만 기존 per-adapter gate가 남아 있으면 후속 실행 제한이 갱신되지 않는다. 또한 router lock 구조와 old registry stop 정책은 runtime refresh 중 교착 또는 in-flight 실행 손상을 만들 수 있다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Archive Evidence Snapshot
- Prior active task: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff`
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/node/node.go:188` keeps old per-adapter gate capacity after refresh.
- `apps/node/internal/router/router.go:71` nests `RLock` through `ResolveAdapter` -> `Resolve`.
- `apps/node/internal/node/node.go:339` stops the old registry immediately despite the drain boundary.
- Suggested summary:
- `apps/node/internal/adapters/config_set.go:52` has an unused `adapter` variable and `_ = adapter` cleanup.
- Verification evidence from review:
- `go test -count=1 ./apps/node/...` PASS.
- `git diff --check` PASS.
- Roadmap carryover: keep `adapter-diff` target open; no `complete.log` was written.
- Additional archive reread allowed: only the two archived files listed above.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/node/rules.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/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-roadmap/current.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-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/complete.log`
- `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_cloud_G07_0.log`
- `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_cloud_G07_0.log`
- `apps/node/internal/adapters/config_set.go`
- `apps/node/internal/adapters/config_set_test.go`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/adapters/registry.go`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`
- `apps/node/internal/bootstrap/module.go`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/router/router.go`
- `apps/node/internal/router/router_test.go`
- `apps/node/internal/transport/session.go`
- `apps/node/internal/transport/parser.go`
- `apps/node/internal/runtime/types.go`
- `proto/iop/runtime.proto`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- 상태: 승인됨, SDD 잠금 해제.
- 대상 Acceptance Scenario: S13 / Milestone Task `adapter-diff`.
- Evidence Map: S13은 Node adapter registry diff test for openai_compat와 capacity/endpoint changes reflected evidence를 요구한다.
- 이번 follow-up은 S13을 닫기 위해 capacity가 capabilities뿐 아니라 run admission에도 반영되고, refresh swap이 교착/old lifecycle stop으로 route path를 깨지 않는지 검증한다.
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md` present/read.
- Matched profiles: `agent-test/local/node-smoke.md`, `agent-test/local/platform-common-smoke.md`, `agent-test/local/edge-smoke.md`.
- 적용 명령: Node 변경은 `go test -count=1 ./apps/node/...`가 필수다. proto는 이번 follow-up에서 새로 바꾸지 않지만 기존 push-contract 변경과 함께 동작하므로 targeted node tests와 `git diff --check`를 함께 실행한다.
- full-cycle 실제 구동은 field/drain task 범위라 이번 follow-up PASS 조건에는 포함하지 않는다.
### 테스트 커버리지 공백
- Capacity refresh after an adapter gate already exists: 기존 테스트는 capabilities value만 확인해 stale gate를 잡지 못한다. 회귀 테스트 필요.
- Router swap while requests resolve: 기존 테스트는 순차 swap만 확인해 nested `RLock` 교착 가능성을 잡지 못한다. 회귀 테스트 필요.
- Old registry lifecycle during active run: 기존 테스트는 refresh 중 active run이 있을 때 old lifecycle adapter가 즉시 stopped 되지 않는지 확인하지 않는다. 회귀 테스트 필요.
### 심볼 참조
- rename/remove 없음.
- `router.New` 반환 타입 변경은 이미 call site가 갱신되어 있다. 이번 follow-up은 public interface를 새로 바꾸지 않는 방향을 우선한다.
### 분할 판단
- 기존 split subtask `05+04_node_adapter_diff`의 follow-up이다.
- predecessor `04`는 `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/complete.log`로 충족됐다.
- 세 Required는 모두 Node adapter refresh apply correctness를 막는 한 묶음이므로 별도 subtask로 쪼개지 않는다.
### 범위 결정 근거
- 포함: Node per-adapter gate capacity refresh, router lock refactor, old registry stop/defer boundary, tests.
- 제외: Edge push contract/proto 변경, full drain policy 완성, runtime concurrency/workspace root refresh, field validation.
- drain 전체 구현은 `06+05_node_drain_runtime_config` 범위다. 이 follow-up은 old registry를 즉시 stop하지 않아 다음 task가 in-flight drain을 안전하게 완성할 수 있는 경계까지만 정리한다.
### 빌드 등급
- lane/GNN: `cloud-G07`.
- 이유: Node runtime shared state, concurrency gate, router swap lock, lifecycle boundary가 함께 얽혀 있고 기존 local unit tests가 놓친 refresh-time 동작을 보강해야 한다.
## 구현 체크리스트
- [ ] Refresh 후 기존 per-adapter gate capacity를 새 adapter capabilities 기준으로 갱신한다. 검증: refresh 전에 old capacity gate를 생성한 뒤 capacity 증가/감소가 후속 run admission에 반영되는 regression test를 추가한다.
- [ ] Router `ResolveAdapter`가 registry lock을 중첩 획득하지 않도록 refactor한다. 검증: registry swap과 resolve가 동시에 반복되어도 timeout/deadlock 없이 완료되는 router test를 추가한다.
- [ ] `OnConfigRefresh`가 active run이 있는 old registry/lifecycle adapter를 즉시 stop하지 않도록 한다. 검증: active run 중 refresh가 old adapter `Stop`을 호출하지 않고 후속 request는 새 registry로 라우팅되는 test를 추가한다.
- [ ] `go test -count=1 ./apps/node/internal/adapters ./apps/node/internal/router ./apps/node/internal/node`, `go test -count=1 ./apps/node/...`, `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 follow-up은 같은 subtask `05+04_node_adapter_diff` 안의 plan=1이다.
- 선행 `04+02_config_push_contract` 완료 근거: `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/04+02_config_push_contract/complete.log`.
- 구현 순서: gate capacity update -> router lock refactor -> old registry stop boundary -> focused tests -> full node regression.
### [REVIEW_NCADAPT-1] Refresh Admission Gate Capacity
#### 문제
`apps/node/internal/node/node.go:188`의 `adapterGateFor`는 adapter key가 이미 있으면 기존 `fifoGate`를 그대로 반환한다. refresh 전에 해당 adapter로 run이 실행되어 gate가 만들어진 경우, `OnConfigRefresh`로 capacity가 바뀌어도 `fifoGate.capacity`가 갱신되지 않아 후속 run admission이 old capacity를 따른다.
Before `apps/node/internal/node/node.go:188`:
```go
func (n *Node) adapterGateFor(adapterKey string, caps runtime.Capabilities) *fifoGate {
n.adapterGatesMu.Lock()
defer n.adapterGatesMu.Unlock()
g, ok := n.adapterGates[adapterKey]
if !ok {
g = newFifoGate(caps.MaxConcurrency)
n.adapterGates[adapterKey] = g
}
return g
}
```
#### 해결 방법
`fifoGate`에 capacity 갱신 method를 추가해 in-flight count를 보존한다. `OnConfigRefresh`가 successful swap 이후 added/updated adapter의 capabilities를 읽어 기존 gate capacity를 갱신하고, removed adapter는 새 run이 resolve되지 않게 하되 active ticket release를 깨지 않도록 처리한다. `config_set.go`의 unused `adapter` 변수는 이 항목에서 함께 제거해도 된다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/run_manager.go`: `fifoGate` capacity update helper 추가.
- [ ] `apps/node/internal/node/node.go`: refresh diff 적용 후 gate capacity refresh helper 호출.
- [ ] `apps/node/internal/node/node_test.go`: old gate 생성 후 capacity increase/decrease가 admission에 반영되는 test 추가.
- [ ] `apps/node/internal/adapters/config_set.go`: unused `adapter` 변수와 `_ = adapter` 제거.
#### 테스트 작성
- `TestNodeConfigRefreshUpdatesExistingAdapterGateCapacity`: capacity 1로 gate를 만든 뒤 refresh capacity 2 후 동시 후속 run 2개가 admission되고, capacity 감소 후 신규 admission이 새 limit을 따르는지 확인한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
```
기대 결과: stale gate capacity regression과 adapter diff tests PASS.
### [REVIEW_NCADAPT-2] Router Swap Lock Safety
#### 문제
`apps/node/internal/router/router.go:71`의 `ResolveAdapter`는 `RLock`을 잡은 상태에서 `Resolve`를 호출하고, `Resolve`는 `apps/node/internal/router/router.go:36`에서 다시 `RLock`을 잡는다. `SetRegistry` writer가 두 lock 사이에 대기하면 두 번째 reader lock이 writer 대기 때문에 막힐 수 있어 refresh swap과 resolve가 겹칠 때 교착 위험이 있다.
Before `apps/node/internal/router/router.go:71`:
```go
func (r *defaultRouter) ResolveAdapter(ctx context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, runtime.Adapter, error) {
r.mu.RLock()
defer r.mu.RUnlock()
spec, err := r.Resolve(ctx, req)
```
#### 해결 방법
lock을 한 번만 잡도록 `resolveLocked` helper를 만들거나, lock 안에서 registry pointer를 snapshot으로 복사한 뒤 lock 밖에서 spec을 구성한다. `Resolve`, `ResolveAdapter`, `LookupAdapter`, `GetAdapter` 모두 lock order가 단순하게 유지되어야 한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/router/router.go`: nested `RLock` 제거.
- [ ] `apps/node/internal/router/router_test.go`: swap/resolve concurrent regression 추가.
#### 테스트 작성
- `TestRouterResolveAdapterConcurrentSetRegistryDoesNotDeadlock`: timeout context나 done channel을 사용해 `ResolveAdapter`와 `SetRegistry` 반복이 완료되는지 확인한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/router
```
기대 결과: router swap tests PASS.
### [REVIEW_NCADAPT-3] Defer Old Registry Stop Before Drain
#### 문제
`apps/node/internal/node/node.go:339`는 registry swap 직후 old registry를 무조건 `Stop`한다. 이번 plan은 in-flight drain을 완료하지 않는다고 명시했으므로, active run이 있는 old adapter snapshot을 즉시 stop하면 CLI lifecycle adapter 같은 구현에서 기존 실행/session이 끊길 수 있다.
Before `apps/node/internal/node/node.go:339`:
```go
if n.currentConfigSet != nil && n.currentConfigSet.Registry != nil {
if err := n.currentConfigSet.Registry.Stop(context.Background()); err != nil {
n.logger.Warn("config refresh: failed to stop old adapters", zap.Error(err))
}
}
```
#### 해결 방법
`runManager`에서 adapter key별 active run 존재 여부를 확인할 수 있게 하거나, 이번 follow-up에서는 old registry stop을 drain task로 명시적으로 defer한다. 후속 request는 새 router registry를 보되, active old adapter lifecycle을 즉시 종료하지 않는 것이 핵심이다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/run_manager.go`: active run by adapter helper 추가 또는 defer 정책에 필요한 최소 helper 추가.
- [ ] `apps/node/internal/node/node.go`: active changed/removed adapter의 old registry stop을 즉시 수행하지 않도록 변경.
- [ ] `apps/node/internal/node/node_test.go`: active run 중 refresh가 old lifecycle adapter stop을 호출하지 않는 test 추가.
#### 테스트 작성
- `TestNodeConfigRefreshDoesNotStopOldRegistryWithActiveRun`: lifecycle test adapter로 active run을 유지한 상태에서 refresh를 적용하고, old adapter `Stop`이 호출되지 않으며 후속 resolve/capabilities는 새 registry를 보는지 확인한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node
```
기대 결과: in-flight boundary regression PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/node/run_manager.go` | REVIEW_NCADAPT-1, REVIEW_NCADAPT-3 |
| `apps/node/internal/node/node.go` | REVIEW_NCADAPT-1, REVIEW_NCADAPT-3 |
| `apps/node/internal/node/node_test.go` | REVIEW_NCADAPT-1, REVIEW_NCADAPT-3 |
| `apps/node/internal/router/router.go` | REVIEW_NCADAPT-2 |
| `apps/node/internal/router/router_test.go` | REVIEW_NCADAPT-2 |
| `apps/node/internal/adapters/config_set.go` | REVIEW_NCADAPT-1 |
## 최종 검증
```bash
go test -count=1 ./apps/node/internal/adapters ./apps/node/internal/router ./apps/node/internal/node
go test -count=1 ./apps/node/...
git diff --check
```
기대 결과: targeted Node refresh/router tests와 전체 Node regression PASS, whitespace error 없음. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,131 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=4 tag=REVIEW_REVIEW_REVIEW_REVIEW_NCADAPT -->
# Plan - REVIEW_REVIEW_REVIEW_REVIEW_NCADAPT
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
세 번째 NCADAPT follow-up은 production 동작을 새로 바꾸지 않고 두 regression test의 증거력을 보강했다. 리뷰 결과, 테스트가 `time.Sleep` 대신 request-start signal을 사용하도록 개선된 점은 확인됐지만, 신호 채널이 unbuffered라 handler가 먼저 도착하면 nonblocking send가 드롭될 수 있다. 이번 follow-up은 해당 latch를 유실 불가능하게 만들어 test evidence 신뢰성을 닫는다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Archive Evidence Snapshot
- Prior active task: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff`
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_local_G07_3.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_local_G07_3.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/node/node_test.go:2179` and `apps/node/internal/node/node_test.go:2421` create unbuffered `startedChan` values while the httptest handlers use nonblocking sends. A handler can reach the send before the test goroutine starts receiving, drop the signal, then block on `blockChan` while the test blocks forever on `<-startedChan`.
- Suggested summary: 없음.
- Verification evidence from review:
- `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router` PASS.
- `go test -count=20 -timeout=10s -run 'TestNodeConfigRefresh(DoesNotStopUnchangedActiveAdapterWhenSiblingChanges|DecreasesExistingAdapterGateCapacity)$' ./apps/node/internal/node` PASS.
- `go test -count=1 ./apps/node/...` PASS.
- `git diff --check` PASS.
- Affected files: `apps/node/internal/node/node_test.go`.
- Roadmap carryover: keep `adapter-diff` target open; no `complete.log` was written.
- Additional archive reread allowed: only the two archived files listed above.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- Completion mode: check-on-pass
## 분석 결과
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- 상태: 승인됨, SDD 잠금 해제.
- 대상 Acceptance Scenario: S13 / Milestone Task `adapter-diff`.
- Evidence Map: S13은 Node adapter registry diff test for openai_compat와 capacity/endpoint changes reflected evidence를 요구한다.
- 이번 follow-up은 S13 evidence 자체를 바꾸지 않고, 그 evidence를 뒷받침하는 두 regression test의 request-start synchronization을 deterministic하게 만든다.
### 테스트 환경 규칙
- test_env: `local`.
- Matched profile: `agent-test/local/node-smoke.md`.
- 적용 명령: focused two-test 반복 실행, targeted Node packages, full Node regression, `git diff --check`.
### 분할 판단
- split decision policy를 평가했다.
- 이 follow-up은 같은 subtask `05+04_node_adapter_diff`의 plan=4이며, 단일 테스트 동기화 패턴 수정만 다룬다.
- production behavior, proto, Edge, field smoke로 범위를 확장하지 않는다.
### 범위 결정 근거
- 포함: `apps/node/internal/node/node_test.go`의 두 `startedChan` latch를 buffered 또는 equivalent lossless signal로 바꾸는 수정.
- 선택 포함: stale capacity 회귀 시 test가 장시간 hang하지 않도록 두 번째 run에 짧은 context timeout assertion을 추가하는 보강.
- 제외: `apps/node/internal/node/node.go`, `run_manager.go`, router, adapter factory production behavior 변경.
### 빌드 등급
- lane/GNN: `local-G05`.
- 이유: 수정 범위는 두 테스트의 synchronization evidence에 한정되고 deterministic Go tests와 코드 inspection으로 검증 가능하다.
## 구현 체크리스트
- [ ] `TestNodeConfigRefreshDoesNotStopUnchangedActiveAdapterWhenSiblingChanges`의 request-start signal이 handler 선도착 시에도 유실되지 않도록 buffered channel 또는 equivalent latch로 수정한다.
- [ ] `TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity`의 request-start signal이 handler 선도착 시에도 유실되지 않도록 buffered channel 또는 equivalent latch로 수정한다.
- [ ] focused 반복 검증, targeted Node packages, full Node regression, `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 follow-up은 같은 subtask `05+04_node_adapter_diff` 안의 plan=4다.
- 구현 순서: first test latch fix -> second test latch fix -> focused repeated test -> targeted/full verification.
### [REVIEW_REVIEW_REVIEW_REVIEW_NCADAPT-1] Lossless Request-Start Signal
#### 문제
두 테스트의 `startedChan`이 unbuffered인데 handler send가 nonblocking이다. 수신자가 아직 대기하지 않는 아주 짧은 스케줄링 구간에서 signal이 드롭될 수 있고, 테스트는 `<-startedChan`에서 멈춘다.
#### 해결 방법
두 테스트의 `startedChan`을 `make(chan struct{}, 1)`로 만들거나 동일한 의미의 one-shot latch helper를 사용한다. 기존 `select { case startedChan <- struct{}{}: default: }` 패턴은 buffered channel과 함께 유지해도 된다. 이렇게 하면 handler가 test goroutine보다 먼저 도착해도 signal 하나가 저장되고, 이후 `<-startedChan`이 확정적으로 진행된다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node_test.go`: `TestNodeConfigRefreshDoesNotStopUnchangedActiveAdapterWhenSiblingChanges`의 `startedChan` 신호 유실 가능성을 제거한다.
- [ ] `apps/node/internal/node/node_test.go`: `TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity`의 `startedChan` 신호 유실 가능성을 제거한다.
#### 테스트 작성
- 새 테스트 추가는 필수 아님. 기존 두 regression test가 deterministic latch를 사용하도록 수정한다.
- 선택 보강: stale capacity 2 회귀 시 `run-2`가 block되어 긴 timeout에 의존하지 않도록 `context.WithTimeout`을 사용해 `ErrConcurrencyLimitExceeded`와 다른 결과가 즉시 실패하도록 만든다.
#### 중간 검증
```bash
go test -count=20 -timeout=10s -run 'TestNodeConfigRefresh(DoesNotStopUnchangedActiveAdapterWhenSiblingChanges|DecreasesExistingAdapterGateCapacity)$' ./apps/node/internal/node
```
기대 결과: 두 regression test가 반복 실행에서도 timeout 없이 PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/node/node_test.go` | REVIEW_REVIEW_REVIEW_REVIEW_NCADAPT-1 |
## 최종 검증
```bash
go test -count=20 -timeout=10s -run 'TestNodeConfigRefresh(DoesNotStopUnchangedActiveAdapterWhenSiblingChanges|DecreasesExistingAdapterGateCapacity)$' ./apps/node/internal/node
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router
go test -count=1 ./apps/node/...
git diff --check
```
기대 결과: focused 반복 테스트, targeted Node tests, 전체 Node regression PASS, whitespace error 없음.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,300 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=2 tag=REVIEW_REVIEW_NCADAPT -->
# Plan - REVIEW_REVIEW_NCADAPT
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
두 번째 NCADAPT 리뷰에서 adapter capacity/endpoint 기본 반영과 router deadlock fix는 확인됐지만, old registry lifecycle stop 경계에 아직 실행 손상 race가 남았다. 특히 refresh가 old registry 전체를 멈추는 현재 방식은 unchanged active adapter까지 같이 stop할 수 있고, run이 old adapter를 resolve한 뒤 `runManager`에 등록되기 전 refresh가 들어오는 ordering을 막지 못한다. 또한 plan이 요구한 capacity 감소 regression evidence가 빠져 S13의 capacity 변경 검증이 불완전하다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Archive Evidence Snapshot
- Prior active task: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff`
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_cloud_G07_1.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/node/node.go:372` can stop the whole old registry while an unchanged adapter in that registry still has an active run.
- `apps/node/internal/node/node.go:80` through `apps/node/internal/node/node.go:149` has a resolve-to-register race where refresh can stop an old adapter before the run becomes visible to `runManager`.
- `apps/node/internal/node/node_test.go:1886` only tests adapter gate capacity increase, not decrease.
- Suggested summary: 없음.
- Verification evidence from review:
- `go test -count=1 ./apps/node/internal/adapters ./apps/node/internal/router ./apps/node/internal/node` PASS.
- `go test -count=1 ./apps/node/...` PASS.
- `git diff --check` PASS.
- Roadmap carryover: keep `adapter-diff` target open; no `complete.log` was written.
- Additional archive reread allowed: only the two archived files listed above.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/node/rules.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/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-contract/index.md`
- `agent-contract/provided/openai-compatible-api.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-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_cloud_G07_1.log`
- `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_cloud_G07_1.log`
- `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/PLAN-cloud-G08.md`
- `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/CODE_REVIEW-cloud-G08.md`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/router/router.go`
- `apps/node/internal/router/router_test.go`
- `apps/node/internal/adapters/config_set.go`
- `apps/node/internal/adapters/registry.go`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`
- `apps/node/internal/adapters/ollama/ollama.go`
- `apps/node/internal/adapters/vllm/vllm.go`
- `apps/node/internal/adapters/mock/mock.go`
- `apps/node/internal/adapters/cli/cli.go`
- `apps/node/internal/bootstrap/module.go`
- `apps/node/internal/runtime/types.go`
- `apps/node/internal/transport/session.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/transport/server.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/service/run_dispatch.go`
- `proto/iop/runtime.proto`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- 상태: 승인됨, SDD 잠금 해제.
- 대상 Acceptance Scenario: S13 / Milestone Task `adapter-diff`.
- Evidence Map: S13은 Node adapter registry diff test for openai_compat와 capacity/endpoint changes reflected evidence를 요구한다.
- 이번 follow-up은 S13의 capacity evidence를 증가/감소 양방향으로 완성하고, adapter diff 적용 중 old snapshot lifecycle이 후속 drain task 전에 실행을 깨지 않는 최소 안전 경계를 보강한다.
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md` present/read.
- Matched profiles: `agent-test/local/node-smoke.md`, `agent-test/local/platform-common-smoke.md`, `agent-test/local/edge-smoke.md`.
- 적용 명령: Node 변경은 `go test -count=1 ./apps/node/...`가 필수다. 이 follow-up은 `apps/node/internal/node`, `apps/node/internal/adapters`, 필요 시 `apps/node/internal/router` 대상 테스트를 먼저 실행하고, 최종으로 전체 Node regression과 `git diff --check`를 실행한다.
- Edge/proto 파일은 현재 working tree에 남아 있지만 이번 follow-up의 수정 범위가 아니다. Edge/proto 검증은 이 plan의 pass 조건이 아니며, 해당 범위는 다른 subtask/이전 task evidence로만 다룬다.
### 테스트 커버리지 공백
- Old registry whole-stop: unchanged lifecycle adapter에 active run이 있는 상태에서 다른 adapter만 변경되는 refresh test가 없다.
- Resolve-to-register ordering: run이 old adapter를 resolve했지만 `runManager`에 등록되기 전 refresh가 old registry stop 판단을 수행하는 race test가 없다.
- Adapter gate capacity decrease: existing gate가 capacity 2에서 1로 줄 때 후속 admission이 새 limit을 따르는 test가 없다.
### 심볼 참조
- rename/remove 없음.
- `configSetMu`를 `OnRunRequest` barrier로 사용하면 `OnConfigRefresh` 외 call site는 없다.
- `fifoGate.updateCapacity` call site는 `Node.OnConfigRefresh`뿐이다.
### 분할 판단
- split decision policy를 평가했다.
- 이 follow-up은 기존 split subtask `05+04_node_adapter_diff`의 plan=2이며, 세 Required 모두 Node adapter refresh apply correctness를 닫는 같은 predecessor 품질 보강이다.
- sibling `06+05_node_drain_runtime_config`은 아직 구현되지 않은 active stub이며, full drain/wait/stop-after-completion 구현은 그 범위다. 이번 follow-up은 `05` PASS를 막는 최소 lifecycle safety와 missing regression만 수정한다.
### 범위 결정 근거
- 포함: `OnRunRequest`와 `OnConfigRefresh` 사이의 refresh barrier, old registry stop 판단 보수화, adapter gate capacity decrease regression.
- 제외: full deferred stop scheduling, per-key registry stop helper, global runtime concurrency live apply, workspace_root restart_required, Edge classifier/result 변경, proto 변경, field smoke.
- 제외된 항목은 `06+05_node_drain_runtime_config` 또는 이미 완료된 push-contract 범위에 속한다.
### 빌드 등급
- lane/GNN: `local-G07`.
- 이유: 범위가 Node refresh lifecycle과 regression tests로 좁고 deterministic Go tests로 검증 가능하지만, shared-state ordering과 lifecycle stop race를 다루므로 local 고난도 리뷰가 필요하다.
## 구현 체크리스트
- [ ] Old registry 전체 stop이 unchanged active adapter를 멈추지 않도록 refresh stop 판단을 보수화한다. 검증: unchanged active lifecycle adapter와 changed/removed sibling adapter를 함께 둔 regression test를 추가한다.
- [ ] `ResolveAdapter` 이후 `runManager.register` 전 refresh가 old registry를 stop하지 못하도록 run admission/register와 refresh stop 판단 사이에 ordering barrier를 추가한다. 검증: capability/registration 지연 test로 refresh가 미등록 old run을 stop하지 않는지 확인한다.
- [ ] Existing adapter gate capacity decrease regression을 추가한다. 검증: capacity 2 -> 1 refresh 후 기존 admitted run은 유지되고 새 초과 run은 `ErrConcurrencyLimitExceeded`로 거부된다.
- [ ] `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router`, `go test -count=1 ./apps/node/...`, `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 follow-up은 같은 subtask `05+04_node_adapter_diff` 안의 plan=2다.
- 선행 `04+02_config_push_contract`는 이전 plan의 predecessor evidence로 충족되어 있다.
- 구현 순서: stop 판단 보수화 -> resolve/register barrier -> capacity decrease regression -> focused tests -> full node regression.
### [REVIEW_REVIEW_NCADAPT-1] Old Registry Stop Scope
#### 문제
`apps/node/internal/node/node.go:372`는 changed/removed adapter에 active run이 없으면 old registry 전체를 stop한다. 그러나 `adapters.Registry.Stop`은 registry 안의 모든 lifecycle adapter를 멈추므로, unchanged adapter에 active run이 있어도 다른 adapter 변경만으로 그 run의 adapter/session이 stop될 수 있다.
#### 해결 방법
이번 follow-up에서는 full drain 구현을 만들지 않는다. `Registry.Stop`이 전체 stop인 동안에는 old registry에 active run이 하나라도 있으면 old registry stop을 defer한다. 추후 `06+05_node_drain_runtime_config`에서 per-key stop 또는 wait-after-completion을 구현할 수 있게, 변경은 최소 helper 수준으로 유지한다.
Before `apps/node/internal/node/node.go:355`:
```go
// Stop the old registry if there are no active runs on updated/removed adapters.
hasActiveRuns := false
for _, key := range diff.Updated {
if n.runs.hasActiveRunsForAdapter(key) {
hasActiveRuns = true
break
}
}
```
After:
```go
// Registry.Stop stops every lifecycle adapter in the old registry, so defer
// the whole old registry while any pre-swap run is active.
hasActiveRuns := n.runs.hasAnyActiveRuns()
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/run_manager.go`: active run 존재 여부 helper 추가.
- [ ] `apps/node/internal/node/node.go`: old registry stop 판단을 전체 active run 기준으로 보수화.
- [ ] `apps/node/internal/node/node_test.go`: unchanged active adapter + changed sibling adapter refresh regression 추가.
#### 테스트 작성
- `TestNodeConfigRefreshDoesNotStopUnchangedActiveAdapterWhenSiblingChanges`: old registry에 lifecycle adapter A/B를 두고 A run을 block한다. Refresh로 B fingerprint만 바꾸거나 B를 제거한다. A `Stop`이 호출되지 않고 A run이 완료되는지 확인한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
```
기대 결과: old registry stop scope regression PASS.
### [REVIEW_REVIEW_NCADAPT-2] Resolve To Register Refresh Barrier
#### 문제
`apps/node/internal/node/node.go:80`에서 old adapter를 resolve한 run은 `apps/node/internal/node/node.go:149`에서 `runManager`에 등록되기 전까지 refresh active-run 검사에 보이지 않는다. 이 사이에 `OnConfigRefresh`가 들어오면 `apps/node/internal/node/node.go:355` 이후 stop 판단이 false가 되어 old registry를 stop할 수 있고, 기존 request는 stop된 old adapter로 실행될 수 있다.
#### 해결 방법
`OnRunRequest`가 old adapter snapshot을 잡는 구간부터 `n.runs.register(h)`까지 `configSetMu.RLock`을 잡고, `OnConfigRefresh`의 existing write lock과 ordering을 맞춘다. Lock은 adapter `Execute`까지 들고 가지 않는다. 이렇게 하면 refresh는 미등록 old snapshot run이 등록될 때까지 기다린 뒤 active-run 판단을 수행한다.
Before `apps/node/internal/node/node.go:80`:
```go
spec, adapter, err := n.router.ResolveAdapter(ctx, rr)
```
After:
```go
n.configSetMu.RLock()
spec, adapter, err := n.router.ResolveAdapter(ctx, rr)
// build admission/run handle
n.runs.register(h)
n.configSetMu.RUnlock()
```
에러 반환 경로와 admission 실패 경로에서 `RUnlock`이 누락되지 않도록 작은 helper 또는 scoped function을 사용한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node.go`: resolve/admission/register 구간에 refresh barrier 추가.
- [ ] `apps/node/internal/node/node_test.go`: resolve/register race regression 추가.
#### 테스트 작성
- `TestNodeConfigRefreshWaitsForResolvedRunRegistrationBeforeStoppingOldRegistry`: adapter `Capabilities`가 signal 후 block되게 만든다. Run goroutine이 old adapter를 resolve한 뒤 register 전 멈춘 상태에서 refresh를 호출하고, refresh가 old registry stop까지 진행하지 않는지 확인한다. Capability block을 해제하면 run이 등록되고 refresh는 active run 때문에 old stop을 defer해야 한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node
```
기대 결과: ordering regression PASS.
### [REVIEW_REVIEW_NCADAPT-3] Adapter Gate Capacity Decrease Evidence
#### 문제
`apps/node/internal/node/node_test.go:1886`의 `TestNodeConfigRefreshUpdatesExistingAdapterGateCapacity`는 기존 per-adapter gate capacity가 1에서 2로 증가하는 경로만 검증한다. Plan과 S13 evidence는 provider capacity 변경을 요구하므로 stale gate 회귀를 잡으려면 감소 경계도 필요하다.
#### 해결 방법
기존 test를 확장하거나 별도 test를 추가해 capacity 2 -> 1 refresh를 검증한다. 이미 admitted된 run은 취소하지 않고, 후속 admission이 새 capacity 1을 따라 초과 run을 거부해야 한다.
Before `apps/node/internal/node/node_test.go:1979`:
```go
// Refresh config to increase capacity to 2
```
After:
```go
// Refresh config to decrease capacity to 1 and verify future admission rejects.
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node_test.go`: capacity decrease assertion 추가.
- [ ] 필요 시 테스트 fixture helper를 중복 제거하되, behavior 변경 없이 유지한다.
#### 테스트 작성
- `TestNodeConfigRefreshUpdatesExistingAdapterGateCapacity`에 decrease half를 추가하거나 `TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity`를 추가한다.
- Assertion: capacity 2에서 두 run active, refresh capacity 1, 세 번째 run은 `ErrConcurrencyLimitExceeded`, 기존 두 run은 정상 완료.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node
```
기대 결과: capacity decrease regression PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/node/run_manager.go` | REVIEW_REVIEW_NCADAPT-1 |
| `apps/node/internal/node/node.go` | REVIEW_REVIEW_NCADAPT-1, REVIEW_REVIEW_NCADAPT-2 |
| `apps/node/internal/node/node_test.go` | REVIEW_REVIEW_NCADAPT-1, REVIEW_REVIEW_NCADAPT-2, REVIEW_REVIEW_NCADAPT-3 |
## 최종 검증
```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router
go test -count=1 ./apps/node/...
git diff --check
```
기대 결과: targeted Node refresh/admission tests와 전체 Node regression PASS, whitespace error 없음. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,240 @@
<!-- task=m-runtime-reconnect-config-refresh/05+04_node_adapter_diff plan=3 tag=REVIEW_REVIEW_REVIEW_NCADAPT -->
# Plan - REVIEW_REVIEW_REVIEW_NCADAPT
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
세 번째 NCADAPT 리뷰에서 production 쪽 보수화 구현과 검증 명령은 통과했지만, 두 회귀 테스트가 이전 결함을 실제로 잡지 못하는 형태로 작성되어 있었다. 이번 follow-up은 `adapter-diff` 완료 evidence를 신뢰할 수 있게 테스트 시나리오를 좁게 보강한다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Archive Evidence Snapshot
- Prior active task: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff`
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_local_G07_2.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_local_G07_2.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/node/node_test.go:2220` does not actually create the unchanged active adapter + changed/removed sibling case; active `adapter-A` is updated to `mock`.
- `apps/node/internal/node/node_test.go:2364` rejects a third run while two runs are already active, so stale capacity 2 and refreshed capacity 1 both pass.
- Suggested summary: 없음.
- Verification evidence from review:
- `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router` PASS.
- `go test -count=1 ./apps/node/...` PASS.
- `git diff --check` PASS.
- Affected files: `apps/node/internal/node/node_test.go`.
- Roadmap carryover: keep `adapter-diff` target open; no `complete.log` was written.
- Additional archive reread allowed: only the two archived files listed above.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `adapter-diff`: Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.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-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_local_G07_2.log`
- `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_local_G07_2.log`
- `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/plan_cloud_G07_1.log`
- `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/code_review_cloud_G07_1.log`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/router/router.go`
- `apps/node/internal/adapters/config_set.go`
- `apps/node/internal/adapters/registry.go`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/adapters/cli/cli.go`
- `apps/node/internal/adapters/mock/mock.go`
- `apps/node/internal/bootstrap/module.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- 상태: 승인됨, SDD 잠금 해제.
- 대상 Acceptance Scenario: S13 / Milestone Task `adapter-diff`.
- Evidence Map: S13은 Node adapter registry diff test for openai_compat와 capacity/endpoint changes reflected evidence를 요구한다.
- 이번 follow-up은 S13 completion evidence 중 capacity decrease와 old-registry stop-scope regression이 이전 결함을 실제로 검출하도록 테스트를 보강한다. S14 full drain은 범위가 아니다.
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md` present/read.
- Matched profile: `agent-test/local/node-smoke.md`.
- 적용 명령: `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router`, `go test -count=1 ./apps/node/...`, `git diff --check`.
- Go test cache output은 허용하지 않는다.
### 테스트 커버리지 공백
- Old registry stop scope: 현재 sibling-change 테스트는 active adapter를 unchanged로 유지하지 않아 이전 `updated/removed` 한정 active-run 검사 결함을 잡지 못한다.
- Adapter gate capacity decrease: 현재 decrease 테스트는 두 run active 상태에서 세 번째 run만 거부해 stale capacity 2와 refreshed capacity 1을 구분하지 못한다.
### 심볼 참조
- rename/remove 없음.
### 분할 판단
- split decision policy를 평가했다.
- 이 follow-up은 기존 split subtask `05+04_node_adapter_diff`의 plan=3이며, 두 Required 모두 같은 파일의 test evidence correction이다.
- production API, cross-domain 계약, runtime behavior를 새로 바꾸지 않으므로 별도 subtask로 분리하지 않는다.
### 범위 결정 근거
- 포함: `apps/node/internal/node/node_test.go`의 두 regression test 수정과 필요 시 테스트 helper 보강.
- 제외: `apps/node/internal/node/node.go`, `run_manager.go`, router, adapter factory production behavior 변경. 테스트를 통과시키기 위한 production 완화는 이 follow-up 범위가 아니다.
- 제외: full deferred stop scheduling, runtime concurrency/workspace root live apply, Edge/proto/field smoke. 해당 항목은 sibling task 범위다.
### 빌드 등급
- lane/GNN: `local-G07`.
- 이유: 변경은 Node test evidence에 한정되어 deterministic Go tests로 검증 가능하지만, active-run ordering과 capacity gate regression의 증거력을 판단해야 한다.
## 구현 체크리스트
- [ ] `TestNodeConfigRefreshDoesNotStopUnchangedActiveAdapterWhenSiblingChanges`가 active adapter를 refresh 후에도 unchanged로 유지하고, changed/removed sibling 때문에 old registry `Stop`이 호출되지 않는지 검증하도록 수정한다.
- [ ] `TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity`가 capacity 2에서 active run 하나만 유지한 뒤 capacity 1 refresh 후 두 번째 run이 `ErrConcurrencyLimitExceeded`로 거부되는지 검증하도록 수정한다.
- [ ] `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router`, `go test -count=1 ./apps/node/...`, `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 follow-up은 같은 subtask `05+04_node_adapter_diff` 안의 plan=3이다.
- 선행 구현은 현재 working tree와 archived review `code_review_local_G07_2.log`로 확인한다.
- 구현 순서: unchanged sibling regression 수정 -> capacity decrease evidence 수정 -> targeted tests -> full Node regression.
### [REVIEW_REVIEW_REVIEW_NCADAPT-1] Unchanged Active Sibling Stop Regression
#### 문제
`apps/node/internal/node/node_test.go:2220`의 refresh payload는 active `adapter-A`를 `Type: "mock"`으로 바꾼다. 따라서 `adapter-A`는 unchanged active adapter가 아니라 updated adapter이고, 이전 실패 구현처럼 updated/removed adapter만 active-run 검사해도 테스트가 통과할 수 있다.
#### 해결 방법
active run은 refresh 후에도 same key/type/fingerprint로 남는 adapter에 걸고, diff는 sibling adapter에서만 발생시킨다. Supported payload 경로만 써야 하면 active adapter는 blocked `openai_compat`로 구성하고, old registry에 test lifecycle sibling을 수동 등록한 뒤 next payload에서 active `openai_compat`만 동일하게 유지해 sibling removal을 만든다. Assertion은 sibling lifecycle adapter `Stop`이 active run 중 호출되지 않는지 확인해야 한다.
Before `apps/node/internal/node/node_test.go:2220`:
```go
refreshPayload := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Name: "adapter-A",
Type: "mock",
Enabled: true,
},
},
}
```
After:
```go
// Keep the active adapter unchanged and change/remove only its sibling.
refreshPayload := unchangedActiveAdapterPayload(server.URL)
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node_test.go`: test fixture가 active adapter unchanged와 sibling removed/changed를 분명히 만들도록 수정한다.
- [ ] `apps/node/internal/node/node_test.go`: `time.Sleep`만으로 active 상태를 추정하지 말고 request-start channel 등 deterministic signal을 사용한다.
#### 테스트 작성
- 수정 대상: `TestNodeConfigRefreshDoesNotStopUnchangedActiveAdapterWhenSiblingChanges`.
- Assertion: active adapter가 diff `Unchanged`에 해당하고 sibling만 `Removed` 또는 `Updated`인 refresh에서 old registry `Stop`이 호출되지 않는다. 기존 active run은 정상 완료된다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node
```
기대 결과: unchanged active sibling regression이 이전 결함을 잡는 형태로 PASS.
### [REVIEW_REVIEW_REVIEW_NCADAPT-2] Capacity Decrease Evidence
#### 문제
`apps/node/internal/node/node_test.go:2364`는 capacity 2에서 두 run을 이미 active로 만든 뒤 capacity 1 refresh를 적용하고 세 번째 run 거부를 확인한다. stale gate가 capacity 2로 남아 있어도 in-flight가 2라 세 번째 run은 거부되므로 `updateCapacity(1)` 적용 여부를 검증하지 못한다.
#### 해결 방법
capacity 2로 기존 gate를 만든 뒤 active run은 하나만 유지한다. refresh로 capacity 1을 적용한 후 두 번째 run이 `ErrConcurrencyLimitExceeded`로 거부되어야 stale capacity 2와 refreshed capacity 1을 구분할 수 있다. 기존 active run은 refresh 후에도 정상 완료되어야 한다.
Before `apps/node/internal/node/node_test.go:2424`:
```go
errChan1 := make(chan error, 1)
go func() { ... }()
errChan2 := make(chan error, 1)
go func() { ... }()
```
After:
```go
errChan1 := make(chan error, 1)
go func() { ... }()
waitForBlockedCompletion(t, requestStarted)
// refresh capacity 2 -> 1, then run-2 must be rejected.
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node_test.go`: capacity decrease test를 one-active-run scenario로 바꾼다.
- [ ] `apps/node/internal/node/node_test.go`: httptest handler가 completion request 진입을 channel로 알려 테스트가 sleep에 의존하지 않게 한다.
#### 테스트 작성
- 수정 대상: `TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity`.
- Assertion: capacity 2에서 one active run, refresh capacity 1, second run rejects with `ErrConcurrencyLimitExceeded`, first run completes successfully.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node
```
기대 결과: stale gate capacity 2이면 실패하고 refreshed capacity 1이면 PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/node/node_test.go` | REVIEW_REVIEW_REVIEW_NCADAPT-1, REVIEW_REVIEW_REVIEW_NCADAPT-2 |
## 최종 검증
```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/node/internal/router
go test -count=1 ./apps/node/...
git diff --check
```
기대 결과: targeted Node tests와 전체 Node regression PASS, whitespace error 없음.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,165 @@
<!-- task=m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config plan=0 tag=NCDRAIN -->
# Code Review Reference - NCDRAIN
> **[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-22
task=m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config, plan=0, tag=NCDRAIN
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `drain`: 진행 중 run이 있는 adapter/provider 변경은 drop하지 않고 기존 run은 이전 adapter snapshot으로 완료시키며, 후속 request는 새 config 기준으로 라우팅한다. 검증: in-flight run 중 refresh 시 run completion과 후속 request routing이 기대대로 동작한다.
- `runtime-config`: runtime concurrency와 workspace root 변경의 live apply 가능 여부가 구현되고, 불가능한 변경은 restart_required로 보고된다.
- 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/06+05_node_drain_runtime_config/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [NCDRAIN-1] Adapter Drain And Deferred Stop | [ ] |
| [NCDRAIN-2] Runtime Concurrency Live Apply | [ ] |
| [NCDRAIN-3] Workspace Restart Required And Edge Classification | [ ] |
## 구현 체크리스트
- [ ] `05+04_node_adapter_diff``complete.log`가 active/archive 중 하나에 있는지 확인하고, 없으면 구현하지 말고 review stub `검증 결과`에 dependency blocker로 기록한다.
- [ ] `runManager`에 adapter별 active run snapshot/wait 기능을 추가하고 changed/removed adapter stop을 run completion 이후로 지연한다. 검증: in-flight run 중 refresh해도 기존 run은 old adapter로 complete된다.
- [ ] 후속 request가 refresh 후 새 registry/adapter snapshot으로 라우팅되는 integration test를 추가한다. 검증: old adapter call count와 new adapter call count가 기대값과 일치한다.
- [ ] runtime concurrency live apply를 구현하고 `workspace_root` 변경은 restart_required로 보고한다. 검증: concurrency 변경 후 admission limit이 바뀌고 workspace root 변경은 live store를 바꾸지 않는다.
- [ ] Edge config refresh classifier/result가 node runtime concurrency는 applied, workspace root는 restart_required/node result로 보고하게 조정한다. 검증: Edge classifier/runtime tests가 S15 경계를 확인한다.
- [ ] 전체 Edge/Node 회귀로 `go test -count=1 ./apps/node/...``go test -count=1 ./apps/edge/...`를 실행한다.
- [ ] 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/06+05_node_drain_runtime_config/``agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md``CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- predecessor `05+04_node_adapter_diff` 완료 근거가 확인됐는지 본다.
- Config refresh가 router swap 뒤 old in-flight adapter stop을 run completion 이후로 미루는지 확인한다.
- Concurrency 감소가 기존 admitted run을 취소하지 않고 후속 admission에만 적용되는지 확인한다.
- Workspace root 변경이 store를 live reopen하지 않고 restart_required로 보고되는지 확인한다.
- Edge classifier가 `runtime.concurrency``runtime.workspace_root`를 field별로 분류하는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### NCDRAIN-1 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
(output)
```
### NCDRAIN-2 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node
(output)
```
### NCDRAIN-3 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/node/internal/node
(output)
```
### 최종 검증
```text
$ go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
(output)
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/node/internal/node
(output)
$ go test -count=1 ./apps/node/...
(output)
$ go test -count=1 ./apps/edge/...
(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 `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check items as completed; final item is mandatory. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
| 검증 결과 | Implementing agent | Paste actual command output. |
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |

View file

@ -0,0 +1,298 @@
<!-- task=m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config plan=0 tag=NCDRAIN -->
# Plan - NCDRAIN
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
## 배경
Adapter diff가 구현되어도 old adapter lifecycle을 즉시 stop하면 실행 중 run이 깨질 수 있다. 또한 `runtime.concurrency``workspace_root`는 Node admission gate와 store lifecycle에 직접 닿아 live apply 가능 여부를 구분해야 한다. 이 계획은 S14/S15를 완료 대상으로 삼고, field smoke와 문서 작업은 후속 field Epic으로 남긴다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `drain`: 진행 중 run이 있는 adapter/provider 변경은 drop하지 않고 기존 run은 이전 adapter snapshot으로 완료시키며, 후속 request는 새 config 기준으로 라우팅한다. 검증: in-flight run 중 refresh 시 run completion과 후속 request routing이 기대대로 동작한다.
- `runtime-config`: runtime concurrency와 workspace root 변경의 live apply 가능 여부가 구현되고, 불가능한 변경은 restart_required로 보고된다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `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-ops/rules/project/domain/node/rules.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/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.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`
- `packages/go/config/config.go`
- `proto/iop/runtime.proto`
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/result.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/node/internal/bootstrap/module.go`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/router/router.go`
- `apps/node/internal/adapters/registry.go`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/runtime/types.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/node/node_concurrency_integration_test.go`
- `apps/node/internal/bootstrap/module_test.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- 상태: 승인됨, SDD 잠금 해제.
- 대상 Acceptance Scenario: S14 / `drain`, S15 / `runtime-config`.
- Evidence Map: S14는 in-flight run refresh integration test와 `drain`: previous adapter snapshot completion plus follow-up routing; S15는 runtime concurrency/workspace refresh tests와 `runtime-config`: live or restart_required behavior verified를 요구한다.
- 구현 checklist는 runManager active adapter tracking, deferred stop, global gate live update, workspace restart_required response, Edge classifier/result reporting을 포함한다.
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/node-smoke.md`는 실행 요청/stream/adapter registry 경로 변경 시 Node tests와 edge-node 진단을 요구한다. 이 plan은 unit/integration evidence로 S14/S15를 닫고 field run은 field Epic에 남긴다.
- `agent-test/local/edge-smoke.md`는 Edge refresh/classifier 변경 시 `go test ./apps/edge/...`를 요구한다.
- `agent-test/local/platform-common-smoke.md`는 config/proto boundary 변경 시 공통 검증을 요구한다. 이 plan은 proto 원본 변경 없이 existing runtime fields를 적용/분류한다.
### 테스트 커버리지 공백
- Drain: 현재 `runManager`는 run id cancel만 제공하고 adapter별 active run wait/list가 없다.
- Deferred stop: `Registry.Stop`은 전체 registry stop만 제공하고 changed/removed adapter의 stop timing을 run completion과 연결하지 않는다.
- Runtime concurrency: `Node.globalGate`는 생성 시점 capacity로 고정되고 live update test가 없다.
- Workspace root: `storeDSN`은 bootstrap에서 DB path를 만들며 live store migration/reopen 정책이 없다. 이 plan은 workspace root 변경을 restart_required로 보고해야 한다.
- Edge classifier: 현재 `nodes[*].runtime` 전체가 restart_required라 concurrency live apply를 표현하지 못한다.
### 심볼 참조
- rename/remove 없음.
- `fifoGate` capacity update method를 추가하면 uses는 `Node.globalGate`, `adapterGates`, concurrency tests다.
- Adapter lifecycle stop helper를 추가하면 uses는 `Registry.Stop`, Node config apply manager, `runtimeOwner.close`다.
### 분할 판단
- split decision policy를 먼저 평가했다. Drain/runtime config는 adapter diff 완료 후에만 의미가 있고, concurrency/shared-state risk가 높아 `adapter-diff`와 분리했다.
- 공유 task group: `m-runtime-reconnect-config-refresh`.
- sibling subtask:
- `04+02_config_push_contract`: predecessor `02` 기반 push contract.
- `05+04_node_adapter_diff`: direct predecessor. 현재 missing.
- `06+05_node_drain_runtime_config`: 이 plan. predecessor `05` complete 필요.
### 범위 결정 근거
- 포함: in-flight old adapter snapshot 보존, changed/removed adapter deferred stop, global concurrency live apply, workspace root restart_required, Edge classifier/result node runtime reporting, tests.
- 제외: New proto fields, base adapter diff, openai_compat endpoint/header apply, field smoke, docs. 각각 NCPUSH/NCADAPT/field Epic 대상이다.
- Workspace root live store migration은 이 Milestone 범위에서 위험하므로 구현하지 않고 명시적 `restart_required`로 보고한다.
### 빌드 등급
- lane/GNN: `cloud-G08`.
- 이유: concurrency admission, lifecycle stop ordering, Edge classifier/reporting을 동시에 바꾸는 shared-state 작업이다.
## 구현 체크리스트
- [ ] `05+04_node_adapter_diff``complete.log`가 active/archive 중 하나에 있는지 확인하고, 없으면 구현하지 말고 review stub `검증 결과`에 dependency blocker로 기록한다.
- [ ] `runManager`에 adapter별 active run snapshot/wait 기능을 추가하고 changed/removed adapter stop을 run completion 이후로 지연한다. 검증: in-flight run 중 refresh해도 기존 run은 old adapter로 complete된다.
- [ ] 후속 request가 refresh 후 새 registry/adapter snapshot으로 라우팅되는 integration test를 추가한다. 검증: old adapter call count와 new adapter call count가 기대값과 일치한다.
- [ ] runtime concurrency live apply를 구현하고 `workspace_root` 변경은 restart_required로 보고한다. 검증: concurrency 변경 후 admission limit이 바뀌고 workspace root 변경은 live store를 바꾸지 않는다.
- [ ] Edge config refresh classifier/result가 node runtime concurrency는 applied, workspace root는 restart_required/node result로 보고하게 조정한다. 검증: Edge classifier/runtime tests가 S15 경계를 확인한다.
- [ ] 전체 Edge/Node 회귀로 `go test -count=1 ./apps/node/...``go test -count=1 ./apps/edge/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- 이 subtask는 `06+05_node_drain_runtime_config`이므로 predecessor `05`만 요구한다.
- predecessor 상태: missing. 구현 시작 전 `agent-task/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/complete.log` 또는 `agent-task/archive/**/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/complete.log`가 있어야 한다.
- predecessor가 없으면 소스 코드를 수정하지 말고 active `CODE_REVIEW-cloud-G08.md`에 dependency blocker를 기록해 code-review로 넘긴다.
### [NCDRAIN-1] Adapter Drain And Deferred Stop
#### 문제
`apps/node/internal/node/node.go:135`에서 run은 adapter object를 잡고 실행하지만, config apply가 changed/removed adapter lifecycle을 즉시 stop하면 old adapter가 실행 중일 수 있다. `apps/node/internal/node/run_manager.go:22``runManager`는 run id map만 있고 adapter별 active wait가 없다.
Before `apps/node/internal/node/run_manager.go:22`:
```go
type runManager struct {
mu sync.RWMutex
runs map[string]*runHandle
}
```
#### 해결 방법
`runManager`에 adapter별 active handles snapshot과 wait helper를 추가한다. Config apply는 router swap을 먼저 수행해 후속 request가 새 adapter를 보게 하고, old changed/removed adapter stop은 해당 adapter key의 pre-swap active handles가 닫힌 뒤 수행한다. Stop failure는 refresh response error metadata에 남기되 이미 완료된 run을 실패 처리하지 않는다.
After:
```go
func (m *runManager) activeByAdapter(adapterKey string) []*runHandle {
m.mu.RLock()
defer m.mu.RUnlock()
// copy handles matching adapterKey.
}
func waitHandles(ctx context.Context, handles []*runHandle) error {
// wait for done or ctx cancellation.
}
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/run_manager.go`: adapter별 active snapshot/wait helper 추가.
- [ ] `apps/node/internal/node/node.go`: config apply가 swap 후 deferred stop을 schedule.
- [ ] `apps/node/internal/adapters/registry.go`: single-key stop helper 또는 lifecycle adapter accessor 추가.
- [ ] `apps/node/internal/node/node_test.go`: old adapter blocks, refresh, old completes, new receives follow-up request.
#### 테스트 작성
- `TestConfigRefreshDrainsInFlightRunAndRoutesFollowUpToNewAdapter`: old slow adapter run 시작, refresh로 same key updated, first run complete, second run은 new adapter 호출.
- `TestConfigRefreshDefersStopUntilInFlightDone`: old lifecycle adapter stop이 run done 이후 호출된다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
```
기대 결과: drain/deferred stop tests PASS.
### [NCDRAIN-2] Runtime Concurrency Live Apply
#### 문제
`apps/node/internal/node/node.go:63``globalGate``New` 시점 concurrency로 생성된다. `apps/node/internal/node/run_manager.go:101``fifoGate`는 capacity update method가 없어 refresh 후 global concurrency를 바꿀 수 없다.
Before `apps/node/internal/node/run_manager.go:101`:
```go
type fifoGate struct {
mu sync.Mutex
capacity int
inFlight int
}
```
#### 해결 방법
`fifoGate``setCapacity`를 추가한다. Capacity 감소는 이미 admitted된 run을 취소하지 않고, 후속 acquire부터 새 limit을 적용한다. `Node.OnConfigRefresh`는 payload runtime concurrency가 바뀌면 global gate capacity를 갱신하고 response/result에 applied path를 남긴다.
After:
```go
func (g *fifoGate) setCapacity(capacity int) {
g.mu.Lock()
defer g.mu.Unlock()
g.capacity = capacity
}
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/run_manager.go`: `setCapacity`, optional capacity getter 추가.
- [ ] `apps/node/internal/node/node.go`: runtime concurrency diff apply.
- [ ] `apps/node/internal/node/node_concurrency_integration_test.go`: refresh 후 concurrency limit 변경 assertion 추가.
#### 테스트 작성
- `TestConfigRefreshUpdatesGlobalConcurrencyForFutureRuns`: concurrency 1에서 second concurrent run rejected, refresh to 2 후 two concurrent runs accepted.
- `TestConfigRefreshConcurrencyDecreaseDoesNotCancelInFlight`: concurrency 2에서 two runs active, refresh to 1은 active run을 취소하지 않고 third run만 rejected.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node
```
기대 결과: concurrency live apply tests PASS.
### [NCDRAIN-3] Workspace Restart Required And Edge Classification
#### 문제
`packages/go/config/config.go:329``RuntimeConf`에는 `Concurrency``WorkspaceRoot`가 같이 있지만 live apply 가능성이 다르다. `apps/node/internal/bootstrap/module.go:281``storeDSN`은 workspace root로 DB path를 열고, runtime 중 store reopen/migration 경로가 없다. `apps/edge/internal/configrefresh/classify.go:219``nodes[*].runtime` 전체를 restart_required로 묶어 concurrency applied를 표현하지 못한다.
Before `apps/edge/internal/configrefresh/classify.go:219`:
```go
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[%q].runtime", key), StatusRestartRequired, cur.Runtime, next.Runtime)
```
#### 해결 방법
Edge classifier를 runtime field별로 나누어 `runtime.concurrency`는 applied, `runtime.workspace_root`는 restart_required로 분류한다. Node apply는 workspace root 변경을 실제 store에 적용하지 않고 `NodeConfigRefreshResponse.Status=RESTART_REQUIRED``restart_required_paths`에 명시한다. Result node_results와 restart_required_paths가 operator 응답에 남아야 한다.
After:
```go
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].runtime.concurrency", key), StatusApplied, cur.Runtime.Concurrency, next.Runtime.Concurrency)
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].runtime.workspace_root", key), StatusRestartRequired, cur.Runtime.WorkspaceRoot, next.Runtime.WorkspaceRoot)
```
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/configrefresh/classify.go`: runtime field별 분류.
- [ ] `apps/edge/internal/configrefresh/classify_test.go`: concurrency applied, workspace root restart_required.
- [ ] `apps/node/internal/node/node.go`: workspace root response restart_required.
- [ ] `apps/node/internal/bootstrap/module.go`: storeDSN 변경 없음. live workspace migration 구현 금지.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: node result restart_required reporting 확인.
#### 테스트 작성
- `TestClassifyNodeRuntimeConcurrencyAppliedWorkspaceRestartRequired`: field별 classification 확인.
- `TestConfigRefreshWorkspaceRootReportsRestartRequired`: Node response가 workspace root를 restart_required로 보고하고 store path를 바꾸지 않는다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/node/internal/node
```
기대 결과: Edge classification과 Node workspace restart_required tests PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/node/run_manager.go` | NCDRAIN-1, NCDRAIN-2 |
| `apps/node/internal/node/node.go` | NCDRAIN-1, NCDRAIN-2, NCDRAIN-3 |
| `apps/node/internal/adapters/registry.go` | NCDRAIN-1 |
| `apps/node/internal/node/node_test.go` | NCDRAIN-1, NCDRAIN-3 |
| `apps/node/internal/node/node_concurrency_integration_test.go` | NCDRAIN-2 |
| `apps/edge/internal/configrefresh/classify.go` | NCDRAIN-3 |
| `apps/edge/internal/configrefresh/classify_test.go` | NCDRAIN-3 |
| `apps/edge/internal/bootstrap/runtime_test.go` | NCDRAIN-3 |
## 최종 검증
```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/node/internal/node
go test -count=1 ./apps/node/...
go test -count=1 ./apps/edge/...
```
기대 결과: drain, runtime concurrency, workspace restart_required, Edge classifier/reporting tests PASS. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -18,6 +18,7 @@ import (
"iop/packages/go/config"
"iop/packages/go/observability"
"iop/packages/go/version"
iop "iop/proto/gen/iop"
)
// Runtime is the shared edge runtime assembly used by both `serve` and
@ -181,12 +182,14 @@ func (r *Runtime) RefreshConfig(ctx context.Context, req configrefresh.Request)
return result, err
}
if req.Mode == configrefresh.ModeApply && result.Status == configrefresh.StatusApplied && candidate != nil {
if err := r.applyMutableConfig(candidate); err != nil {
nodeResults, err := r.applyMutableConfig(ctx, candidate, result.Changes, req.RequestID)
if err != nil {
// Apply failed after a clean classification: preserve the previous
// runtime snapshot (applyMutableConfig commits nothing on error) and
// surface a rejected result with stable ops-report slices.
return configrefresh.RejectedResult(req, "apply candidate config: "+err.Error()), nil
}
result.NodeResults = nodeResults
}
return result, nil
}
@ -194,10 +197,15 @@ func (r *Runtime) RefreshConfig(ctx context.Context, req configrefresh.Request)
// 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 {
// After the local commit, it pushes the new config to all connected nodes and
// returns per-node results. Node push failures do not roll back the local commit.
// When len(changes)==0 (no-change apply), the local snapshot is still committed
// but connected nodes are NOT pushed, preventing a spurious restart_required
// response from identical configs.
func (r *Runtime) applyMutableConfig(ctx context.Context, candidate *config.EdgeConfig, changes []configrefresh.Change, requestID string) ([]configrefresh.NodeResult, error) {
nextStore, err := edgenode.LoadFromConfig(candidate.Nodes)
if err != nil {
return err
return nil, err
}
r.cfgMu.Lock()
@ -208,7 +216,28 @@ func (r *Runtime) applyMutableConfig(candidate *config.EdgeConfig) error {
r.Server.SetNodeStore(nextStore)
r.Service.SetRuntimeConfig(nextStore, candidate.Models)
r.Input.SetModelCatalog(candidate.Models)
return nil
// No-change apply: commit the snapshot but skip node push to prevent
// spurious restart_required from identical configs.
if len(changes) == 0 {
r.Logger.Debug("no-change apply refresh: skipping node push", zap.String("request_id", requestID))
return nil, nil
}
changedPaths := make([]string, 0, len(changes))
for _, ch := range changes {
changedPaths = append(changedPaths, ch.Path)
}
nodeResults := r.Server.PushConfigRefresh(ctx, buildNodeConfigRefreshRequest(requestID, changedPaths))
return nodeResults, nil
}
func buildNodeConfigRefreshRequest(requestID string, changedPaths []string) *iop.NodeConfigRefreshRequest {
return &iop.NodeConfigRefreshRequest{
RequestId: requestID,
ChangedPaths: changedPaths,
}
}
func (r *Runtime) newLifetimeContext() context.Context {

View file

@ -13,6 +13,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@ -832,6 +833,536 @@ func containsString(s []string, want string) bool {
return false
}
// TestRefreshConfigApplyIncludesNodeResults verifies SDD S12: when a refresh
// apply succeeds, the result includes per-node refresh outcomes. Uses a
// net.Pipe-based fake node that registers with the edge and responds applied.
func TestRefreshConfigApplyIncludesNodeResults(t *testing.T) {
dir := t.TempDir()
serverAddr := freeTCPAddr(t)
writeRefreshConfig := func(name string, capacity int) string {
path := filepath.Join(dir, name)
yaml := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "0.0.0.0:18090"
artifact_dir: "artifacts"
logging:
level: "error"
refresh:
enabled: false
listen: "127.0.0.1:0"
openai:
listen: "0.0.0.0:18091"
a2a:
listen: "0.0.0.0:8082"
metrics:
port: 0
nodes:
- id: "node-refresh-1"
alias: "refresh-node"
token: "tok-refresh"
adapters:
cli:
enabled: true
openai_compat_instances:
- name: "primary"
enabled: true
provider: "vllm"
endpoint: "http://127.0.0.1:9000/v1"
capacity: 5
max_queue: 4
queue_timeout_ms: 5000
runtime:
concurrency: 7
workspace_root: "/tmp/iop-refresh-ws"
providers:
- id: "prov-r"
type: "ollama"
category: "local_inference"
models: ["llama3"]
capacity: %d
max_queue: 4
queue_timeout_ms: 5000
`, serverAddr, capacity)
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return path
}
basePath := writeRefreshConfig("base.yaml", 2)
candidatePath := writeRefreshConfig("candidate.yaml", 8)
rt, err := NewRuntime(loadServeNormalizedConfig(t, basePath))
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)
}
}()
// Build a fake node parser that can decode NodeConfigRefreshRequest (edge push)
// and RegisterResponse (registration).
nodeParserMap := toki.ParserMap{
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeConfigRefreshRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeConfigRefreshRequest{}
return m, proto.Unmarshal(b, m)
},
}
host, portStr, _ := net.SplitHostPort(serverAddr)
port := 0
fmt.Sscanf(portStr, "%d", &port)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
fakeNode, err := toki.DialTcp(ctx, host, port, 30, 10, nodeParserMap)
if err != nil {
t.Fatalf("dial edge: %v", err)
}
defer fakeNode.Close()
// Fake node captures the refresh request and responds restart_required
// (current Node behavior for any non-empty payload/change).
var capturedRefreshReq *iop.NodeConfigRefreshRequest
var capturedRefreshMu sync.Mutex
toki.AddRequestListenerTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
&fakeNode.Communicator,
func(req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
capturedRefreshMu.Lock()
capturedRefreshReq = req
capturedRefreshMu.Unlock()
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED,
RestartRequiredPaths: req.GetChangedPaths(),
}, nil
},
)
// Register the fake node.
regResp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&fakeNode.Communicator,
&iop.RegisterRequest{Token: "tok-refresh"},
2*time.Second,
)
if err != nil {
t.Fatalf("register: %v", err)
}
if !regResp.GetAccepted() {
t.Fatalf("expected accepted, got reason %q", regResp.GetReason())
}
// Wait for the node to appear in the registry.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, ok := rt.Registry.Get("node-refresh-1"); ok {
break
}
time.Sleep(25 * time.Millisecond)
}
if _, ok := rt.Registry.Get("node-refresh-1"); !ok {
t.Fatal("fake node not registered in edge registry")
}
result, err := rt.RefreshConfig(context.Background(), configrefresh.Request{
Mode: configrefresh.ModeApply,
ConfigPath: candidatePath,
RequestID: "node-result-test",
})
if err != nil {
t.Fatalf("RefreshConfig: %v", err)
}
if result.Status != configrefresh.StatusApplied {
t.Fatalf("expected status=applied, got %q (summary=%s)", result.Status, result.Summary)
}
if len(result.NodeResults) == 0 {
t.Fatal("expected node_results to be populated after apply")
}
found := false
for _, nr := range result.NodeResults {
if nr.NodeID == "node-refresh-1" {
found = true
if nr.Status != "restart_required" {
t.Errorf("expected node status=restart_required (current behavior for non-empty payload), got %q (error=%q)", nr.Status, nr.Error)
}
}
}
if !found {
t.Fatalf("node-refresh-1 not found in node_results: %+v", result.NodeResults)
}
// Verify request_id propagation and payload contents on the fake node side.
capturedRefreshMu.Lock()
captured := capturedRefreshReq
capturedRefreshMu.Unlock()
if captured == nil {
t.Fatal("fake node never received a NodeConfigRefreshRequest")
}
if captured.GetRequestId() != "node-result-test" {
t.Errorf("request_id: got %q, want %q", captured.GetRequestId(), "node-result-test")
}
// Payload must carry the node-specific config edge builds from the NodeStore
// record. Note: providers[].capacity is the refresh *trigger* (it drives
// changed_paths), not a NodeConfigPayload field; the payload itself carries
// the node's adapters + runtime, so we assert those concrete typed fields.
payload := captured.GetConfig()
if payload == nil {
t.Fatal("expected non-nil NodeConfigPayload in refresh request")
}
// Runtime tuning is pushed verbatim from the node record.
if got := payload.GetRuntime().GetConcurrency(); got != 7 {
t.Errorf("runtime concurrency: got %d, want 7", got)
}
if got := payload.GetRuntime().GetWorkspaceRoot(); got != "/tmp/iop-refresh-ws" {
t.Errorf("runtime workspace_root: got %q, want %q", got, "/tmp/iop-refresh-ws")
}
// The configured openai_compat adapter instance must be present with its
// typed fields (provider/endpoint/capacity) intact, and the CLI adapter too.
var oai *iop.OpenAICompatAdapterConfig
hasCLI := false
for _, a := range payload.GetAdapters() {
switch a.GetType() {
case "openai_compat":
if a.GetName() == "primary" {
oai = a.GetOpenaiCompat()
}
case "cli":
hasCLI = true
}
}
if oai == nil {
t.Fatalf("expected openai_compat adapter %q in refresh payload, got %+v", "primary", payload.GetAdapters())
}
if oai.GetProvider() != "vllm" {
t.Errorf("openai_compat provider: got %q, want %q", oai.GetProvider(), "vllm")
}
if oai.GetEndpoint() != "http://127.0.0.1:9000/v1" {
t.Errorf("openai_compat endpoint: got %q, want %q", oai.GetEndpoint(), "http://127.0.0.1:9000/v1")
}
if oai.GetCapacity() != 5 {
t.Errorf("openai_compat capacity: got %d, want 5", oai.GetCapacity())
}
if !hasCLI {
t.Errorf("expected cli adapter in refresh payload, got %+v", payload.GetAdapters())
}
}
// TestRefreshConfigApplySkipsDisconnectedConfiguredNode verifies that a node
// that is in the NodeStore but not in the live registry (post-disconnect state)
// appears as "skipped" in node_results alongside connected nodes.
func TestRefreshConfigApplySkipsDisconnectedConfiguredNode(t *testing.T) {
dir := t.TempDir()
serverAddr := freeTCPAddr(t)
writeConfig := func(name string, capacity int) string {
path := filepath.Join(dir, name)
yaml := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "0.0.0.0:18092"
artifact_dir: "artifacts"
logging:
level: "error"
refresh:
enabled: false
listen: "127.0.0.1:0"
openai:
listen: "0.0.0.0:18093"
a2a:
listen: "0.0.0.0:8083"
metrics:
port: 0
nodes:
- id: "node-connected"
alias: "conn-node"
token: "tok-conn"
adapters:
cli:
enabled: true
providers:
- id: "prov-skip"
type: "ollama"
category: "local_inference"
models: ["llama3"]
capacity: %d
max_queue: 4
queue_timeout_ms: 5000
- id: "node-disconnected"
alias: "disc-node"
token: "tok-disc"
adapters:
cli:
enabled: true
`, serverAddr, capacity)
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return path
}
basePath := writeConfig("base.yaml", 2)
candidatePath := writeConfig("candidate.yaml", 8)
rt, err := NewRuntime(loadServeNormalizedConfig(t, basePath))
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)
}
}()
nodeParserMap := toki.ParserMap{
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeConfigRefreshRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeConfigRefreshRequest{}
return m, proto.Unmarshal(b, m)
},
}
host, portStr, _ := net.SplitHostPort(serverAddr)
port := 0
fmt.Sscanf(portStr, "%d", &port)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Only connect node-connected; node-disconnected never dials in.
fakeNode, err := toki.DialTcp(ctx, host, port, 30, 10, nodeParserMap)
if err != nil {
t.Fatalf("dial edge: %v", err)
}
defer fakeNode.Close()
toki.AddRequestListenerTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
&fakeNode.Communicator,
func(req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED,
RestartRequiredPaths: req.GetChangedPaths(),
}, nil
},
)
regResp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&fakeNode.Communicator,
&iop.RegisterRequest{Token: "tok-conn"},
2*time.Second,
)
if err != nil {
t.Fatalf("register: %v", err)
}
if !regResp.GetAccepted() {
t.Fatalf("expected accepted, got reason %q", regResp.GetReason())
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, ok := rt.Registry.Get("node-connected"); ok {
break
}
time.Sleep(25 * time.Millisecond)
}
if _, ok := rt.Registry.Get("node-connected"); !ok {
t.Fatal("fake node not registered in edge registry")
}
result, err := rt.RefreshConfig(context.Background(), configrefresh.Request{
Mode: configrefresh.ModeApply,
ConfigPath: candidatePath,
RequestID: "skip-test",
})
if err != nil {
t.Fatalf("RefreshConfig: %v", err)
}
if result.Status != configrefresh.StatusApplied {
t.Fatalf("expected status=applied, got %q (summary=%s)", result.Status, result.Summary)
}
if len(result.NodeResults) != 2 {
t.Fatalf("expected 2 node_results (connected + disconnected), got %d: %+v", len(result.NodeResults), result.NodeResults)
}
statusByID := make(map[string]string)
for _, nr := range result.NodeResults {
statusByID[nr.NodeID] = nr.Status
}
if statusByID["node-connected"] != "restart_required" {
t.Errorf("node-connected: expected restart_required, got %q", statusByID["node-connected"])
}
if statusByID["node-disconnected"] != "skipped" {
t.Errorf("node-disconnected: expected skipped, got %q", statusByID["node-disconnected"])
}
}
// TestRefreshConfigApplyNoChangeSkipsNodePush verifies that an identical config
// apply (no-change) does NOT push a refresh request to connected nodes,
// preventing spurious restart_required responses.
func TestRefreshConfigApplyNoChangeSkipsNodePush(t *testing.T) {
dir := t.TempDir()
serverAddr := freeTCPAddr(t)
writeConfig := func(name string) string {
path := filepath.Join(dir, name)
yaml := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "0.0.0.0:18094"
artifact_dir: "artifacts"
logging:
level: "error"
refresh:
enabled: false
listen: "127.0.0.1:0"
openai:
listen: "0.0.0.0:18095"
a2a:
listen: "0.0.0.0:8084"
metrics:
port: 0
nodes:
- id: "node-noc-change"
alias: "noc-change-node"
token: "tok-noc-change"
adapters:
cli:
enabled: true
providers:
- id: "prov-nc"
type: "ollama"
category: "local_inference"
models: ["llama3"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`, serverAddr)
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return path
}
basePath := writeConfig("base.yaml")
// candidate is identical to base (same file).
candidatePath := basePath
rt, err := NewRuntime(loadServeNormalizedConfig(t, basePath))
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)
}
}()
// Build a fake node parser.
nodeParserMap := toki.ParserMap{
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeConfigRefreshRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeConfigRefreshRequest{}
return m, proto.Unmarshal(b, m)
},
}
host, portStr, _ := net.SplitHostPort(serverAddr)
port := 0
fmt.Sscanf(portStr, "%d", &port)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
fakeNode, err := toki.DialTcp(ctx, host, port, 30, 10, nodeParserMap)
if err != nil {
t.Fatalf("dial edge: %v", err)
}
defer fakeNode.Close()
// Track whether NodeConfigRefreshRequest is received.
var receivedRefresh atomic.Bool
toki.AddRequestListenerTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
&fakeNode.Communicator,
func(req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
receivedRefresh.Store(true)
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
}, nil
},
)
// Register the fake node.
regResp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&fakeNode.Communicator,
&iop.RegisterRequest{Token: "tok-noc-change"},
2*time.Second,
)
if err != nil {
t.Fatalf("register: %v", err)
}
if !regResp.GetAccepted() {
t.Fatalf("expected accepted, got reason %q", regResp.GetReason())
}
// Wait for the node to appear in the registry.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, ok := rt.Registry.Get("node-noc-change"); ok {
break
}
time.Sleep(25 * time.Millisecond)
}
if _, ok := rt.Registry.Get("node-noc-change"); !ok {
t.Fatal("fake node not registered in edge registry")
}
// Apply identical config (no-change).
result, err := rt.RefreshConfig(context.Background(), configrefresh.Request{
Mode: configrefresh.ModeApply,
ConfigPath: candidatePath,
RequestID: "no-change-1",
})
if err != nil {
t.Fatalf("RefreshConfig: %v", err)
}
if result.Status != configrefresh.StatusApplied {
t.Fatalf("expected status=applied, got %q (summary=%s)", result.Status, result.Summary)
}
// No-change apply should produce no node_results (no push).
if len(result.NodeResults) != 0 {
t.Errorf("expected no node_results for no-change apply, got %d: %+v", len(result.NodeResults), result.NodeResults)
}
// The fake node must NOT have received a NodeConfigRefreshRequest.
if receivedRefresh.Load() {
t.Error("expected no NodeConfigRefreshRequest to be sent for no-change apply, but it was received")
}
}
func freeTCPAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")

View file

@ -12,6 +12,15 @@ const (
StatusRejected Status = "rejected"
)
// NodeResult captures the config refresh outcome for one connected node.
type NodeResult struct {
NodeID string `json:"node_id"`
Alias string `json:"alias,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
RestartRequiredPaths []string `json:"restart_required_paths,omitempty"`
}
// RejectedResult builds a rejected Result with stable, non-nil change and
// ops-report slices so the API/CLI JSON response is consistent across rejected
// paths (invalid request, load/validate failure, apply failure).
@ -26,6 +35,7 @@ func RejectedResult(req Request, summary string) Result {
ChangedProviders: []string{},
ChangedModels: []string{},
RestartRequiredPaths: []string{},
NodeResults: []NodeResult{},
}
}
@ -48,8 +58,9 @@ type Result struct {
// and model items changed and which paths force a restart, without parsing
// individual Change paths. They are stable, sorted, and always non-nil so
// the API/CLI JSON response is consistent across runs.
ChangedNodes []string `json:"changed_nodes"`
ChangedProviders []string `json:"changed_providers"`
ChangedModels []string `json:"changed_models"`
RestartRequiredPaths []string `json:"restart_required_paths"`
ChangedNodes []string `json:"changed_nodes"`
ChangedProviders []string `json:"changed_providers"`
ChangedModels []string `json:"changed_models"`
RestartRequiredPaths []string `json:"restart_required_paths"`
NodeResults []NodeResult `json:"node_results,omitempty"`
}

View file

@ -6,11 +6,13 @@ import (
"strconv"
"sync"
"sync/atomic"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"iop/apps/edge/internal/configrefresh"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/events"
iop "iop/proto/gen/iop"
@ -23,6 +25,8 @@ const (
heartbeatWaitSec = 45
)
const configRefreshTimeoutSec = 30
func edgeParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.RunEvent{}): func(b []byte) (proto.Message, error) {
@ -41,6 +45,10 @@ func edgeParserMap() toki.ParserMap {
m := &iop.NodeCommandResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeConfigRefreshResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeConfigRefreshResponse{}
return m, proto.Unmarshal(b, m)
},
}
}
@ -285,6 +293,67 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
)
}
// PushConfigRefresh sends a NodeConfigRefreshRequest to every configured node
// and collects per-node results. Nodes that are not in the live registry or
// have no alive client are recorded as skipped. Connected nodes receive a
// node-specific payload built from the refreshed NodeStore record. Transport
// failures are recorded as failed and do not roll back the local config commit.
func (s *Server) PushConfigRefresh(ctx context.Context, req *iop.NodeConfigRefreshRequest) []configrefresh.NodeResult {
ns := s.nodeStoreSnapshot()
var records []*edgenode.NodeRecord
if ns != nil {
records = ns.All()
}
results := make([]configrefresh.NodeResult, 0, len(records))
timeout := time.Duration(configRefreshTimeoutSec) * time.Second
for _, rec := range records {
nr := configrefresh.NodeResult{
NodeID: rec.ID,
Alias: rec.Alias,
}
entry, ok := s.registry.Get(rec.ID)
if !ok || entry.Client == nil || !entry.Client.IsAlive() {
nr.Status = "skipped"
results = append(results, nr)
continue
}
nodeReq := &iop.NodeConfigRefreshRequest{
RequestId: req.GetRequestId(),
ChangedPaths: req.GetChangedPaths(),
}
if payload, err := edgenode.BuildConfigPayload(rec); err == nil {
nodeReq.Config = payload
}
resp, err := toki.SendRequestTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
&entry.Client.Communicator,
nodeReq,
timeout,
)
if err != nil {
nr.Status = "failed"
nr.Error = err.Error()
results = append(results, nr)
continue
}
switch resp.GetStatus() {
case iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED:
nr.Status = "applied"
case iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED:
nr.Status = "restart_required"
nr.RestartRequiredPaths = resp.GetRestartRequiredPaths()
case iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED:
nr.Status = "failed"
nr.Error = resp.GetError()
case iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_SKIPPED:
nr.Status = "skipped"
default:
nr.Status = "skipped"
}
results = append(results, nr)
}
return results
}
func (s *Server) enrichRunEvent(event *iop.RunEvent) {
if event == nil || event.GetNodeAlias() != "" || s.registry == nil {
return

View file

@ -1,9 +1,11 @@
package transport
import (
"context"
"testing"
toki "git.toki-labs.com/toki/proto-socket/go"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
edgenode "iop/apps/edge/internal/node"
@ -175,6 +177,90 @@ func TestServerEnrichesRunEventNodeAlias(t *testing.T) {
}
}
// TestEdgeParserMap_NodeConfigRefreshResponse verifies the parser map includes
// the NodeConfigRefreshResponse type so Edge can receive Node ack messages.
func TestEdgeParserMap_NodeConfigRefreshResponse(t *testing.T) {
parsers := edgeParserMap()
original := &iop.NodeConfigRefreshResponse{
RequestId: "req-refresh-1",
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
}
payload, err := proto.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
key := toki.TypeNameOf(original)
parser, ok := parsers[key]
if !ok {
t.Fatalf("parser not found for key: %s", key)
}
parsed, err := parser(payload)
if err != nil {
t.Fatalf("parse: %v", err)
}
got := parsed.(*iop.NodeConfigRefreshResponse)
if got.GetRequestId() != original.GetRequestId() || got.GetStatus() != original.GetStatus() {
t.Fatalf("unexpected response: %+v", got)
}
}
// TestServerPushConfigRefreshSkippedWhenNodeDisconnected verifies that a node
// configured in NodeStore but absent from the live registry is recorded as
// skipped. This represents the real disconnected-node path: the disconnect
// listener removes the entry from the registry, so configured-but-disconnected
// nodes never appear in registry.All().
func TestServerPushConfigRefreshSkippedWhenNodeDisconnected(t *testing.T) {
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{ID: "node-1", Alias: "alias-1", Token: "tok-1"},
})
if err != nil {
t.Fatalf("load node store: %v", err)
}
// Registry has no entry for node-1 (simulates post-disconnect state).
registry := edgenode.NewRegistry()
s := &Server{
registry: registry,
nodeStore: nodeStore,
logger: zap.NewNop(),
HeartbeatInterval: 30,
HeartbeatWait: 45,
}
req := &iop.NodeConfigRefreshRequest{RequestId: "req-1"}
results := s.PushConfigRefresh(context.Background(), req)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].NodeID != "node-1" {
t.Fatalf("unexpected node id: %q", results[0].NodeID)
}
if results[0].Status != "skipped" {
t.Fatalf("expected status=skipped for absent registry entry, got %q", results[0].Status)
}
}
// TestServerPushConfigRefreshEmptyNodeStoreProducesNoResults verifies that
// PushConfigRefresh with an empty NodeStore returns an empty result slice.
func TestServerPushConfigRefreshEmptyNodeStoreProducesNoResults(t *testing.T) {
nodeStore, err := edgenode.LoadFromConfig(nil)
if err != nil {
t.Fatalf("load node store: %v", err)
}
s := &Server{
registry: edgenode.NewRegistry(),
nodeStore: nodeStore,
logger: zap.NewNop(),
HeartbeatInterval: 30,
HeartbeatWait: 45,
}
results := s.PushConfigRefresh(context.Background(), &iop.NodeConfigRefreshRequest{RequestId: "req-empty"})
if len(results) != 0 {
t.Fatalf("expected 0 results for empty node store, got %d", len(results))
}
}
func findCLIAdapter(payload *iop.NodeConfigPayload) *iop.AdapterConfig {
for _, a := range payload.GetAdapters() {
if a.GetType() == "cli" {

View file

@ -0,0 +1,177 @@
package adapters
import (
"encoding/json"
"fmt"
"sort"
"go.uber.org/zap"
"iop/apps/node/internal/adapters/cli"
"iop/apps/node/internal/adapters/mock"
"iop/apps/node/internal/adapters/ollama"
"iop/apps/node/internal/adapters/openai_compat"
"iop/apps/node/internal/adapters/vllm"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
type ConfigSet struct {
Registry *Registry
Items map[string]ConfigItem
}
type ConfigItem struct {
Key string
Type string
Fingerprint string
}
type ConfigDiff struct {
Added []string
Updated []string
Removed []string
Unchanged []string
}
func BuildConfigSet(payload *iop.NodeConfigPayload, logger *zap.Logger) (*ConfigSet, error) {
reg := NewRegistry()
items := make(map[string]ConfigItem)
for _, ac := range payload.GetAdapters() {
if !ac.GetEnabled() {
continue
}
instanceKey := ac.GetName()
typeName := ac.GetType()
if instanceKey == "" {
instanceKey = typeName
}
var fingerprint string
switch typeName {
case "mock":
a := mock.New(logger)
reg.RegisterKeyed(instanceKey, typeName, a)
fingerprint = "mock"
case "ollama":
var cfg config.OllamaConf
if m := ac.GetOllama(); m != nil {
cfg = ollamaConfFromProto(m)
} else {
cfg = ollamaConfFromStruct(ac.GetSettings())
}
a := ollama.New(cfg, logger, instanceKey)
reg.RegisterKeyed(instanceKey, typeName, a)
bs, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshal ollama config: %w", err)
}
fingerprint = string(bs)
case "vllm":
var cfg config.VllmConf
if m := ac.GetVllm(); m != nil {
cfg = vllmConfFromProto(m)
} else {
cfg = vllmConfFromStruct(ac.GetSettings())
}
a := vllm.New(cfg, logger, instanceKey)
reg.RegisterKeyed(instanceKey, typeName, a)
bs, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshal vllm config: %w", err)
}
fingerprint = string(bs)
case "openai_compat":
var cfg config.OpenAICompatConf
if m := ac.GetOpenaiCompat(); m != nil {
cfg = openAICompatConfFromProto(m)
} else {
cfg = openAICompatConfFromStruct(ac.GetSettings())
}
a := openai_compat.New(cfg, logger, instanceKey)
reg.RegisterKeyed(instanceKey, typeName, a)
bs, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshal openai_compat config: %w", err)
}
fingerprint = string(bs)
case "cli":
var cfg config.CLIConf
if m := ac.GetCli(); m != nil {
cfg = cliConfFromProto(m)
} else {
cfg = cliConfFromStruct(ac.GetSettings())
}
a := cli.New(cfg, logger)
reg.RegisterKeyed(instanceKey, typeName, a)
bs, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshal cli config: %w", err)
}
fingerprint = string(bs)
default:
return nil, fmt.Errorf("adapters: unknown adapter type %q", typeName)
}
items[instanceKey] = ConfigItem{
Key: instanceKey,
Type: typeName,
Fingerprint: fingerprint,
}
}
return &ConfigSet{
Registry: reg,
Items: items,
}, nil
}
func DiffConfigSets(current, next *ConfigSet) ConfigDiff {
var diff ConfigDiff
currItems := make(map[string]ConfigItem)
if current != nil {
currItems = current.Items
}
nextItems := make(map[string]ConfigItem)
if next != nil {
nextItems = next.Items
}
// Added / Updated / Unchanged
for k, nextItem := range nextItems {
currItem, exists := currItems[k]
if !exists {
diff.Added = append(diff.Added, k)
} else if currItem.Type != nextItem.Type || currItem.Fingerprint != nextItem.Fingerprint {
diff.Updated = append(diff.Updated, k)
} else {
diff.Unchanged = append(diff.Unchanged, k)
}
}
// Removed
for k := range currItems {
if _, exists := nextItems[k]; !exists {
diff.Removed = append(diff.Removed, k)
}
}
sort.Strings(diff.Added)
sort.Strings(diff.Updated)
sort.Strings(diff.Removed)
sort.Strings(diff.Unchanged)
return diff
}

View file

@ -0,0 +1,151 @@
package adapters
import (
"testing"
"google.golang.org/protobuf/types/known/structpb"
iop "iop/proto/gen/iop"
)
func TestDiffConfigSetsAddedRemovedUnchanged(t *testing.T) {
currPayload := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{Name: "mock1", Type: "mock", Enabled: true},
{Name: "mock2", Type: "mock", Enabled: true},
},
}
nextPayload := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{Name: "mock2", Type: "mock", Enabled: true},
{Name: "mock3", Type: "mock", Enabled: true},
},
}
currSet, err := BuildConfigSet(currPayload, nil)
if err != nil {
t.Fatalf("BuildConfigSet curr: %v", err)
}
nextSet, err := BuildConfigSet(nextPayload, nil)
if err != nil {
t.Fatalf("BuildConfigSet next: %v", err)
}
diff := DiffConfigSets(currSet, nextSet)
if len(diff.Added) != 1 || diff.Added[0] != "mock3" {
t.Errorf("expected added [mock3], got %v", diff.Added)
}
if len(diff.Removed) != 1 || diff.Removed[0] != "mock1" {
t.Errorf("expected removed [mock1], got %v", diff.Removed)
}
if len(diff.Unchanged) != 1 || diff.Unchanged[0] != "mock2" {
t.Errorf("expected unchanged [mock2], got %v", diff.Unchanged)
}
if len(diff.Updated) != 0 {
t.Errorf("expected updated empty, got %v", diff.Updated)
}
}
func TestDiffConfigSetsOpenAICompatCapacityEndpoint(t *testing.T) {
currPayload := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Name: "oai",
Type: "openai_compat",
Enabled: true,
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: "openai",
Endpoint: "http://endpoint-old",
Capacity: 2,
},
},
},
},
}
// 1. Capacity 변경
nextPayload1 := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Name: "oai",
Type: "openai_compat",
Enabled: true,
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: "openai",
Endpoint: "http://endpoint-old",
Capacity: 5,
},
},
},
},
}
// 2. Endpoint 변경
nextPayload2 := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Name: "oai",
Type: "openai_compat",
Enabled: true,
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: "openai",
Endpoint: "http://endpoint-new",
Capacity: 2,
},
},
},
},
}
currSet, _ := BuildConfigSet(currPayload, nil)
// Test case 1
nextSet1, _ := BuildConfigSet(nextPayload1, nil)
diff1 := DiffConfigSets(currSet, nextSet1)
if len(diff1.Updated) != 1 || diff1.Updated[0] != "oai" {
t.Errorf("expected capacity change to trigger updated, got updated=%v", diff1.Updated)
}
// Test case 2
nextSet2, _ := BuildConfigSet(nextPayload2, nil)
diff2 := DiffConfigSets(currSet, nextSet2)
if len(diff2.Updated) != 1 || diff2.Updated[0] != "oai" {
t.Errorf("expected endpoint change to trigger updated, got updated=%v", diff2.Updated)
}
}
func TestDiffConfigSetsLegacySettings(t *testing.T) {
currSt, _ := structpb.NewStruct(map[string]any{
"provider": "openai",
"endpoint": "http://legacy",
"capacity": float64(2),
})
currPayload := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{Name: "oai", Type: "openai_compat", Enabled: true, Settings: currSt},
},
}
nextSt, _ := structpb.NewStruct(map[string]any{
"provider": "openai",
"endpoint": "http://legacy-changed",
"capacity": float64(2),
})
nextPayload := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{Name: "oai", Type: "openai_compat", Enabled: true, Settings: nextSt},
},
}
currSet, _ := BuildConfigSet(currPayload, nil)
nextSet, _ := BuildConfigSet(nextPayload, nil)
diff := DiffConfigSets(currSet, nextSet)
if len(diff.Updated) != 1 || diff.Updated[0] != "oai" {
t.Errorf("expected legacy settings change to trigger updated, got updated=%v", diff.Updated)
}
}

View file

@ -1,70 +1,20 @@
package adapters
import (
"fmt"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
"iop/apps/node/internal/adapters/cli"
"iop/apps/node/internal/adapters/mock"
"iop/apps/node/internal/adapters/ollama"
"iop/apps/node/internal/adapters/openai_compat"
"iop/apps/node/internal/adapters/vllm"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
// BuildFromPayload creates a Registry from a NodeConfigPayload received from edge.
func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Registry, error) {
reg := NewRegistry()
for _, ac := range payload.GetAdapters() {
if !ac.GetEnabled() {
continue
}
instanceKey := ac.GetName()
typeName := ac.GetType()
switch typeName {
case "mock":
reg.RegisterKeyed(instanceKey, typeName, mock.New(logger))
case "ollama":
var cfg config.OllamaConf
if m := ac.GetOllama(); m != nil {
cfg = ollamaConfFromProto(m)
} else {
cfg = ollamaConfFromStruct(ac.GetSettings())
}
reg.RegisterKeyed(instanceKey, typeName, ollama.New(cfg, logger, instanceKey))
case "vllm":
var cfg config.VllmConf
if m := ac.GetVllm(); m != nil {
cfg = vllmConfFromProto(m)
} else {
cfg = vllmConfFromStruct(ac.GetSettings())
}
reg.RegisterKeyed(instanceKey, typeName, vllm.New(cfg, logger, instanceKey))
case "openai_compat":
var cfg config.OpenAICompatConf
if m := ac.GetOpenaiCompat(); m != nil {
cfg = openAICompatConfFromProto(m)
} else {
cfg = openAICompatConfFromStruct(ac.GetSettings())
}
reg.RegisterKeyed(instanceKey, typeName, openai_compat.New(cfg, logger, instanceKey))
case "cli":
var cfg config.CLIConf
if m := ac.GetCli(); m != nil {
cfg = cliConfFromProto(m)
} else {
cfg = cliConfFromStruct(ac.GetSettings())
}
reg.RegisterKeyed(instanceKey, typeName, cli.New(cfg, logger))
default:
return nil, fmt.Errorf("adapters: unknown adapter type %q", typeName)
}
set, err := BuildConfigSet(payload, logger)
if err != nil {
return nil, err
}
return reg, nil
return set.Registry, nil
}
func ollamaConfFromProto(m *iop.OllamaAdapterConfig) config.OllamaConf {

View file

@ -135,3 +135,19 @@ func (r *Registry) Stop(ctx context.Context) error {
}
return firstErr
}
// Keys returns all registered adapter instance keys in insertion order.
func (r *Registry) Keys() []string {
out := make([]string, len(r.order))
copy(out, r.order)
return out
}
// TypeOf returns the type name of the adapter registered under key.
func (r *Registry) TypeOf(key string) (string, bool) {
e, ok := r.entries[key]
if !ok {
return "", false
}
return e.typeName, true
}

View file

@ -76,12 +76,12 @@ func connectRuntime(ctx context.Context, cfg *config.NodeConfig, logger *zap.Log
}
owner := &runtimeOwner{sess: result.Session}
reg, err := adapters.BuildFromPayload(result.Config, logger)
set, err := adapters.BuildConfigSet(result.Config, logger)
if err != nil {
owner.close()
return nil, fmt.Errorf("build adapters: %w", err)
}
owner.reg = reg
owner.reg = set.Registry
dsn, err := storeDSN(result.Config.GetRuntime().GetWorkspaceRoot())
if err != nil {
@ -95,14 +95,14 @@ func connectRuntime(ctx context.Context, cfg *config.NodeConfig, logger *zap.Log
}
owner.st = st
if err := reg.Start(ctx); err != nil {
if err := set.Registry.Start(ctx); err != nil {
owner.close()
return nil, fmt.Errorf("start adapters: %w", err)
}
rtr := router.New(reg, logger)
rtr := router.New(set.Registry, logger)
globalConcurrency := int(result.Config.GetRuntime().GetConcurrency())
n := node.New(result.NodeID, rtr, st, globalConcurrency, os.Stdout, logger)
n := node.New(result.NodeID, rtr, st, globalConcurrency, os.Stdout, logger, set)
result.Session.SetEventHandler(func(event *iop.EdgeNodeEvent) {
printEdgeEvent(os.Stdout, event)
})

View file

@ -20,6 +20,8 @@ import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"iop/apps/node/internal/adapters"
"iop/apps/node/internal/router"
"iop/apps/node/internal/runtime"
"iop/apps/node/internal/store"
"iop/apps/node/internal/transport"
@ -28,15 +30,17 @@ import (
// Node implements transport.Handler and coordinates the full execution pipeline.
type Node struct {
nodeID string
router runtime.Router
store *store.Store
runs *runManager
globalGate *fifoGate // node-wide concurrency safety guard across all adapters
adapterGatesMu sync.Mutex
adapterGates map[string]*fifoGate // per adapter-key concurrency safety guard
out io.Writer
logger *zap.Logger
nodeID string
router runtime.Router
store *store.Store
runs *runManager
globalGate *fifoGate // node-wide concurrency safety guard across all adapters
adapterGatesMu sync.Mutex
adapterGates map[string]*fifoGate // per adapter-key concurrency safety guard
out io.Writer
logger *zap.Logger
currentConfigSet *adapters.ConfigSet
configSetMu sync.RWMutex
}
// New creates a Node. It satisfies transport.Handler.
@ -51,19 +55,21 @@ func New(
globalConcurrency int,
out io.Writer,
logger *zap.Logger,
initialConfigSet *adapters.ConfigSet,
) *Node {
if out == nil {
out = os.Stdout
}
return &Node{
nodeID: nodeID,
router: router,
store: st,
runs: newRunManager(),
globalGate: newFifoGate(globalConcurrency),
adapterGates: make(map[string]*fifoGate),
out: out,
logger: logger,
nodeID: nodeID,
router: router,
store: st,
runs: newRunManager(),
globalGate: newFifoGate(globalConcurrency),
adapterGates: make(map[string]*fifoGate),
out: out,
logger: logger,
currentConfigSet: initialConfigSet,
}
}
@ -90,6 +96,14 @@ func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *i
}
printEdgeMessage(n.out, rr.Input)
n.configSetMu.RLock()
configLocked := true
defer func() {
if configLocked {
n.configSetMu.RUnlock()
}
}()
spec, adapter, err := n.router.ResolveAdapter(ctx, rr)
if err != nil {
return fmt.Errorf("node: resolve: %w", err)
@ -142,8 +156,16 @@ func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *i
}
n.runs.register(h)
configLocked = false
n.configSetMu.RUnlock()
var sender protoSender = noopSender{}
if sess != nil && sess.IsAlive() {
sender = sess
}
sink := &sessionSink{
sess: sess,
sess: sender,
out: n.out,
nodeID: n.nodeID,
sessionID: normalizeSessionID(spec.SessionID),
@ -266,6 +288,103 @@ func (n *Node) OnCancel(_ context.Context, _ *transport.Session, req *iop.Cancel
}
}
// OnConfigRefresh handles a config refresh request from edge.
func (n *Node) OnConfigRefresh(ctx context.Context, _ *transport.Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
n.logger.Info("config refresh request received", zap.String("request_id", req.GetRequestId()))
if req.GetConfig() == nil {
if len(req.GetChangedPaths()) == 0 {
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
}, nil
}
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED,
RestartRequiredPaths: req.GetChangedPaths(),
}, nil
}
nextSet, err := adapters.BuildConfigSet(req.GetConfig(), n.logger)
if err != nil {
n.logger.Error("config refresh: failed to build next config set", zap.Error(err))
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED,
Error: fmt.Sprintf("build config set: %s", err.Error()),
}, nil
}
n.configSetMu.Lock()
defer n.configSetMu.Unlock()
diff := adapters.DiffConfigSets(n.currentConfigSet, nextSet)
if len(diff.Added) == 0 && len(diff.Updated) == 0 && len(diff.Removed) == 0 {
n.logger.Info("config refresh: no adapter changes detected")
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
}, nil
}
n.logger.Info("config refresh: adapter diff calculated",
zap.Strings("added", diff.Added),
zap.Strings("updated", diff.Updated),
zap.Strings("removed", diff.Removed),
)
// Start the new registry
if err := nextSet.Registry.Start(ctx); err != nil {
n.logger.Error("config refresh: failed to start new adapters", zap.Error(err))
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED,
Error: fmt.Sprintf("start next registry: %s", err.Error()),
}, nil
}
// Live swap registry in router
if mr, ok := n.router.(router.MutableRouter); ok {
mr.SetRegistry(nextSet.Registry)
} else {
n.logger.Warn("router does not implement MutableRouter, cannot swap registry")
}
// Update existing adapter gates capacity
n.adapterGatesMu.Lock()
for key, gate := range n.adapterGates {
if adapter, ok := nextSet.Registry.Get(key); ok {
if caps, err := adapter.Capabilities(ctx); err == nil {
gate.updateCapacity(caps.MaxConcurrency)
}
}
}
n.adapterGatesMu.Unlock()
// Registry.Stop stops every lifecycle adapter in the old registry, so defer
// the whole old registry while any pre-swap run is active.
hasActiveRuns := n.runs.hasAnyActiveRuns()
if !hasActiveRuns {
if n.currentConfigSet != nil && n.currentConfigSet.Registry != nil {
if err := n.currentConfigSet.Registry.Stop(context.Background()); err != nil {
n.logger.Warn("config refresh: failed to stop old adapters", zap.Error(err))
}
}
} else {
n.logger.Info("config refresh: deferring old registry stop due to active runs on updated/removed adapters")
}
n.currentConfigSet = nextSet
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
}, nil
}
// OnCommandRequest handles an incoming node command from a transport Session.
func (n *Node) OnCommandRequest(ctx context.Context, sess *transport.Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) {
n.logger.Info("command request",
@ -510,6 +629,10 @@ type protoSender interface {
Send(m proto.Message) error
}
type noopSender struct{}
func (noopSender) Send(proto.Message) error { return nil }
// sessionSink wraps a transport.Session to implement runtime.EventSink.
type sessionSink struct {
sess protoSender

View file

@ -173,7 +173,7 @@ func TestOverDispatchSafety_RejectEventObservedByEdge(t *testing.T) {
}
t.Cleanup(func() { _ = st.Close() })
n := node.New("test-node", rtr, st, 1, nil, logger)
n := node.New("test-node", rtr, st, 1, nil, logger, nil)
dialResult.Session.SetHandler(n)
// Send first RunRequest from Edge → Node (adapter will block).

File diff suppressed because it is too large Load diff

View file

@ -51,6 +51,23 @@ func (m *runManager) cancelRun(runID string) bool {
return true
}
func (m *runManager) hasActiveRunsForAdapter(adapterKey string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
for _, r := range m.runs {
if r.adapter == adapterKey {
return true
}
}
return false
}
func (m *runManager) hasAnyActiveRuns() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.runs) > 0
}
// rejectReason classifies why a run could not be admitted.
type rejectReason string
@ -135,6 +152,12 @@ func (g *fifoGate) activeCount() int {
return g.inFlight
}
func (g *fifoGate) updateCapacity(capacity int) {
g.mu.Lock()
defer g.mu.Unlock()
g.capacity = capacity
}
func (g *fifoGate) queuedCount() int {
return 0
}

View file

@ -3,6 +3,7 @@ package router
import (
"context"
"fmt"
"sync"
"go.uber.org/zap"
@ -10,23 +11,35 @@ import (
"iop/apps/node/internal/runtime"
)
type MutableRouter interface {
runtime.Router
SetRegistry(*adapters.Registry)
}
type defaultRouter struct {
mu sync.RWMutex
registry *adapters.Registry
logger *zap.Logger
}
// New returns a Router that resolves requests to registered adapters.
func New(reg *adapters.Registry, logger *zap.Logger) runtime.Router {
// New returns a MutableRouter that resolves requests to registered adapters.
func New(reg *adapters.Registry, logger *zap.Logger) MutableRouter {
return &defaultRouter{registry: reg, logger: logger}
}
func (r *defaultRouter) Resolve(_ context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, error) {
func (r *defaultRouter) SetRegistry(reg *adapters.Registry) {
r.mu.Lock()
defer r.mu.Unlock()
r.registry = reg
}
func (r *defaultRouter) resolveWithRegistry(req runtime.RunRequest, reg *adapters.Registry) (runtime.ExecutionSpec, error) {
adapterName := req.Adapter
if adapterName == "" {
return runtime.ExecutionSpec{}, fmt.Errorf("router: adapter is required")
}
if _, err := r.registry.Lookup(adapterName); err != nil {
if _, err := reg.Lookup(adapterName); err != nil {
return runtime.ExecutionSpec{}, fmt.Errorf("router: %w", err)
}
@ -52,12 +65,24 @@ func (r *defaultRouter) Resolve(_ context.Context, req runtime.RunRequest) (runt
return spec, nil
}
func (r *defaultRouter) Resolve(_ context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, error) {
r.mu.RLock()
reg := r.registry
r.mu.RUnlock()
return r.resolveWithRegistry(req, reg)
}
func (r *defaultRouter) ResolveAdapter(ctx context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, runtime.Adapter, error) {
spec, err := r.Resolve(ctx, req)
r.mu.RLock()
reg := r.registry
r.mu.RUnlock()
spec, err := r.resolveWithRegistry(req, reg)
if err != nil {
return runtime.ExecutionSpec{}, nil, err
}
adapter, err := r.registry.Lookup(spec.Adapter)
adapter, err := reg.Lookup(spec.Adapter)
if err != nil {
return runtime.ExecutionSpec{}, nil, fmt.Errorf("router: %w", err)
}
@ -65,11 +90,19 @@ func (r *defaultRouter) ResolveAdapter(ctx context.Context, req runtime.RunReque
}
func (r *defaultRouter) LookupAdapter(adapterName string) (runtime.Adapter, error) {
return r.registry.Lookup(adapterName)
r.mu.RLock()
reg := r.registry
r.mu.RUnlock()
return reg.Lookup(adapterName)
}
func (r *defaultRouter) GetAdapter(adapterName string) (runtime.Adapter, bool) {
a, err := r.registry.Lookup(adapterName)
r.mu.RLock()
reg := r.registry
r.mu.RUnlock()
a, err := reg.Lookup(adapterName)
if err != nil {
return nil, false
}

View file

@ -4,6 +4,7 @@ import (
"context"
"strings"
"testing"
"time"
"go.uber.org/zap"
"iop/apps/node/internal/adapters"
@ -206,3 +207,97 @@ func TestGetAdapter_AmbiguousReturnsNotFound(t *testing.T) {
t.Fatal("expected GetAdapter to return false for ambiguous type name")
}
}
func TestRouterSetRegistryAffectsSubsequentResolve(t *testing.T) {
reg1 := adapters.NewRegistry()
reg1.Register(&stubAdapter{name: "adapter1"})
r := New(reg1, zap.NewNop())
// Resolving adapter1 should succeed initially.
_, _, err := r.ResolveAdapter(context.Background(), runtime.RunRequest{
RunID: "run-1",
Adapter: "adapter1",
})
if err != nil {
t.Fatalf("resolve adapter1 initially: %v", err)
}
// Resolving adapter2 should fail initially.
_, _, err = r.ResolveAdapter(context.Background(), runtime.RunRequest{
RunID: "run-2",
Adapter: "adapter2",
})
if err == nil {
t.Fatal("expected error resolving adapter2 initially")
}
// Swap registry with one containing only adapter2.
reg2 := adapters.NewRegistry()
reg2.Register(&stubAdapter{name: "adapter2"})
r.SetRegistry(reg2)
// Resolving adapter1 should now fail.
_, _, err = r.ResolveAdapter(context.Background(), runtime.RunRequest{
RunID: "run-3",
Adapter: "adapter1",
})
if err == nil {
t.Fatal("expected error resolving adapter1 after swap")
}
// Resolving adapter2 should now succeed.
_, _, err = r.ResolveAdapter(context.Background(), runtime.RunRequest{
RunID: "run-4",
Adapter: "adapter2",
})
if err != nil {
t.Fatalf("resolve adapter2 after swap: %v", err)
}
}
func TestRouterResolveAdapterConcurrentSetRegistryDoesNotDeadlock(t *testing.T) {
reg1 := adapters.NewRegistry()
reg1.Register(&stubAdapter{name: "cli"})
r := New(reg1, zap.NewNop())
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
done := make(chan struct{})
go func() {
for {
select {
case <-ctx.Done():
return
default:
_, _, err := r.ResolveAdapter(ctx, runtime.RunRequest{
RunID: "run-1",
Adapter: "cli",
})
_ = err
}
}
}()
go func() {
reg2 := adapters.NewRegistry()
reg2.Register(&stubAdapter{name: "cli"})
emptyReg := adapters.NewRegistry()
for {
select {
case <-ctx.Done():
close(done)
return
default:
r.SetRegistry(reg2)
r.SetRegistry(emptyReg)
}
}
}()
select {
case <-done:
// Success! Did not deadlock within timeout.
}
}

View file

@ -0,0 +1,11 @@
package transport
import (
toki "git.toki-labs.com/toki/proto-socket/go"
"go.uber.org/zap"
)
// ExportNewSession exposes newSession for black-box transport tests.
func ExportNewSession(client *toki.TcpClient, logger *zap.Logger, nodeID, alias string) *Session {
return newSession(client, logger, nodeID, alias)
}

View file

@ -126,6 +126,13 @@ func (m *mockHandler) OnCommandRequest(ctx context.Context, sess *transport.Sess
return &iop.NodeCommandResponse{Error: "not implemented in mock"}, nil
}
func (m *mockHandler) OnConfigRefresh(_ context.Context, _ *transport.Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED,
}, nil
}
func getFreePort(t *testing.T) string {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {

View file

@ -29,5 +29,9 @@ func nodeParserMap() toki.ParserMap {
m := &iop.RegisterResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeConfigRefreshRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeConfigRefreshRequest{}
return m, proto.Unmarshal(b, m)
},
}
}

View file

@ -17,6 +17,7 @@ type Handler interface {
OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
OnCommandRequest(ctx context.Context, sess *Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error)
OnConfigRefresh(ctx context.Context, sess *Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error)
}
// Session represents the node's persistent connection to edge.
@ -82,6 +83,28 @@ func newSession(client *toki.TcpClient, logger *zap.Logger, nodeID, alias string
return resp, nil
})
toki.AddRequestListenerTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](&client.Communicator, func(req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED,
Error: "handler not ready",
}, nil
}
resp, err := h.OnConfigRefresh(context.Background(), s, req)
if err != nil {
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED,
Error: err.Error(),
}, nil
}
return resp, nil
})
toki.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(event *iop.EdgeNodeEvent) {
s.emitEvent(event)
})

View file

@ -2,8 +2,16 @@ package transport_test
import (
"context"
"errors"
"fmt"
"net"
"sync"
"testing"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"iop/apps/node/internal/transport"
iop "iop/proto/gen/iop"
@ -20,6 +28,12 @@ func (h *noopHandler) OnCancel(_ context.Context, _ *transport.Session, _ *iop.C
func (h *noopHandler) OnCommandRequest(_ context.Context, _ *transport.Session, _ *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) {
return nil, nil
}
func (h *noopHandler) OnConfigRefresh(_ context.Context, _ *transport.Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
}, nil
}
func TestSession_SetHandler_ConcurrentSafe(t *testing.T) {
var s transport.Session
@ -34,3 +48,120 @@ func TestSession_SetHandler_ConcurrentSafe(t *testing.T) {
}
wg.Wait()
}
// buildSessionTestPipe creates a net.Pipe-based pair: one side acts as "edge"
// (sends requests) and the other side acts as the node session under test.
// The edge side parser map must include the response type; the node side must
// include the request type (handled by nodeParserMap via DialEdge, but for
// unit tests we wire it manually).
func buildSessionTestPipe(t *testing.T) (edgeSide *toki.TcpClient, nodeSide *toki.TcpClient) {
t.Helper()
edgeConn, nodeConn := net.Pipe()
edgeParserMap := toki.ParserMap{
toki.TypeNameOf(&iop.NodeConfigRefreshResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeConfigRefreshResponse{}
return m, proto.Unmarshal(b, m)
},
}
nodeParserMap := toki.ParserMap{
toki.TypeNameOf(&iop.NodeConfigRefreshRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeConfigRefreshRequest{}
return m, proto.Unmarshal(b, m)
},
}
edgeSide = toki.NewTcpClient(edgeConn, 0, 0, edgeParserMap)
nodeSide = toki.NewTcpClient(nodeConn, 0, 0, nodeParserMap)
t.Cleanup(func() { edgeSide.Close(); nodeSide.Close() })
return edgeSide, nodeSide
}
// appliedHandler always returns applied.
type appliedHandler struct{ noopHandler }
func (h *appliedHandler) OnConfigRefresh(_ context.Context, _ *transport.Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
}, nil
}
// errorHandler returns an error from OnConfigRefresh.
type errorHandler struct{ noopHandler }
func (h *errorHandler) OnConfigRefresh(_ context.Context, _ *transport.Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
return nil, errors.New("refresh failed")
}
// TestSessionConfigRefreshRequestReturnsHandlerResponse verifies that a
// NodeConfigRefreshRequest pushed by the edge reaches the handler and the
// response is returned to the edge.
func TestSessionConfigRefreshRequestReturnsHandlerResponse(t *testing.T) {
edgeSide, nodeSide := buildSessionTestPipe(t)
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test")
sess.SetHandler(&appliedHandler{})
resp, err := toki.SendRequestTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
&edgeSide.Communicator,
&iop.NodeConfigRefreshRequest{RequestId: "req-1", ChangedPaths: []string{"nodes.0.providers.0.capacity"}},
2*time.Second,
)
if err != nil {
t.Fatalf("SendRequestTyped: %v", err)
}
if resp.GetStatus() != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED {
t.Fatalf("expected status=applied, got %v", resp.GetStatus())
}
if resp.GetRequestId() != "req-1" {
t.Fatalf("expected request_id=req-1, got %q", resp.GetRequestId())
}
}
// TestSessionConfigRefreshRequestHandlerErrorReturnsFailure verifies that a
// handler error is translated to a failed protocol response.
func TestSessionConfigRefreshRequestHandlerErrorReturnsFailure(t *testing.T) {
edgeSide, nodeSide := buildSessionTestPipe(t)
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test")
sess.SetHandler(&errorHandler{})
resp, err := toki.SendRequestTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
&edgeSide.Communicator,
&iop.NodeConfigRefreshRequest{RequestId: "req-err"},
2*time.Second,
)
if err != nil {
t.Fatalf("SendRequestTyped: %v", err)
}
if resp.GetStatus() != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED {
t.Fatalf("expected status=failed, got %v", resp.GetStatus())
}
if resp.GetError() == "" {
t.Fatal("expected non-empty error message")
}
}
// TestSessionConfigRefreshRequestNoHandlerReturnsFailure verifies that when no
// handler is set, the session returns a failed response with an informative message.
func TestSessionConfigRefreshRequestNoHandlerReturnsFailure(t *testing.T) {
edgeSide, nodeSide := buildSessionTestPipe(t)
_ = transport.ExportNewSession(nodeSide, zap.NewNop(), "node-nohandler", "")
// handler intentionally NOT set
resp, err := toki.SendRequestTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
&edgeSide.Communicator,
&iop.NodeConfigRefreshRequest{RequestId: "req-nohandler"},
2*time.Second,
)
if err != nil {
t.Fatalf("SendRequestTyped: %v", err)
}
if resp.GetStatus() != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED {
t.Fatalf("expected status=failed when handler is nil, got %v", resp.GetStatus())
}
}
// Compile check: Session must export a way to create instances for tests.
// ExportNewSession is expected in session_export_test.go or a separate test helper file.
var _ = fmt.Sprintf

View file

@ -178,6 +178,61 @@ func (NodeCommandType) EnumDescriptor() ([]byte, []int) {
return file_proto_iop_runtime_proto_rawDescGZIP(), []int{2}
}
type NodeConfigRefreshStatus int32
const (
NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_UNSPECIFIED NodeConfigRefreshStatus = 0
NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED NodeConfigRefreshStatus = 1
NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED NodeConfigRefreshStatus = 2
NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED NodeConfigRefreshStatus = 3
NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_SKIPPED NodeConfigRefreshStatus = 4
)
// Enum value maps for NodeConfigRefreshStatus.
var (
NodeConfigRefreshStatus_name = map[int32]string{
0: "NODE_CONFIG_REFRESH_STATUS_UNSPECIFIED",
1: "NODE_CONFIG_REFRESH_STATUS_APPLIED",
2: "NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED",
3: "NODE_CONFIG_REFRESH_STATUS_FAILED",
4: "NODE_CONFIG_REFRESH_STATUS_SKIPPED",
}
NodeConfigRefreshStatus_value = map[string]int32{
"NODE_CONFIG_REFRESH_STATUS_UNSPECIFIED": 0,
"NODE_CONFIG_REFRESH_STATUS_APPLIED": 1,
"NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED": 2,
"NODE_CONFIG_REFRESH_STATUS_FAILED": 3,
"NODE_CONFIG_REFRESH_STATUS_SKIPPED": 4,
}
)
func (x NodeConfigRefreshStatus) Enum() *NodeConfigRefreshStatus {
p := new(NodeConfigRefreshStatus)
*p = x
return p
}
func (x NodeConfigRefreshStatus) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (NodeConfigRefreshStatus) Descriptor() protoreflect.EnumDescriptor {
return file_proto_iop_runtime_proto_enumTypes[3].Descriptor()
}
func (NodeConfigRefreshStatus) Type() protoreflect.EnumType {
return &file_proto_iop_runtime_proto_enumTypes[3]
}
func (x NodeConfigRefreshStatus) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use NodeConfigRefreshStatus.Descriptor instead.
func (NodeConfigRefreshStatus) EnumDescriptor() ([]byte, []int) {
return file_proto_iop_runtime_proto_rawDescGZIP(), []int{3}
}
// RunRequest initiates an adapter execution on a node.
type RunRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -2104,6 +2159,136 @@ func (x *NodeRuntimeConfig) GetWorkspaceRoot() string {
return ""
}
// NodeConfigRefreshRequest is sent by edge to a connected node to push a new config payload.
type NodeConfigRefreshRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
Config *NodeConfigPayload `protobuf:"bytes,2,opt,name=config,proto3" json:"config,omitempty"`
ChangedPaths []string `protobuf:"bytes,3,rep,name=changed_paths,json=changedPaths,proto3" json:"changed_paths,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NodeConfigRefreshRequest) Reset() {
*x = NodeConfigRefreshRequest{}
mi := &file_proto_iop_runtime_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NodeConfigRefreshRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NodeConfigRefreshRequest) ProtoMessage() {}
func (x *NodeConfigRefreshRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_runtime_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NodeConfigRefreshRequest.ProtoReflect.Descriptor instead.
func (*NodeConfigRefreshRequest) Descriptor() ([]byte, []int) {
return file_proto_iop_runtime_proto_rawDescGZIP(), []int{23}
}
func (x *NodeConfigRefreshRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *NodeConfigRefreshRequest) GetConfig() *NodeConfigPayload {
if x != nil {
return x.Config
}
return nil
}
func (x *NodeConfigRefreshRequest) GetChangedPaths() []string {
if x != nil {
return x.ChangedPaths
}
return nil
}
// NodeConfigRefreshResponse is returned by the node to acknowledge or report failure.
type NodeConfigRefreshResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
Status NodeConfigRefreshStatus `protobuf:"varint,2,opt,name=status,proto3,enum=iop.NodeConfigRefreshStatus" json:"status,omitempty"`
RestartRequiredPaths []string `protobuf:"bytes,3,rep,name=restart_required_paths,json=restartRequiredPaths,proto3" json:"restart_required_paths,omitempty"`
Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NodeConfigRefreshResponse) Reset() {
*x = NodeConfigRefreshResponse{}
mi := &file_proto_iop_runtime_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NodeConfigRefreshResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NodeConfigRefreshResponse) ProtoMessage() {}
func (x *NodeConfigRefreshResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_runtime_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NodeConfigRefreshResponse.ProtoReflect.Descriptor instead.
func (*NodeConfigRefreshResponse) Descriptor() ([]byte, []int) {
return file_proto_iop_runtime_proto_rawDescGZIP(), []int{24}
}
func (x *NodeConfigRefreshResponse) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *NodeConfigRefreshResponse) GetStatus() NodeConfigRefreshStatus {
if x != nil {
return x.Status
}
return NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_UNSPECIFIED
}
func (x *NodeConfigRefreshResponse) GetRestartRequiredPaths() []string {
if x != nil {
return x.RestartRequiredPaths
}
return nil
}
func (x *NodeConfigRefreshResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
var File_proto_iop_runtime_proto protoreflect.FileDescriptor
const file_proto_iop_runtime_proto_rawDesc = "" +
@ -2310,7 +2495,18 @@ const file_proto_iop_runtime_proto_rawDesc = "" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\\\n" +
"\x11NodeRuntimeConfig\x12 \n" +
"\vconcurrency\x18\x01 \x01(\x05R\vconcurrency\x12%\n" +
"\x0eworkspace_root\x18\x02 \x01(\tR\rworkspaceRoot*\x81\x01\n" +
"\x0eworkspace_root\x18\x02 \x01(\tR\rworkspaceRoot\"\x8e\x01\n" +
"\x18NodeConfigRefreshRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12.\n" +
"\x06config\x18\x02 \x01(\v2\x16.iop.NodeConfigPayloadR\x06config\x12#\n" +
"\rchanged_paths\x18\x03 \x03(\tR\fchangedPaths\"\xbc\x01\n" +
"\x19NodeConfigRefreshResponse\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x124\n" +
"\x06status\x18\x02 \x01(\x0e2\x1c.iop.NodeConfigRefreshStatusR\x06status\x124\n" +
"\x16restart_required_paths\x18\x03 \x03(\tR\x14restartRequiredPaths\x12\x14\n" +
"\x05error\x18\x04 \x01(\tR\x05error*\x81\x01\n" +
"\x0eRunSessionMode\x12 \n" +
"\x1cRUN_SESSION_MODE_UNSPECIFIED\x10\x00\x12&\n" +
"\"RUN_SESSION_MODE_CREATE_IF_MISSING\x10\x01\x12%\n" +
@ -2325,7 +2521,13 @@ const file_proto_iop_runtime_proto_rawDesc = "" +
"\x1eNODE_COMMAND_TYPE_CAPABILITIES\x10\x02\x12\"\n" +
"\x1eNODE_COMMAND_TYPE_SESSION_LIST\x10\x03\x12&\n" +
"\"NODE_COMMAND_TYPE_TRANSPORT_STATUS\x10\x04\x12 \n" +
"\x1cNODE_COMMAND_TYPE_OLLAMA_API\x10\x05B\x13Z\x11iop/proto/gen/iopb\x06proto3"
"\x1cNODE_COMMAND_TYPE_OLLAMA_API\x10\x05*\xed\x01\n" +
"\x17NodeConfigRefreshStatus\x12*\n" +
"&NODE_CONFIG_REFRESH_STATUS_UNSPECIFIED\x10\x00\x12&\n" +
"\"NODE_CONFIG_REFRESH_STATUS_APPLIED\x10\x01\x12/\n" +
"+NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED\x10\x02\x12%\n" +
"!NODE_CONFIG_REFRESH_STATUS_FAILED\x10\x03\x12&\n" +
"\"NODE_CONFIG_REFRESH_STATUS_SKIPPED\x10\x04B\x13Z\x11iop/proto/gen/iopb\x06proto3"
var (
file_proto_iop_runtime_proto_rawDescOnce sync.Once
@ -2339,79 +2541,84 @@ func file_proto_iop_runtime_proto_rawDescGZIP() []byte {
return file_proto_iop_runtime_proto_rawDescData
}
var file_proto_iop_runtime_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
var file_proto_iop_runtime_proto_msgTypes = make([]protoimpl.MessageInfo, 31)
var file_proto_iop_runtime_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
var file_proto_iop_runtime_proto_msgTypes = make([]protoimpl.MessageInfo, 33)
var file_proto_iop_runtime_proto_goTypes = []any{
(RunSessionMode)(0), // 0: iop.RunSessionMode
(CancelAction)(0), // 1: iop.CancelAction
(NodeCommandType)(0), // 2: iop.NodeCommandType
(*RunRequest)(nil), // 3: iop.RunRequest
(*RunEvent)(nil), // 4: iop.RunEvent
(*EdgeNodeEvent)(nil), // 5: iop.EdgeNodeEvent
(*Usage)(nil), // 6: iop.Usage
(*Heartbeat)(nil), // 7: iop.Heartbeat
(*CancelRequest)(nil), // 8: iop.CancelRequest
(*NodeCommandRequest)(nil), // 9: iop.NodeCommandRequest
(*NodeCommandResponse)(nil), // 10: iop.NodeCommandResponse
(*ProviderSnapshot)(nil), // 11: iop.ProviderSnapshot
(*AgentUsageStatus)(nil), // 12: iop.AgentUsageStatus
(*Error)(nil), // 13: iop.Error
(*RegisterRequest)(nil), // 14: iop.RegisterRequest
(*RegisterResponse)(nil), // 15: iop.RegisterResponse
(*NodeConfigPayload)(nil), // 16: iop.NodeConfigPayload
(*AdapterConfig)(nil), // 17: iop.AdapterConfig
(*MockAdapterConfig)(nil), // 18: iop.MockAdapterConfig
(*CLIAdapterConfig)(nil), // 19: iop.CLIAdapterConfig
(*CLIProfileConfig)(nil), // 20: iop.CLIProfileConfig
(*CLICompletionMarker)(nil), // 21: iop.CLICompletionMarker
(*OllamaAdapterConfig)(nil), // 22: iop.OllamaAdapterConfig
(*VllmAdapterConfig)(nil), // 23: iop.VllmAdapterConfig
(*OpenAICompatAdapterConfig)(nil), // 24: iop.OpenAICompatAdapterConfig
(*NodeRuntimeConfig)(nil), // 25: iop.NodeRuntimeConfig
nil, // 26: iop.RunRequest.MetadataEntry
nil, // 27: iop.RunEvent.MetadataEntry
nil, // 28: iop.EdgeNodeEvent.MetadataEntry
nil, // 29: iop.NodeCommandRequest.MetadataEntry
nil, // 30: iop.NodeCommandResponse.ResultEntry
nil, // 31: iop.AgentUsageStatus.MetadataEntry
nil, // 32: iop.CLIAdapterConfig.ProfilesEntry
nil, // 33: iop.OpenAICompatAdapterConfig.HeadersEntry
(*structpb.Struct)(nil), // 34: google.protobuf.Struct
(NodeConfigRefreshStatus)(0), // 3: iop.NodeConfigRefreshStatus
(*RunRequest)(nil), // 4: iop.RunRequest
(*RunEvent)(nil), // 5: iop.RunEvent
(*EdgeNodeEvent)(nil), // 6: iop.EdgeNodeEvent
(*Usage)(nil), // 7: iop.Usage
(*Heartbeat)(nil), // 8: iop.Heartbeat
(*CancelRequest)(nil), // 9: iop.CancelRequest
(*NodeCommandRequest)(nil), // 10: iop.NodeCommandRequest
(*NodeCommandResponse)(nil), // 11: iop.NodeCommandResponse
(*ProviderSnapshot)(nil), // 12: iop.ProviderSnapshot
(*AgentUsageStatus)(nil), // 13: iop.AgentUsageStatus
(*Error)(nil), // 14: iop.Error
(*RegisterRequest)(nil), // 15: iop.RegisterRequest
(*RegisterResponse)(nil), // 16: iop.RegisterResponse
(*NodeConfigPayload)(nil), // 17: iop.NodeConfigPayload
(*AdapterConfig)(nil), // 18: iop.AdapterConfig
(*MockAdapterConfig)(nil), // 19: iop.MockAdapterConfig
(*CLIAdapterConfig)(nil), // 20: iop.CLIAdapterConfig
(*CLIProfileConfig)(nil), // 21: iop.CLIProfileConfig
(*CLICompletionMarker)(nil), // 22: iop.CLICompletionMarker
(*OllamaAdapterConfig)(nil), // 23: iop.OllamaAdapterConfig
(*VllmAdapterConfig)(nil), // 24: iop.VllmAdapterConfig
(*OpenAICompatAdapterConfig)(nil), // 25: iop.OpenAICompatAdapterConfig
(*NodeRuntimeConfig)(nil), // 26: iop.NodeRuntimeConfig
(*NodeConfigRefreshRequest)(nil), // 27: iop.NodeConfigRefreshRequest
(*NodeConfigRefreshResponse)(nil), // 28: iop.NodeConfigRefreshResponse
nil, // 29: iop.RunRequest.MetadataEntry
nil, // 30: iop.RunEvent.MetadataEntry
nil, // 31: iop.EdgeNodeEvent.MetadataEntry
nil, // 32: iop.NodeCommandRequest.MetadataEntry
nil, // 33: iop.NodeCommandResponse.ResultEntry
nil, // 34: iop.AgentUsageStatus.MetadataEntry
nil, // 35: iop.CLIAdapterConfig.ProfilesEntry
nil, // 36: iop.OpenAICompatAdapterConfig.HeadersEntry
(*structpb.Struct)(nil), // 37: google.protobuf.Struct
}
var file_proto_iop_runtime_proto_depIdxs = []int32{
34, // 0: iop.RunRequest.policy:type_name -> google.protobuf.Struct
34, // 1: iop.RunRequest.input:type_name -> google.protobuf.Struct
26, // 2: iop.RunRequest.metadata:type_name -> iop.RunRequest.MetadataEntry
37, // 0: iop.RunRequest.policy:type_name -> google.protobuf.Struct
37, // 1: iop.RunRequest.input:type_name -> google.protobuf.Struct
29, // 2: iop.RunRequest.metadata:type_name -> iop.RunRequest.MetadataEntry
0, // 3: iop.RunRequest.session_mode:type_name -> iop.RunSessionMode
6, // 4: iop.RunEvent.usage:type_name -> iop.Usage
27, // 5: iop.RunEvent.metadata:type_name -> iop.RunEvent.MetadataEntry
28, // 6: iop.EdgeNodeEvent.metadata:type_name -> iop.EdgeNodeEvent.MetadataEntry
7, // 4: iop.RunEvent.usage:type_name -> iop.Usage
30, // 5: iop.RunEvent.metadata:type_name -> iop.RunEvent.MetadataEntry
31, // 6: iop.EdgeNodeEvent.metadata:type_name -> iop.EdgeNodeEvent.MetadataEntry
1, // 7: iop.CancelRequest.action:type_name -> iop.CancelAction
2, // 8: iop.NodeCommandRequest.type:type_name -> iop.NodeCommandType
29, // 9: iop.NodeCommandRequest.metadata:type_name -> iop.NodeCommandRequest.MetadataEntry
32, // 9: iop.NodeCommandRequest.metadata:type_name -> iop.NodeCommandRequest.MetadataEntry
2, // 10: iop.NodeCommandResponse.type:type_name -> iop.NodeCommandType
12, // 11: iop.NodeCommandResponse.usage_status:type_name -> iop.AgentUsageStatus
30, // 12: iop.NodeCommandResponse.result:type_name -> iop.NodeCommandResponse.ResultEntry
11, // 13: iop.NodeCommandResponse.provider_snapshots:type_name -> iop.ProviderSnapshot
31, // 14: iop.AgentUsageStatus.metadata:type_name -> iop.AgentUsageStatus.MetadataEntry
16, // 15: iop.RegisterResponse.config:type_name -> iop.NodeConfigPayload
17, // 16: iop.NodeConfigPayload.adapters:type_name -> iop.AdapterConfig
25, // 17: iop.NodeConfigPayload.runtime:type_name -> iop.NodeRuntimeConfig
34, // 18: iop.AdapterConfig.settings:type_name -> google.protobuf.Struct
19, // 19: iop.AdapterConfig.cli:type_name -> iop.CLIAdapterConfig
22, // 20: iop.AdapterConfig.ollama:type_name -> iop.OllamaAdapterConfig
23, // 21: iop.AdapterConfig.vllm:type_name -> iop.VllmAdapterConfig
18, // 22: iop.AdapterConfig.mock:type_name -> iop.MockAdapterConfig
24, // 23: iop.AdapterConfig.openai_compat:type_name -> iop.OpenAICompatAdapterConfig
32, // 24: iop.CLIAdapterConfig.profiles:type_name -> iop.CLIAdapterConfig.ProfilesEntry
21, // 25: iop.CLIProfileConfig.completion_marker:type_name -> iop.CLICompletionMarker
33, // 26: iop.OpenAICompatAdapterConfig.headers:type_name -> iop.OpenAICompatAdapterConfig.HeadersEntry
20, // 27: iop.CLIAdapterConfig.ProfilesEntry.value:type_name -> iop.CLIProfileConfig
28, // [28:28] is the sub-list for method output_type
28, // [28:28] is the sub-list for method input_type
28, // [28:28] is the sub-list for extension type_name
28, // [28:28] is the sub-list for extension extendee
0, // [0:28] is the sub-list for field type_name
13, // 11: iop.NodeCommandResponse.usage_status:type_name -> iop.AgentUsageStatus
33, // 12: iop.NodeCommandResponse.result:type_name -> iop.NodeCommandResponse.ResultEntry
12, // 13: iop.NodeCommandResponse.provider_snapshots:type_name -> iop.ProviderSnapshot
34, // 14: iop.AgentUsageStatus.metadata:type_name -> iop.AgentUsageStatus.MetadataEntry
17, // 15: iop.RegisterResponse.config:type_name -> iop.NodeConfigPayload
18, // 16: iop.NodeConfigPayload.adapters:type_name -> iop.AdapterConfig
26, // 17: iop.NodeConfigPayload.runtime:type_name -> iop.NodeRuntimeConfig
37, // 18: iop.AdapterConfig.settings:type_name -> google.protobuf.Struct
20, // 19: iop.AdapterConfig.cli:type_name -> iop.CLIAdapterConfig
23, // 20: iop.AdapterConfig.ollama:type_name -> iop.OllamaAdapterConfig
24, // 21: iop.AdapterConfig.vllm:type_name -> iop.VllmAdapterConfig
19, // 22: iop.AdapterConfig.mock:type_name -> iop.MockAdapterConfig
25, // 23: iop.AdapterConfig.openai_compat:type_name -> iop.OpenAICompatAdapterConfig
35, // 24: iop.CLIAdapterConfig.profiles:type_name -> iop.CLIAdapterConfig.ProfilesEntry
22, // 25: iop.CLIProfileConfig.completion_marker:type_name -> iop.CLICompletionMarker
36, // 26: iop.OpenAICompatAdapterConfig.headers:type_name -> iop.OpenAICompatAdapterConfig.HeadersEntry
17, // 27: iop.NodeConfigRefreshRequest.config:type_name -> iop.NodeConfigPayload
3, // 28: iop.NodeConfigRefreshResponse.status:type_name -> iop.NodeConfigRefreshStatus
21, // 29: iop.CLIAdapterConfig.ProfilesEntry.value:type_name -> iop.CLIProfileConfig
30, // [30:30] is the sub-list for method output_type
30, // [30:30] is the sub-list for method input_type
30, // [30:30] is the sub-list for extension type_name
30, // [30:30] is the sub-list for extension extendee
0, // [0:30] is the sub-list for field type_name
}
func init() { file_proto_iop_runtime_proto_init() }
@ -2431,8 +2638,8 @@ func file_proto_iop_runtime_proto_init() {
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_iop_runtime_proto_rawDesc), len(file_proto_iop_runtime_proto_rawDesc)),
NumEnums: 3,
NumMessages: 31,
NumEnums: 4,
NumMessages: 33,
NumExtensions: 0,
NumServices: 0,
},

View file

@ -253,3 +253,26 @@ message NodeRuntimeConfig {
int32 concurrency = 1;
string workspace_root = 2;
}
enum NodeConfigRefreshStatus {
NODE_CONFIG_REFRESH_STATUS_UNSPECIFIED = 0;
NODE_CONFIG_REFRESH_STATUS_APPLIED = 1;
NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED = 2;
NODE_CONFIG_REFRESH_STATUS_FAILED = 3;
NODE_CONFIG_REFRESH_STATUS_SKIPPED = 4;
}
// NodeConfigRefreshRequest is sent by edge to a connected node to push a new config payload.
message NodeConfigRefreshRequest {
string request_id = 1;
NodeConfigPayload config = 2;
repeated string changed_paths = 3;
}
// NodeConfigRefreshResponse is returned by the node to acknowledge or report failure.
message NodeConfigRefreshResponse {
string request_id = 1;
NodeConfigRefreshStatus status = 2;
repeated string restart_required_paths = 3;
string error = 4;
}