diff --git a/agent-contract/index.md b/agent-contract/index.md index daf130d..1425e6e 100644 --- a/agent-contract/index.md +++ b/agent-contract/index.md @@ -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` | diff --git a/agent-contract/inner/edge-node-runtime-wire.md b/agent-contract/inner/edge-node-runtime-wire.md index 8c1401b..46d0e1a 100644 --- a/agent-contract/inner/edge-node-runtime-wire.md +++ b/agent-contract/inner/edge-node-runtime-wire.md @@ -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` diff --git a/agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md b/agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md new file mode 100644 index 0000000..287ca2b --- /dev/null +++ b/agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md @@ -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 원본은 소유하지 않는다. diff --git a/agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md b/agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md index 482c5c9..a0b7c42 100644 --- a/agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md +++ b/agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md @@ -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 상태 반영: 해당 없음 - 리뷰 코멘트: 없음 diff --git a/agent-spec/runtime/edge-node-execution.md b/agent-spec/runtime/edge-node-execution.md index b4012a8..51fd2c9 100644 --- a/agent-spec/runtime/edge-node-execution.md +++ b/agent-spec/runtime/edge-node-execution.md @@ -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를 여는 순서를 반영. diff --git a/agent-spec/runtime/provider-pool-config-refresh.md b/agent-spec/runtime/provider-pool-config-refresh.md index 3b8b298..2dbc42c 100644 --- a/agent-spec/runtime/provider-pool-config-refresh.md +++ b/agent-spec/runtime/provider-pool-config-refresh.md @@ -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가 일어나는 현재 동작을 반영. diff --git a/scripts/e2e-smoke.sh b/scripts/e2e-smoke.sh index 4dc209c..83e3e60 100755 --- a/scripts/e2e-smoke.sh +++ b/scripts/e2e-smoke.sh @@ -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 < "$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\\] " "$EDGE_OUT" "empty node message found" check_node_messages_relayed_to_edge