docs(spec,contract,roadmap,scripts): 계약·스펙·로드맵 문서 갱신 및 e2e 테스트 보완

- edge-node-runtime-wire 계약 스키마 동기화
- edge-node-execution, provider-pool-config-refresh 스펙 갱신
- provider-resource-admission-ownership-alignment 마일스톤 상태 동기화
- control-plane-portal-ops phase 신규 추가 (multi-edge-operations)
- e2e-smoke: 재연결 테스트 플로우, bind timeout, reconnect 설정 반영
This commit is contained in:
toki 2026-07-22 18:11:24 +09:00
parent cc6e71db79
commit 51de0e259e
7 changed files with 158 additions and 22 deletions

View file

@ -19,7 +19,7 @@
| id | 읽는 조건 | 원본 경로 | path |
|----|-----------|-----------|------|
| `iop.edge-node-runtime-wire` | Edge-Node TCP/protobuf, proto-socket transport, `RegisterRequest`, `RegisterResponse`, `RunRequest`, `RunEvent`, `ProviderTunnelRequest`, `ProviderTunnelFrame`, `CancelRequest`, `NodeCommandRequest`, `NodeConfigPayload`, `NodeConfigRefreshRequest` | `proto/iop/runtime.proto`, `apps/edge/internal/transport/*`, `apps/node/internal/transport/*`, `apps/edge/internal/node/mapper.go`, `apps/node/internal/adapters/config_set.go` | `agent-contract/inner/edge-node-runtime-wire.md` |
| `iop.edge-node-runtime-wire` | Edge-Node TCP/protobuf, proto-socket transport, `RegisterRequest`, `RegisterResponse`, `NodeReadyRequest`, `NodeReadyResponse`, `RunRequest`, `RunEvent`, `ProviderTunnelRequest`, `ProviderTunnelFrame`, `CancelRequest`, `NodeCommandRequest`, `NodeConfigPayload`, `NodeConfigRefreshRequest` | `proto/iop/runtime.proto`, `apps/edge/internal/transport/*`, `apps/node/internal/transport/*`, `apps/edge/internal/node/mapper.go`, `apps/node/internal/adapters/config_set.go` | `agent-contract/inner/edge-node-runtime-wire.md` |
| `iop.control-plane-edge-wire` | Control Plane-Edge wire, `EdgeHelloRequest`, `EdgeStatusRequest`, `EdgeStatusResponse`, `EdgeCommandRequest`, `EdgeCommandEvent`, Edge connection registry | `proto/iop/control.proto`, `apps/control-plane/internal/wire/*`, `apps/edge/internal/controlplane/*` | `agent-contract/inner/control-plane-edge-wire.md` |
| `iop.client-control-plane-wire` | Client-Control Plane wire, `/client` WebSocket, proto-socket WS, `ClientHelloRequest`, `ClientHelloResponse`, Flutter client wire | `proto/iop/control.proto`, `apps/control-plane/internal/wire/client.go`, `apps/client/lib/iop_wire/*` | `agent-contract/inner/client-control-plane-wire.md` |
| `iop.edge-config-runtime-refresh` | Edge config schema, `configs/edge.yaml`, `packages/go/config`, provider pool, `models[]`, `nodes[].providers[]`, `openai.model_routes`, config refresh, restart/applied classification | `packages/go/config/config.go`, `configs/edge.yaml`, `apps/edge/internal/configrefresh/*`, `proto/iop/runtime.proto` | `agent-contract/inner/edge-config-runtime-refresh.md` |

View file

@ -19,8 +19,8 @@
## 읽는 조건
- Edge-Node TCP/protobuf transport, register handshake, run stream, provider raw tunnel, cancel, node command, node config refresh를 바꿀 때
- `RunRequest`, `RunEvent`, `ProviderTunnelRequest`, `ProviderTunnelFrame`, `CancelRequest`, `NodeCommandRequest`, `NodeCommandResponse`, `NodeConfigPayload`, `NodeConfigRefresh*` 필드를 바꿀 때
- Edge-Node TCP/protobuf transport, register/dispatch-ready handshake, run stream, provider raw tunnel, cancel, node command, node config refresh를 바꿀 때
- `NodeReadyRequest`, `NodeReadyResponse`, `RunRequest`, `RunEvent`, `ProviderTunnelRequest`, `ProviderTunnelFrame`, `CancelRequest`, `NodeCommandRequest`, `NodeCommandResponse`, `NodeConfigPayload`, `NodeConfigRefresh*` 필드를 바꿀 때
- node adapter 설정 payload나 runtime config가 Edge에서 Node로 전달되는 방식을 바꿀 때
## 범위
@ -31,7 +31,7 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
## 주요 흐름
- register: Node가 `RegisterRequest`를 보내고 Edge가 `RegisterResponse`로 수락 여부와 `NodeConfigPayload`를 돌려준다.
- register와 readiness: Node가 `RegisterRequest`를 보내고 Edge가 `RegisterResponse`로 수락 여부와 `NodeConfigPayload`를 돌려준다. accepted registration은 Node ID의 현재 ownership을 pending으로 claim할 뿐 dispatch 가능 상태가 아니다. Node는 config 적용, adapter start, session handler 설치 뒤 `NodeReadyRequest(node_id)`를 보내고, Edge가 current owner를 dispatch-ready로 전환한 뒤 `NodeReadyResponse`로 ack한다. 이 ready ack 전에는 run, provider tunnel, command, config-refresh push와 connected availability/event가 열리지 않는다.
- execution: Edge가 `RunRequest`를 보내고 Node가 `RunEvent` stream으로 실행 상태를 보낸다.
- provider raw tunnel: Edge가 기존 Edge-Node socket으로 `ProviderTunnelRequest`를 보내고 Node가 provider HTTP/SSE 요청을 연 뒤 `ProviderTunnelFrame` stream으로 provider status/header/body/end/error/usage 후보를 sequence와 함께 돌려준다. 이 경로는 OpenAI-compatible provider passthrough용이며 `RunEvent` 실행 stream과 분리된다.
- provider-pool mixed dispatch: Edge service는 model group provider candidate를 선택한 뒤, 같은 selected provider/queue lease로 OpenAI-compatible provider에는 `ProviderTunnelRequest`, Ollama/CLI/native provider에는 normalized `RunRequest`를 보낸다. Edge-Node wire는 client-provided response path selector를 받지 않고, provider type만으로 후보를 제외하지 않는다.
@ -53,6 +53,8 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
- `RunEvent.metadata["openai_text_tool_fallback"]`: OpenAI-compatible provider adapter가 backend native tool API 거부 후 `tools`/`tool_choice`를 제거하고 text tool-call instruction으로 재시도했을 때 `"true"`를 싣는다. 이 instruction은 backend가 system role 위치를 거부하지 않도록 leading system message에 병합한다. Edge는 이 표시가 있는 실행에서만 assistant content의 text tool-call을 OpenAI-compatible `tool_calls`로 복원할 수 있다.
- `NodeCommandRequest.type`: 실행이 아닌 조회/제어성 명령이다. adapter execution 요청과 섞지 않는다.
- `NodeConfigPayload.adapters`: Edge가 Node에 내려주는 adapter instance 설정이다.
- `NodeReadyRequest.node_id`: `RegisterResponse`가 돌려준 Node identity다. Edge registry의 internal connection generation은 이 wire/config field로 노출하지 않으며, Edge는 `(node_id, current client)` ownership 비교로 stale ready를 거부한다.
- `NodeReadyResponse.ready`: current pending owner의 첫 ready transition과 이미 ready인 같은 owner의 duplicate ready에서 true다. 첫 transition만 provider resource activation, stranded provider-pool waiter pump, `node.connected` event를 만든다. stale/superseded/rejected connection은 false와 reason을 받고 session을 닫아 reconnect해야 한다.
- `AdapterConfig.name`: node 내부 stable adapter instance identity다. 비어 있으면 legacy single-instance type 이름과 동등하다.
- `NodeRuntimeConfig.concurrency`: legacy compatibility runtime metadata다. 실행 admission은 이 값을 node-wide global gate로 사용하지 않고 provider/resource capacity를 기준으로 한다. Node store 위치나 CLI 실행 작업 디렉터리는 이 runtime payload에 싣지 않는다.
- `ProviderSnapshot`: legacy wire name을 유지하지만 Node 아래 resource/provider 상태 snapshot으로 해석한다. `category``api`, `cli`, `local_inference` resource kind를 나타내며, provider-pool dispatch 대상은 Edge config `models[].providers`가 참조한 resource뿐이다. 또한 `long_context_capacity`, `long_in_flight`, `long_queued` 필드는 long-context admission 제어를 위해 사용되며, provider가 확보한 long slot 용량과 현재 점유 중인 long slot 수, 대기 중인 long 요청 수를 나타낸다.
@ -64,12 +66,14 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
- `proto/gen/iop/*.pb.go` 생성물을 직접 수정하지 않는다.
- transport handler에서 console/HTTP/A2A 표면 응답을 직접 만들지 않는다. 표면별 변환은 Edge service/input 계층에 둔다.
- Node address, token, transport internals를 Control Plane status 계약으로 노출하지 않는다.
- accepted registration만으로 Node를 dispatch candidate, connected snapshot/event 또는 config refresh recipient로 취급하지 않는다.
## 변경 시 확인할 코드/테스트
- `proto/iop/runtime.proto`
- `apps/edge/internal/transport/*_test.go`
- `apps/node/internal/transport/*_test.go`
- `apps/edge/internal/bootstrap/reconnect_readiness_integration_test.go`
- `apps/edge/internal/openai/provider_dispatch_test.go`
- `apps/edge/internal/openai/provider_selection_test.go`
- `apps/edge/internal/openai/provider_tunnel_test.go`

View file

@ -0,0 +1,71 @@
# Milestone: Multi-Edge 운영
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
## 목표
여러 Edge group을 연결하고 운영하는 fleet-level 기능을 구축한다.
Control Plane은 Edge를 제어하기 쉽게 연결하는 레이어이며, Edge의 설정과 실질 상태 원본은 Edge가 소유한다.
## 상태
[계획]
## 구현 잠금
- 상태: 해제
- 결정 필요: 없음 (아래 결정 기록)
- [x] Multi-Edge 1차 범위를 observe-only로 둘지, fleet-wide 명령까지 포함할지 결정한다. 결정: 관찰은 Edge 연결/health 확인 수준으로 제한하고, 1차 범위는 multi-edge 운영이 실제 가능하도록 fleet-wide 명령과 제어를 포함한다.
- [x] Control Plane과 Edge 사이의 상태 소유권과 aggregation 깊이를 결정한다. 결정: Edge 설정, Node registry, runtime/automation 상태의 원본은 Edge가 소유한다. Control Plane은 연결된 Edge를 제어하기 위한 연결/health, capability 요약, 명령 요청/결과, audit에 필요한 최소 운영 기록만 가진다. Edge는 다른 Control Plane으로 옮길 수 있어야 하며 Control Plane에 실질 데이터를 묶지 않는다.
- [x] OTO/build-deploy domain agent 상태를 fleet 화면의 1차 범위에 포함할지 결정한다. 결정: OTO/build-deploy는 1차 fleet 운영 capability로 포함한다. 단, Control Plane에는 Edge-owned capability/status/command summary만 노출하고, 실제 artifact/log/state 원본은 Edge 또는 해당 domain agent가 소유한다.
## 범위
- 여러 Edge group 등록/연결/health 표시
- Edge가 제공하는 runtime/automation capability와 운영 가능 상태 요약
- Edge 단위 실행/명령 결과와 event relay
- Edge 단위 작업 실행과 제어
- OTO 같은 domain agent의 build/deploy capability, ready/busy/error 상태, 명령 요청/진행/결과 요약 포함
- fleet-wide 명령과 운영 리포트
## 기능
### Epic: [fleet-ops-boundary] Fleet Operations Boundary
- [ ] [native-fleet] Multi-edge 운영 명령과 이벤트 relay는 IOP native protocol을 기준으로 설계한다.
- [ ] [edge-status-view] Control Plane은 여러 Edge 상태를 구분해 조회하고 표시할 수 있다. 검증: 두 개 이상의 Edge 상태가 구분되는 조회/표시 경로를 확인한다.
- [ ] [history-agent-state] Edge별 실행/명령 결과와 domain agent capability/status/command summary가 운영 화면에서 구분된다.
- [ ] [openai-routing] OpenAI-compatible 표면은 특정 Edge/adapter로 라우팅되는 inference 호환 경로로 제한한다.
- [ ] [a2a-routing] A2A 표면은 특정 Edge/adapter로 위임되는 agent task 경로로 제한한다.
- [ ] [edge-ownership] Edge는 설정, Node registry, 로컬 런타임 상태의 원본 소유권을 유지하고, Control Plane은 이동 가능한 제어 attachment로만 동작한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외
- Control Plane이 매 요청마다 Node를 직접 할당하는 중앙 스케줄러 역할
- Control Plane이 Edge 설정, Node registry, runtime/automation 상태의 실질 원본을 소유하는 구조
- Control Plane이 OTO/build-deploy artifact 저장소, 상세 log, domain-specific lifecycle 원본을 소유하는 구조
- OpenAI-compatible API 또는 A2A API를 multi-edge 운영 제어 기본 프로토콜로 사용
- Edge federation 상세 설계를 근거 없이 선확정
## 작업 컨텍스트
- 관련 경로: `apps/control-plane`, `apps/client`, `apps/edge`, `README.md`
- 표준선(선택): Edge는 설정, 로컬 런타임 상태, Node registry의 원본 소유권을 유지하고, Control Plane은 연결/health 확인을 기반으로 fleet-wide 명령과 제어를 조율한다. Control Plane 교체나 이전은 Edge 실질 데이터 이전을 요구하지 않아야 한다.
- 선행 작업: Control Plane과 Client, 정책/이력/감사
- 후속 작업: 없음
- 결정됨: Multi-Edge 1차 범위는 observe-only가 아니라 fleet-wide 운영 중심으로 둔다. 관찰은 Edge 접속/health 확인 수준으로 제한한다.
- 결정됨: Control Plane은 Edge 실질 데이터를 소유하지 않는 제어 레이어로 둔다. Aggregation은 제어에 필요한 연결/health, capability 요약, 명령 요청/결과, audit record 수준으로 제한한다.
- 결정됨: OTO/build-deploy domain agent는 1차 fleet 운영 capability에 포함한다. Control Plane은 Edge-owned capability/status/command summary만 다루고 artifact/log/state 원본은 소유하지 않는다.

View file

@ -74,11 +74,11 @@ provider config에 선언된 정책과 실제 Edge/Node 적용 위치의 불일
Node transport lifecycle을 provider resource eligibility와 운영 관측의 같은 상태 전이로 연결한다.
- [ ] [disconnect-exclusion] 등록된 client ownership/generation을 provider lease와 연결하고, 현재 owner의 정상 close 또는 disconnect만 해당 Node의 모든 provider resource를 즉시 offline/excluded로 fencing한다. stale callback이나 거절된 duplicate connection의 close는 live resource 상태와 disconnect event를 바꾸지 않는다. 정상적인 Node 프로세스 종료와 Windows 종료 절차는 heartbeat timeout을 기다리지 않으며, close를 받을 수 없는 전원·네트워크 장애만 설정된 heartbeat timeout 안에 제외한다. 검증: disconnect·reconnect·duplicate registration·admission 경쟁에서 offline generation의 새 lease/dispatch handoff가 없고 기존 lease는 정확히 한 번 반환된다.
- [ ] [disconnect-queue-resolution] authoritative disconnect 전이가 모든 관련 model queue의 live candidate를 즉시 다시 해석해 남은 provider로 fallback하고, 후보가 없어진 요청은 `queue_timeout_ms`까지 남겨 두지 않고 명시적인 unavailable 결과로 종료한다. 이 correctness 경로는 관측 event delivery 성공 여부에 의존하지 않는다. 검증: capacity 1 provider에 실행·대기 요청이 있는 상태에서 연결을 닫거나 node event subscriber를 포화시켜도 대기 요청이 즉시 fallback 또는 terminal 상태가 되며 queue와 reservation이 남지 않는다.
- [ ] [offline-snapshot] 구성에 존재하지만 연결이 끊긴 Node/provider를 운영 snapshot에 `connected=false`, `status=unavailable`, `health=offline`, effective capacity/counter 0으로 유지하고, reconnect 때 같은 resource identity의 새 generation으로 available 상태와 admission eligibility를 복구해 이전 generation의 orphan/excluded 상태가 새 generation 후보를 계속 막지 않게 한다. 검증: disconnect/reconnect 전후 status snapshot, admission candidate와 in-flight/long counter가 같은 connectivity generation에 수렴하며 model catalog entry 자체는 삭제되지 않는다.
- [ ] [reconnect-candidate-recovery] accepted Node reconnect가 같은 resource identity의 새 generation을 live candidate로 복구하고, 아직 대기 중인 모든 관련 provider-pool 요청의 후보군을 live config/registry에서 다시 구성해 새 요청, config refresh 또는 다른 lease 반환을 기다리지 않고 전역 queue를 즉시 pump한다. 검증: 다른 live 후보가 full이라 대기를 유지한 요청이 별도 외부 trigger 없이 provider reconnect만으로 새 generation 후보를 얻어 즉시 dispatch되고, stale/rejected connection 전이는 후보 복구나 wake-up을 일으키지 않는다.
- [ ] [node-connectivity-supervision] Node daemon이 최초 dial/register 전부터 connectivity supervisor를 실행해 retryable한 initial connect 실패와 연결 수립 뒤 disconnect를 같은 reconnect policy로 처리한다. 명시적인 `reconnect.max_attempts=0`은 unlimited, 설정 생략은 기존 기본값 `10`, 양수는 기존 호환 유한 limit, 음수는 config validation error로 정의한다. unlimited profile에는 생략 시 기본값 `10`이 적용되는 양수 `interval_sec`를 요구해 hot loop와 동시 dial을 막고, 네트워크나 Edge가 오래 unavailable이어도 local shutdown까지 한 번에 하나의 연결 시도만 bounded cadence로 재시도한다. 유한 retry exhaustion과 non-retryable local config/credential 오류는 명시적인 non-zero terminal 결과로 종료한다. OS 시작 배치나 service는 process 실행만 담당하며 연결 복구 correctness는 Node가 소유한다. 검증: 명시적 `max_attempts=0` Node를 네트워크/Edge보다 먼저 시작하고 기존 bounded retry 구간을 넘긴 뒤 Edge를 열어도 같은 process가 정확히 한 번 등록되고, `max_attempts` 생략은 10으로 유지되며, 양수 limit은 정확한 횟수 뒤 종료하고, 음수 max 또는 unlimited profile의 0 이하 interval은 거부되며, 정상 종료는 대기 중 retry를 즉시 중단한다.
- [x] [disconnect-exclusion] 등록된 client ownership/generation을 provider lease와 연결하고, 현재 owner의 정상 close 또는 disconnect만 해당 Node의 모든 provider resource를 즉시 offline/excluded로 fencing한다. stale callback이나 거절된 duplicate connection의 close는 live resource 상태와 disconnect event를 바꾸지 않는다. 정상적인 Node 프로세스 종료와 Windows 종료 절차는 heartbeat timeout을 기다리지 않으며, close를 받을 수 없는 전원·네트워크 장애만 설정된 heartbeat timeout 안에 제외한다. 검증: disconnect·reconnect·duplicate registration·admission 경쟁에서 offline generation의 새 lease/dispatch handoff가 없고 기존 lease는 정확히 한 번 반환된다.
- [x] [disconnect-queue-resolution] authoritative disconnect 전이가 모든 관련 model queue의 live candidate를 즉시 다시 해석해 남은 provider로 fallback하고, 후보가 없어진 요청은 `queue_timeout_ms`까지 남겨 두지 않고 명시적인 unavailable 결과로 종료한다. 이 correctness 경로는 관측 event delivery 성공 여부에 의존하지 않는다. 검증: capacity 1 provider에 실행·대기 요청이 있는 상태에서 연결을 닫거나 node event subscriber를 포화시켜도 대기 요청이 즉시 fallback 또는 terminal 상태가 되며 queue와 reservation이 남지 않는다.
- [x] [offline-snapshot] 구성에 존재하지만 연결이 끊긴 Node/provider를 운영 snapshot에 `connected=false`, `status=unavailable`, `health=offline`, effective capacity/counter 0으로 유지하고, reconnect 때 같은 resource identity의 새 generation으로 available 상태와 admission eligibility를 복구해 이전 generation의 orphan/excluded 상태가 새 generation 후보를 계속 막지 않게 한다. 검증: disconnect/reconnect 전후 status snapshot, admission candidate와 in-flight/long counter가 같은 connectivity generation에 수렴하며 model catalog entry 자체는 삭제되지 않는다.
- [x] [reconnect-candidate-recovery] accepted Node reconnect가 같은 resource identity의 새 generation을 live candidate로 복구하고, 아직 대기 중인 모든 관련 provider-pool 요청의 후보군을 live config/registry에서 다시 구성해 새 요청, config refresh 또는 다른 lease 반환을 기다리지 않고 전역 queue를 즉시 pump한다. 검증: 다른 live 후보가 full이라 대기를 유지한 요청이 별도 외부 trigger 없이 provider reconnect만으로 새 generation 후보를 얻어 즉시 dispatch되고, stale/rejected connection 전이는 후보 복구나 wake-up을 일으키지 않는다.
- [x] [node-connectivity-supervision] Node daemon이 최초 dial/register 전부터 connectivity supervisor를 실행해 retryable한 initial connect 실패와 연결 수립 뒤 disconnect를 같은 reconnect policy로 처리한다. 명시적인 `reconnect.max_attempts=0`은 unlimited, 설정 생략은 기존 기본값 `10`, 양수는 기존 호환 유한 limit, 음수는 config validation error로 정의한다. unlimited profile에는 생략 시 기본값 `10`이 적용되는 양수 `interval_sec`를 요구해 hot loop와 동시 dial을 막고, 네트워크나 Edge가 오래 unavailable이어도 local shutdown까지 한 번에 하나의 연결 시도만 bounded cadence로 재시도한다. 유한 retry exhaustion과 non-retryable local config/credential 오류는 명시적인 non-zero terminal 결과로 종료한다. OS 시작 배치나 service는 process 실행만 담당하며 연결 복구 correctness는 Node가 소유한다. 검증: 명시적 `max_attempts=0` Node를 네트워크/Edge보다 먼저 시작하고 기존 bounded retry 구간을 넘긴 뒤 Edge를 열어도 같은 process가 정확히 한 번 등록되고, `max_attempts` 생략은 10으로 유지되며, 양수 limit은 정확한 횟수 뒤 종료하고, 음수 max 또는 unlimited profile의 0 이하 interval은 거부되며, 정상 종료는 대기 중 retry를 즉시 중단한다.
### Epic: [ownership-verification] 계약과 회귀 검증
@ -92,8 +92,8 @@ Node transport lifecycle을 provider resource eligibility와 운영 관측의
- 상태: 없음
- 요청일: 없음
- 완료 근거: SDD와 선행 리팩터링 gate가 충족되었고, provider 전역 resource lease와 provider policy 의미 정렬의 기능 Task 8개가 archive `complete.log`, 현재 코드·테스트, git 이력으로 확인되었다.
- 검토 항목: Node 연결 기반 provider availability 5개 Task, 계약·회귀·capacity smoke 3개 Task와 연결된 Acceptance Scenario evidence를 확인한다.
- 완료 근거: SDD와 선행 리팩터링 gate가 충족되었고, provider 전역 resource lease, provider policy, Node 연결 기반 provider availability의 기능 Task 13개가 archive `complete.log`, 현재 코드·테스트, git 이력으로 확인되었다.
- 검토 항목: `cross-model-tests`의 명시적 S09/Roadmap Completion evidence, `contract-spec-sync`의 네 계약·관련 living spec 정합화, live `capacity-smoke`의 backend peak concurrency 1과 counter 0 회복 evidence를 확인한다.
- agent-ui 상태 반영: 해당 없음
- 리뷰 코멘트: 없음

View file

@ -51,6 +51,9 @@ source_evidence:
- type: test
path: apps/node/internal/adapters/vllm/vllm_test.go
notes: vLLM usage breakdown 검증
- type: test
path: apps/edge/internal/bootstrap/reconnect_readiness_integration_test.go
notes: 실제 iop-node reconnect 뒤 queued waiter의 ready-gated dispatch와 terminal/counter 수렴 검증
---
# 스펙: Edge-Node 실행 경로
@ -63,7 +66,7 @@ Edge와 Node 사이에 현재 구현된 실행 기능을 기능 단위로 정리
| 기능 | 설명 |
|------|------|
| Node token 등록 | Node가 Edge TCP proto-socket endpoint에 연결한 뒤 `RegisterRequest.token`으로 등록한다. |
| Node token 등록과 dispatch-ready | Node가 `RegisterRequest.token`으로 ownership/config를 받고, config 적용·adapter start·handler 설치 뒤 `NodeReadyRequest`/ack로 dispatch-ready가 된다. |
| Node config payload 전달 | Edge가 token에 매칭되는 node record를 찾아 `NodeConfigPayload``RegisterResponse`에 담아 내려준다. |
| 등록 실패 처리 | unknown token, duplicate connection, config payload build failure를 register response와 node lifecycle event로 표현한다. |
| 실행 요청 전달 | Edge service가 `SubmitRun` 요청을 `RunRequest`로 만들어 선택된 Node에 보낸다. 명시 node가 없고 연결 node가 1개면 single-node fallback을 사용한다. |
@ -98,9 +101,13 @@ sequenceDiagram
Node->>EdgeTransport: RegisterRequest(token)
EdgeTransport->>NodeStore: token으로 NodeRecord 조회
alt token valid
EdgeTransport->>EdgeTransport: NodeConfigPayload 생성
EdgeTransport->>EdgeTransport: NodeConfigPayload 생성, pending ownership claim
EdgeTransport-->>Node: RegisterResponse(accepted=true, config)
EdgeTransport->>EdgeTransport: live registry 등록
Node->>Node: config 적용, adapter start, session handler 설치
Node->>EdgeTransport: NodeReadyRequest(node_id)
EdgeTransport->>EdgeTransport: current owner를 dispatch-ready로 전환
EdgeTransport->>EdgeTransport: provider availability 활성화, queued waiter pump, connected event
EdgeTransport-->>Node: NodeReadyResponse(ready=true)
else token invalid or duplicate
EdgeTransport-->>Node: RegisterResponse(accepted=false, reason)
end
@ -169,6 +176,8 @@ sequenceDiagram
## 설정/데이터/이벤트
- Edge의 node source of truth는 `configs/edge.yaml``packages/go/config``nodes[]` 구조다.
- accepted registration은 duplicate ownership claim과 config 전달만 담당한다. ready ack 전 Node는 direct/provider-pool dispatch, provider tunnel/command, config refresh push, connected snapshot/event에서 제외된다.
- Edge registry의 connection generation은 internal fence이며 wire/config로 노출하지 않는다. current client의 첫 ready만 provider resource activation과 queue pump를 수행하고, duplicate ready는 idempotent ack, stale/rejected ready는 reject로 처리한다.
- `RunEvent`는 adapter execution stream이고, `EdgeNodeEvent`는 node lifecycle/control event다.
- `ProviderTunnelFrame.body`는 OpenAI-compatible provider passthrough의 source of truth이며 `RunEvent.delta`나 Edge event bus payload로 보내지 않는다.
- `ProviderTunnelFrame.usage``metadata`는 관측 후보이며 pure passthrough body에 합쳐지지 않는다.
@ -182,6 +191,7 @@ sequenceDiagram
- `go test ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/node`
- `go test ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/router ./apps/node/internal/adapters ./apps/node/internal/store`
- `go test ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`
- `go test ./apps/edge/internal/bootstrap -run '^TestActualNodeReconnectReadyPumpsQueuedWaiterExactlyOnce$'` - 실제 `iop-node` 재연결 뒤 기존 provider-pool waiter의 dispatch 1회, terminal 1회, counter 0 수렴을 확인한다.
- `make test-e2e` - Edge-Node와 OpenAI 보조 smoke를 함께 실행한다. runtime path 변경 시 사용자 흐름 검증을 대체하지 않는다.
## 한계와 주의사항
@ -200,3 +210,4 @@ sequenceDiagram
- 2026-07-10: `RunEvent.usage`/`ProviderTunnelFrame.usage`의 input/output/reasoning/cached input breakdown 전달 기준을 반영.
- 2026-07-12: Model Group Mixed Provider Dispatch 종료 검토 기준으로 selected provider capability에서 파생되는 `ProviderTunnelRequest`/`RunRequest` 분기 경계를 반영.
- 2026-07-18: 저장소 구조 분해 뒤 Edge run/tunnel, Node handler, adapter split test의 `source_evidence`를 현재 경로로 동기화.
- 2026-07-22: accepted registration을 pending ownership/config 단계로 제한하고, handler 설치 뒤 `NodeReadyRequest`/ack로 dispatch eligibility와 reconnect waiter pump를 여는 순서를 반영.

View file

@ -48,6 +48,9 @@ source_evidence:
- type: test
path: apps/edge/internal/configrefresh/provider_classify_test.go
notes: provider field refresh classification 검증
- type: test
path: apps/edge/internal/bootstrap/reconnect_readiness_integration_test.go
notes: dispatch-ready reconnect가 기존 queued waiter를 실제 Node terminal까지 수렴시키는 검증
---
# 스펙: Provider Pool과 Config Refresh
@ -64,14 +67,14 @@ Edge 설정에서 provider-pool이 어떻게 모델 실행 후보를 고르고,
| provider mapping | `models[].providers`는 provider id를 실제 served model name으로 매핑한다. |
| node provider catalog | `nodes[].providers[]`는 Node 아래 resource/provider catalog이며 provider id는 Edge config에서 전역 유일해야 한다. |
| config validation | config load가 provider id 참조, served model membership, numeric bounds, long-context budget을 검증한다. |
| provider 후보 필터링 | dispatch는 connected node의 provider 후보 중 catalog match, enabled, healthy/available, capacity 조건을 만족하는 후보만 사용한다. |
| provider 후보 필터링 | dispatch는 dispatch-ready connection을 가진 Node의 provider 후보 중 catalog match, enabled, healthy/available, capacity 조건을 만족하는 후보만 사용한다. |
| capacity/priority dispatch | in-flight가 capacity 미만인 후보를 고르고, 동률이면 낮은 `priority`와 round-robin을 적용한다. |
| mixed provider execution path | 같은 model group의 OpenAI-compatible provider와 Ollama/CLI/native provider를 같은 후보군으로 두며, 선택된 provider capability로 passthrough 또는 normalized 실행 경로를 결정한다. |
| long-context admission | estimated input token이 threshold 이상이면 `context_class=long`으로 분류하고, provider long slot이 있으면 일반 capacity slot과 함께 점유한다. |
| config refresh dry-run/apply | loopback admin HTTP `POST /refresh`가 candidate config를 dry-run 또는 apply한다. |
| refresh classification | listener, Edge identity, bootstrap path, adapter structural 변경 등은 restart-required로 분류한다. |
| mutable apply | 적용 가능한 변경은 Edge `Cfg`, `NodeStore`, service/input model catalog, OpenAI long-context threshold를 copy-on-write로 교체한다. |
| Node config refresh push | 변경이 있으면 Edge가 연결된 Node에 node-specific `NodeConfigRefreshRequest`를 push한다. |
| Node config refresh push | 변경이 있으면 Edge가 dispatch-ready Node에 node-specific `NodeConfigRefreshRequest`를 push한다. accepted지만 pending인 Node는 register response config를 적용한 뒤 ready가 될 때까지 push 대상이 아니다. |
| Node registry swap | Node는 refresh payload로 새 adapter registry를 만들고 router registry를 swap한다. old registry stop은 active run이 있으면 drain 이후로 지연한다. |
| principal token mapping config | `openai.principal_tokens[]`는 raw token 없이 `token_ref`, `token_hash_sha256`, `principal_ref`, optional alias를 관리하고 OpenAI usage metering의 principal/token label 후보를 제공한다. 같은 principal에 여러 token entry를 둘 수 있다. |
| provider auth forwarding config | `openai.provider_auth`는 caller가 요청 header로 제공한 raw provider token을 selected OpenAI-compatible provider tunnel header로 전달하는 규칙만 저장한다. |
@ -92,7 +95,7 @@ sequenceDiagram
participant Node
Caller->>Service: SubmitRun(model)
Service->>Queue: provider 후보 선택(capacity + priority)
Service->>Queue: dispatch-ready provider 후보 선택(capacity + priority)
Queue-->>Service: selected provider + served target
alt selected provider supports OpenAI-compatible call
Service->>Node: ProviderTunnelRequest(adapter, served target)
@ -117,6 +120,7 @@ sequenceDiagram
- `long_context_threshold_tokens` 기본 예시는 `100000`이고 0 이하 값은 config load에서 거부된다.
- provider `enabled=false`는 dispatch pool에서 제외하지만 adapter process lifecycle 변경을 의미하지 않는다.
- accepted registration은 provider candidate를 바로 복구하지 않는다. Node가 config 적용과 handler 설치 뒤 ready ack를 받아야 해당 generation이 candidate, connected snapshot, refresh push 대상이 되며 이 transition이 stranded provider-pool waiter를 재평가한다.
- provider capacity, priority, max queue, queue timeout, enabled toggle, model generation policy는 live apply 대상으로 분류된다.
- Edge listener, control plane, openai/a2a listener, bootstrap artifact path, node 추가/삭제, node token/alias, adapter 설정 변경은 restart-required 대상이다.
- `openai.principal_tokens[]``token_ref``token_hash_sha256` 중복을 거부하고, raw token 원문은 tracked config에 저장하지 않는다.
@ -134,6 +138,7 @@ sequenceDiagram
- `go test ./apps/edge/internal/service`
- `go test ./apps/edge/internal/node`
- `go test ./apps/node/internal/adapters ./apps/node/internal/node`
- `go test ./apps/edge/internal/bootstrap -run '^TestActualNodeReconnectReadyPumpsQueuedWaiterExactlyOnce$'`
## 한계와 주의사항
@ -154,3 +159,4 @@ sequenceDiagram
- 2026-07-11: Seulgivibe OpenAI-compatible provider aliases, provider auth forwarding config, static catalog 기준을 현재 코드/계약/테스트 기준으로 반영.
- 2026-07-12: Model Group Mixed Provider Dispatch 종료 검토 기준으로 mixed provider group과 Ollama-only group, capacity/priority 가중 예시를 반영.
- 2026-07-18: 저장소 구조 분해 뒤 config, provider resolution/admission, split test의 `source_evidence`를 현재 경로로 동기화.
- 2026-07-22: pending accepted connection과 dispatch-ready connection을 구분하고, ready ack 뒤에만 provider candidate 복구·refresh push·queued waiter pump가 일어나는 현재 동작을 반영.

View file

@ -60,12 +60,18 @@ EVENT_TIMEOUT="${IOP_E2E_EVENT_TIMEOUT:-30}"
RUN_TIMEOUT="${IOP_E2E_RUN_TIMEOUT:-120}"
STATUS_TIMEOUT="${IOP_E2E_STATUS_TIMEOUT:-$RUN_TIMEOUT}"
COMMAND_SETTLE_SECONDS="${IOP_E2E_COMMAND_SETTLE_SECONDS:-0.2}"
EDGE_BIND_TIMEOUT="${IOP_E2E_BIND_TIMEOUT:-10}"
IS_PERSISTENT=0
IS_APP_SERVER=0
HAS_STATUS=0
EXPECT_TAIL_MESSAGES=0
PROMPT_TEMPLATE_FILE="${IOP_E2E_PROMPT_TEMPLATES:-$REPO_ROOT/scripts/fixtures/user-e2e-prompts.tsv}"
if ! [[ "$EDGE_BIND_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then
echo "[e2e] IOP_E2E_BIND_TIMEOUT must be a positive integer, got: $EDGE_BIND_TIMEOUT" >&2
exit 1
fi
prompt_template_count() {
awk -F'|' 'NF >= 3 && $1 !~ /^#/ { n++ } END { print n + 0 }' "$PROMPT_TEMPLATE_FILE"
}
@ -105,10 +111,14 @@ EOF_PROMPT
IFS=$'\t' read -r BACKGROUND_TEMPLATE BACKGROUND_EXPECTED BACKGROUND_PROMPT <<EOF_PROMPT
$(select_prompt_template 2 "$PROMPT_TEMPLATE_TOTAL")
EOF_PROMPT
IFS=$'\t' read -r FOURTH_TEMPLATE FOURTH_EXPECTED FOURTH_PROMPT <<EOF_PROMPT
$(select_prompt_template 3 "$PROMPT_TEMPLATE_TOTAL")
EOF_PROMPT
FIRST_TAIL_EXPECTED="${FIRST_EXPECTED}_TAIL"
SECOND_TAIL_EXPECTED="${SECOND_EXPECTED}_TAIL"
BACKGROUND_TAIL_EXPECTED="${BACKGROUND_EXPECTED}_TAIL"
echo "[e2e] prompt templates: first=$FIRST_TEMPLATE second=$SECOND_TEMPLATE background=$BACKGROUND_TEMPLATE base=$PROMPT_TEMPLATE_BASE"
FOURTH_TAIL_EXPECTED="${FOURTH_EXPECTED}_TAIL"
echo "[e2e] prompt templates: first=$FIRST_TEMPLATE second=$SECOND_TEMPLATE background=$BACKGROUND_TEMPLATE fourth=$FOURTH_TEMPLATE base=$PROMPT_TEMPLATE_BASE"
if [ "$PROFILE" = "mock" ]; then
echo "[e2e] preparing honest mock smoke test (using scripted cli adapter)..."
@ -225,7 +235,9 @@ cat <<EOF > "$NODE_CONFIG"
transport:
edge_addr: "127.0.0.1:$PORT"
token: test-token
reconnect_interval: 1s
reconnect:
interval_sec: 1
max_attempts: 0
metrics:
port: $NODE_METRICS_PORT
node:
@ -244,7 +256,7 @@ IOP_EDGE_CONFIG="$EDGE_CONFIG" "$REPO_ROOT/scripts/dev/edge.sh" < "$TMP_DIR/edge
EDGE_PID=$!
exec 3> "$TMP_DIR/edge_fifo"
deadline=$((SECONDS + 10))
deadline=$((SECONDS + EDGE_BIND_TIMEOUT))
while ! timeout 1 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/'"$PORT" 2>/dev/null; do
if (( SECONDS >= deadline )); then
echo "[e2e] edge failed to bind port $PORT"
@ -404,6 +416,28 @@ if [ "$HAS_STATUS" -eq 1 ]; then
wait_for_edge_text_since "[node0-status]" "$LAST_CMD_START_LINE" "/status output" "$STATUS_TIMEOUT"
fi
if [ "${IOP_E2E_RECONNECT:-0}" -eq 1 ]; then
echo "[e2e] reconnect test enabled, stopping node..."
# Update start line baseline before stopping node to ignore prior registration
LAST_CMD_START_LINE=$(($(wc -l < "$EDGE_OUT" | tr -d ' ') + 1))
kill_process_tree "$NODE_PID"
sleep 2
echo "[e2e] restarting node..."
echo "=== NODE RESTARTED ===" >> "$NODE_OUT"
IOP_NODE_CONFIG="$NODE_CONFIG" "$REPO_ROOT/scripts/dev/node.sh" >> "$NODE_OUT" 2>&1 &
NODE_PID=$!
wait_for_edge_pattern_since '\[node0-evt\] connected reason="registered"' "$LAST_CMD_START_LINE" "node re-registration"
echo "[e2e] sending 4th command after reconnect..."
send_cmd "$FOURTH_PROMPT"
wait_for_edge_pattern_since "\\[node0-evt\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 4 start" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node0-msg\\].*${FOURTH_EXPECTED}" "$LAST_CMD_START_LINE" "foreground run 4 node message" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node0-evt\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 4 completion" "$RUN_TIMEOUT"
fi
IDLE_FAIL=0
IDLE_MARKER_HITS=""
if [ "$IDLE_SECONDS" -gt 0 ]; then
@ -710,17 +744,27 @@ check_node_messages_relayed_to_edge() {
check_grep "test-node" "$EDGE_OUT" "node registration not found"
check_grep "start run_id=" "$EDGE_OUT" "run start not found"
check_grep "complete run_id=" "$EDGE_OUT" "run completion not found"
if [ "$(grep -c "\\[node0-evt\\] complete run_id=" "$EDGE_OUT" || true)" -lt 3 ]; then
echo "[e2e] FAIL: expected at least 3 completed runs (foreground x2 + background x1)"
EXPECTED_RUNS=3
if [ "${IOP_E2E_RECONNECT:-0}" -eq 1 ]; then
EXPECTED_RUNS=4
fi
if [ "$(grep -c "\\[node0-evt\\] complete run_id=" "$EDGE_OUT" || true)" -lt "$EXPECTED_RUNS" ]; then
echo "[e2e] FAIL: expected at least $EXPECTED_RUNS completed runs"
FAIL=1
fi
check_grep "\\[node0-msg\\].*${FIRST_EXPECTED}" "$EDGE_OUT" "foreground run 1 node message not found"
check_grep "\\[node0-msg\\].*${SECOND_EXPECTED}" "$EDGE_OUT" "foreground run 2 node message not found"
check_grep "\\[node0-msg\\].*${BACKGROUND_EXPECTED}" "$EDGE_OUT" "background run node message not found"
if [ "${IOP_E2E_RECONNECT:-0}" -eq 1 ]; then
check_grep "\\[node0-msg\\].*${FOURTH_EXPECTED}" "$EDGE_OUT" "foreground run 4 node message not found"
fi
if [ "$EXPECT_TAIL_MESSAGES" -eq 1 ]; then
check_grep "\\[node0-msg\\].*${FIRST_TAIL_EXPECTED}" "$EDGE_OUT" "foreground run 1 tail node message not found"
check_grep "\\[node0-msg\\].*${SECOND_TAIL_EXPECTED}" "$EDGE_OUT" "foreground run 2 tail node message not found"
check_grep "\\[node0-msg\\].*${BACKGROUND_TAIL_EXPECTED}" "$EDGE_OUT" "background run tail node message not found"
if [ "${IOP_E2E_RECONNECT:-0}" -eq 1 ]; then
check_grep "\\[node0-msg\\].*${FOURTH_TAIL_EXPECTED}" "$EDGE_OUT" "foreground run 4 tail node message not found"
fi
fi
check_no_grep "\\[node0-msg\\] <empty>" "$EDGE_OUT" "empty node message found"
check_node_messages_relayed_to_edge