feat: provider-resource-admission-ownership alignment
- Archive provider-resource-admission-ownership milestone/SDD - Align contract: CP-edge wire, runtime refresh, node runtime, OpenAI surface - Update roadmap: phase state, priority queue - Update specs: control-plane ops, OpenAI surface, edge execution, provider pool refresh - Add node runtime supervisor bootstrapping and unit tests - Fix control-plane edge registry handler and http_views - Fix edge model queue admission and long context queue tests
This commit is contained in:
parent
51de0e259e
commit
0ffcb88db0
27 changed files with 1662 additions and 85 deletions
|
|
@ -12,14 +12,14 @@
|
|||
|
||||
| id | 읽는 조건 | 원본 경로 | path |
|
||||
|----|-----------|-----------|------|
|
||||
| `iop.openai-compatible-api` | OpenAI-compatible API, Responses API, Chat Completions, legacy Completions, `model` route, model-driven passthrough/normalized routing, Codex/CLI workspace, generic authoring metadata, `metadata.workspace`, `metadata.task_id`, provider-native OpenAI-compatible extension fields such as `chat_template_kwargs` | `apps/edge/internal/openai/*`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/openai-compatible-api.md` |
|
||||
| `iop.openai-compatible-api` | OpenAI-compatible API, Responses API, Chat Completions, legacy Completions, `model` route, model-driven passthrough/normalized routing, provider-pool admission/unavailable error, Codex/CLI workspace, generic authoring metadata, `metadata.workspace`, `metadata.task_id`, provider-native OpenAI-compatible extension fields such as `chat_template_kwargs` | `apps/edge/internal/openai/*`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/openai-compatible-api.md` |
|
||||
| `iop.a2a-json-rpc-api` | A2A JSON-RPC API, `message/send`, `tasks/get`, `tasks/cancel`, A2A task state, agent card, `a2a.bearer_token`, Edge A2A input surface | `apps/edge/internal/input/a2a/*`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/a2a-json-rpc-api.md` |
|
||||
|
||||
## Inner Contracts
|
||||
|
||||
| id | 읽는 조건 | 원본 경로 | path |
|
||||
|----|-----------|-----------|------|
|
||||
| `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.edge-node-runtime-wire` | Edge-Node TCP/protobuf, proto-socket transport, Node initial/reconnect supervision, connection generation fencing, `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/node/internal/bootstrap/runtime_supervisor.go`, `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, configured offline Node/provider snapshot | `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` |
|
||||
| `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/edge_types.go`, `packages/go/config/provider_types.go`, `packages/go/config/load.go`, `configs/edge.yaml`, `apps/edge/internal/configrefresh/*`, `proto/iop/runtime.proto` | `agent-contract/inner/edge-config-runtime-refresh.md` |
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@ Control Plane은 Edge 연결 view와 제어 요청/결과를 관리하고, Edge
|
|||
|
||||
- `EdgeHelloRequest.edge_id`: Control Plane connection registry의 Edge identity다. 비어 있으면 hello는 거부된다.
|
||||
- `EdgeStatusResponse.nodes`: Edge가 소유한 node snapshot view다.
|
||||
- `EdgeNodeSnapshot.connected`: accepted registration 여부가 아니라 Edge registry의 current dispatch-ready ownership을 뜻한다. configured Node가 initial connect 전이거나 disconnect/pending 상태여도 snapshot에서 사라지지 않고 `connected=false`로 남는다.
|
||||
- `EdgeNodeSnapshot.config`: Node에 내려간 config payload의 관찰용 요약이다.
|
||||
- `EdgeNodeSnapshot.provider_snapshots`: runtime `ProviderSnapshot` wire name을 재사용한 Node resource/provider snapshot이다. `category`가 CLI/API/local inference resource kind를 구분하며, Node address, token, transport internals는 싣지 않는다. `long_context_capacity`, `long_in_flight`, `long_queued` 필드를 포함해 long-context admission 상태를 함께 전달한다.
|
||||
- `EdgeNodeSnapshot.provider_snapshots`: runtime `ProviderSnapshot` wire name을 재사용한 Node resource/provider snapshot이다. `category`가 CLI/API/local inference resource kind를 구분하며, Node address, token, transport internals는 싣지 않는다. online provider의 일반·long in-flight는 Edge provider lease state와 같고 queued 값은 Edge queue의 candidate pressure다. configured offline provider는 catalog identity를 유지한 채 `status=unavailable`, `health=offline`, capacity/in-flight/queued/long-context 관련 수치를 0으로 보고한다. reconnect ready 뒤에는 configured capacity와 admission eligibility가 함께 복구된다.
|
||||
- `EdgeNodeEvent`: current owner의 authoritative ready/disconnect 전이가 완료된 뒤 관측용으로 relay된다. rejected duplicate나 stale connection close는 live Node의 disconnect event를 만들지 않으며, provider cleanup correctness는 event delivery 성공에 의존하지 않는다.
|
||||
- `EdgeCommandRequest.operation`: Edge-owned operation 이름이다. Node 직접 scheduling 명령으로 사용하지 않는다.
|
||||
- `EdgeCommandRequest.target_selector`: Edge 내부 operation이 해석할 대상 selector다. Node address나 token을 외부화하지 않는다.
|
||||
- `metadata`: 필요한 Edge identity 또는 운영 보조 정보만 담는다. secret과 private endpoint 원문은 tracked 계약에 쓰지 않는다.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
- boundary: `inner`
|
||||
- status: active
|
||||
- 원본 경로:
|
||||
- `packages/go/config/config.go`
|
||||
- `packages/go/config/edge_types.go`
|
||||
- `packages/go/config/provider_types.go`
|
||||
- `packages/go/config/load.go`
|
||||
- `configs/edge.yaml`
|
||||
- `apps/edge/internal/configrefresh/request.go`
|
||||
- `apps/edge/internal/configrefresh/result.go`
|
||||
|
|
@ -34,6 +36,8 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c
|
|||
- `openai` deep diff는 restart-required로 분류한다. `openai.principal_tokens[]` 변경은 credential/hash 변경으로 restart-required classifier에 포함된다.
|
||||
- `openai.model_routes[]`는 외부 OpenAI-compatible `model` id를 내부 `adapter + target` route로 매핑하는 compatibility catalog다.
|
||||
- `long_context_threshold_tokens`는 Edge root의 입력 토큰 추정 기준 long-context 분류 threshold다. 기본값은 `100000`이며 0 이하 값은 config load에서 거부한다.
|
||||
- `provider_pool.max_queue`와 `provider_pool.queue_timeout_ms`는 모든 model group과 provider candidate에 공통인 Edge provider-pool queue policy의 canonical owner다. `max_queue`는 Edge provider-pool 전체 pending 상한이며 0/생략은 기본값 `16`으로 정규화된다. `queue_timeout_ms`는 각 pending request의 최대 대기 시간이며 명시적 `0`은 timeout 없음, 생략은 기본값 `30000`이다.
|
||||
- canonical `provider_pool` key가 없을 때만 legacy `nodes[].providers[].max_queue`/`queue_timeout_ms`를 compatibility 입력으로 읽는다. 참여 provider의 유효 pair가 모두 같으면 root policy로 승격하고, 하나라도 다르면 first-candidate 값을 택하지 않고 load를 거부한다. canonical root key가 있으면 legacy provider queue 값은 effective policy와 refresh diff에 영향을 주지 않는다.
|
||||
- `models[]`는 provider pool 방향의 canonical routing key이며 `nodes[].providers[].id`를 참조한다. `context_window_tokens`는 해당 model group의 provider 공통 단일 요청 최대 context 계약이다. `default_max_tokens`, `min_max_tokens`, `default_thinking_token_budget`은 OpenAI-compatible 요청을 내부 실행으로 넘기기 전에 적용하는 모델 단위 generation policy다.
|
||||
- 하나의 `models[]` entry는 OpenAI-compatible provider와 normalized-only provider를 함께 참조할 수 있다. 선택된 provider가 OpenAI-compatible 호출 방식을 지원하면 passthrough 실행 경로를 사용하고, `ollama`/`cli` 같은 normalized-only provider면 normalized 실행 경로를 사용한다. Ollama 후보는 model group에서 제거하지 않고 `capacity`와 `priority`로 낮은 동시성/선호도를 표현한다.
|
||||
- `nodes[].providers[]`는 Node 아래 resource/provider catalog다. `category`는 `api`, `cli`, `local_inference` resource kind를 나타낸다.
|
||||
|
|
@ -41,15 +45,15 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c
|
|||
- `nodes[].providers[].id`는 전체 Edge config 안에서 중복되면 안 된다.
|
||||
- `nodes[].providers[].adapter`는 같은 Node 안의 enabled adapter instance key를 참조해야 한다. Exact instance key를 우선하고, legacy type-name route는 같은 type의 enabled instance가 정확히 하나일 때만 허용한다. `category: cli` resource는 enabled CLI adapter가 필요하다.
|
||||
- `nodes[].providers[].enabled`: 생략 또는 `true` → provider pool dispatch 후보에 포함. `false` → dispatch pool에서 제외. 비활성화된 provider는 status snapshot에 `status=disabled`, `health=disabled`, `capacity=0`으로 표시된다. adapter process lifecycle 변경 없음. config refresh 시 `enabled` 토글은 live-apply(restart 불필요)로 분류된다. disabled provider의 adapter reference check는 skip되지만 structural validation(type, category, models, numeric bounds)은 수행된다.
|
||||
- `nodes[].providers[].capacity`와 `long_context_capacity`는 `node_id + provider_id` resource가 소유한다. 같은 provider를 참조하는 여러 `models[].id`는 일반·long slot을 합산 공유한다. `total_context_tokens`는 runtime counter가 아니라 `context_window_tokens * long_context_capacity` 이상이어야 하는 정적 load/refresh validation 값이다.
|
||||
- `nodes[].providers[].priority`: provider-pool dispatch tie-breaker다. 기본값은 `0`이고 음수는 validation error다. dispatch는 `in_flight < capacity` 후보 중 가장 낮은 `in_flight`를 먼저 선택하며, `in_flight`가 같은 후보에서만 낮은 숫자의 `priority`를 우선한다. `in_flight`와 `priority`가 모두 같으면 기존 순환을 유지한다. priority 변경은 live-apply(restart 불필요)로 분류된다.
|
||||
- `nodes[].providers[].queue_timeout_ms`와 adapter instance `queue_timeout_ms`는 provider-pool 대기열 timeout이다. queue policy가 구성된 상태에서 값이 `0`이면 queue timeout을 두지 않고, caller context cancellation 또는 연결 종료로만 대기 요청을 중단한다. provider가 queue policy를 전혀 제공하지 않으면 runtime 기본 queue timeout을 사용한다.
|
||||
- legacy single-instance adapter 설정은 load 시 named instance slice로 normalize된다.
|
||||
- `NodeConfigPayload`는 Edge가 Node에 내려주는 실행 adapter/runtime payload다.
|
||||
- refresh 결과는 `applied`, `restart_required`, `rejected`를 구분하고, changed node/provider/model/report slice는 안정적으로 non-nil이어야 한다.
|
||||
|
||||
## refresh 분류 기준
|
||||
|
||||
- live apply 가능: Edge root `long_context_threshold_tokens`, provider capacity, provider priority, provider max queue, provider queue timeout, provider `enabled` toggle, `models[]` display/context window/provider/generation policy mapping, legacy node runtime concurrency metadata.
|
||||
- live apply 가능: Edge root `long_context_threshold_tokens`, `provider_pool.max_queue`, `provider_pool.queue_timeout_ms`, provider capacity, provider long-context capacity, provider total-context validation budget, provider priority, provider `enabled` toggle, `models[]` display/context window/provider/generation policy mapping, legacy node runtime concurrency metadata. 기존 lease는 유지하며 새 admission과 모든 pending item은 새 policy/candidate 상태로 재평가한다.
|
||||
- restart required: Edge identity/listen/bootstrap/logging/metrics/console/control-plane/openai/a2a listener config, node 추가/삭제, node token/alias/agent kind, adapter 설정, provider type/category/adapter/models/health/lifecycle capability, provider-first execution fields(`provider`, `endpoint`, `base_url`, `headers`, `command`, `args`, `env`, `mode`, `resume_args`, `output_format`, `context_size`, `request_timeout_ms`) 변경.
|
||||
- rejected: candidate config load/validate 실패, invalid refresh mode, apply failure.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
- `apps/node/internal/transport/client.go`
|
||||
- `apps/node/internal/transport/session.go`
|
||||
- `apps/node/internal/transport/parser.go`
|
||||
- `apps/node/internal/bootstrap/runtime_supervisor.go`
|
||||
- `apps/edge/internal/transport/connection_handlers.go`
|
||||
- `apps/edge/internal/service/model_queue_release.go`
|
||||
- `apps/edge/internal/service/status_provider.go`
|
||||
- `apps/edge/internal/node/mapper.go`
|
||||
- `apps/node/internal/adapters/config_set.go`
|
||||
- human docs:
|
||||
|
|
@ -19,7 +23,7 @@
|
|||
|
||||
## 읽는 조건
|
||||
|
||||
- Edge-Node TCP/protobuf transport, register/dispatch-ready handshake, run stream, provider raw tunnel, cancel, node command, node config refresh를 바꿀 때
|
||||
- Edge-Node TCP/protobuf transport, initial/reconnect supervision, register/dispatch-ready handshake, connection generation fencing, 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로 전달되는 방식을 바꿀 때
|
||||
|
||||
|
|
@ -32,6 +36,8 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
|
|||
## 주요 흐름
|
||||
|
||||
- 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가 열리지 않는다.
|
||||
- connectivity supervision: Node daemon은 Fx startup 전에 원격 연결 성공을 요구하지 않고 단일 supervisor goroutine이 initial dial과 established-session reconnect를 같은 policy로 직렬 처리한다. retryable 원격 실패는 재시도하고 local config/credential fatal error, 유한 retry exhaustion, local shutdown만 process terminal로 구분한다.
|
||||
- disconnect/reconnect: current dispatch-ready owner의 close/heartbeat timeout만 해당 connection generation을 fence한다. Edge는 같은 authoritative lifecycle에서 provider lease를 정확히 한 번 반환하고 resource를 offline/excluded로 만든 뒤 queue를 live candidate 기준으로 재평가한다. accepted Node의 ready transition은 새 generation resource를 활성화하고 기존 waiter를 즉시 pump한다. stale/rejected connection callback은 live state나 lifecycle 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만으로 후보를 제외하지 않는다.
|
||||
|
|
@ -57,7 +63,10 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
|
|||
- `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 요청 수를 나타낸다.
|
||||
- `reconnect.interval_sec`, `reconnect.max_attempts`: initial connect와 established-session reconnect에 공통 적용된다. 명시적 `max_attempts=0`은 local shutdown까지 unlimited, 생략은 기본값 `10`, 양수는 정확한 유한 attempt limit, 음수는 validation error다. unlimited mode의 `interval_sec`는 양수여야 하며 생략은 기본값 `10`을 사용한다. 유한 exhaustion과 non-retryable 오류는 exit code 1, local shutdown은 정상 종료다.
|
||||
- `ProviderSnapshot`: legacy wire name을 유지하지만 Node 아래 resource/provider 상태 snapshot으로 해석한다. `category`가 `api`, `cli`, `local_inference` resource kind를 나타내며, provider-pool dispatch 대상은 Edge config `models[].providers`가 참조한 resource뿐이다. `in_flight`와 `long_in_flight`는 `node_id + provider_id` lease state의 현재 점유다. `queued`는 Edge queue에서 해당 provider를 live candidate로 포함하는 고유 pending request 수이고 `long_queued`는 그중 long request 수이므로 여러 provider snapshot에 같은 request가 candidate pressure로 나타날 수 있다.
|
||||
- configured Node가 disconnected/pending이면 Node snapshot은 `connected=false`를 유지하고 provider catalog entry도 남는다. enabled provider의 effective snapshot은 `status=unavailable`, `health=offline`, capacity/in-flight/queued/long-context 관련 수치가 모두 0이다. reconnect ready 뒤에는 같은 resource identity의 새 generation으로 configured capacity와 admission eligibility가 복구된다.
|
||||
- Node adapter instance는 normalized `RunRequest`와 `ProviderTunnelRequest`가 공유하는 local capacity gate를 사용한다. 이 gate는 Edge provider lease를 복제하는 분산 admission이 아니라 Edge queue를 우회한 실행으로부터 같은 backend를 보호하는 defense-in-depth다.
|
||||
|
||||
## 금지 사항
|
||||
|
||||
|
|
@ -67,6 +76,8 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
|
|||
- 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로 취급하지 않는다.
|
||||
- provider lease 반환, generation fencing, queue settlement 같은 correctness 전이를 drop 가능한 node event fanout의 성공에 의존시키지 않는다.
|
||||
- OS service/Task Scheduler restart를 retryable initial connect 또는 장기 outage 복구의 correctness owner로 사용하지 않는다.
|
||||
|
||||
## 변경 시 확인할 코드/테스트
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ IOP 내부 실행은 `adapter + target` 기준이며, OpenAI-compatible 경계
|
|||
IOP 고유 실행 문맥은 별도 `iop` wrapper field를 만들지 않고 OpenAI request의 `metadata`에 둔다.
|
||||
기본 설계 기준은 OpenAI-compatible request/response surface 보존이다. OpenAI-compatible provider로 raw passthrough 되는 경로는 선택된 provider가 지원하는 표준 field와 provider extension field를 IOP allowlist로 제한하지 않는다. IOP 고유 field나 추상화 field는 OpenAI-compatible 기본 surface 위에 더하는 확장으로만 사용하며, provider-native OpenAI-compatible field를 대체하거나 금지하지 않는다.
|
||||
라우팅의 1차 기준은 request `model`이 가리키는 route/provider capability다. 선택된 provider가 OpenAI-compatible provider이면 Edge는 provider tunnel passthrough를 사용하고, 그 외 CLI/Ollama/native 실행은 normalized path를 사용한다. 라우팅과 응답 형태를 caller metadata selector로 고르지 않는다. 2차 처리는 OpenAI `metadata` container에서 IOP가 아는 key만 발췌해 workspace, task, principal, usage/observability 같은 내부 문맥으로 쓰는 방식이다.
|
||||
서로 다른 외부 `model` key가 같은 `nodes[].providers[].id`를 참조하면 일반·long-context capacity는 model group별이 아니라 해당 provider resource 하나에서 공유된다. Edge provider-pool queue의 전체 pending 상한과 timeout도 model group 공통 root policy를 사용한다.
|
||||
|
||||
## Auth
|
||||
|
||||
|
|
@ -133,6 +134,8 @@ Normalized route 금지:
|
|||
- normalized(non-provider) `/v1/responses` route는 strict field validation을 유지하며 non-streaming string input만 지원한다.
|
||||
- provider-pool model group route(`models[]`)의 `/v1/responses` 호출은 selected provider가 OpenAI-compatible provider이면 raw passthrough로 provider `POST /v1/responses`에 전달한다. caller body는 `model` field만 served target으로 rewrite하고, selected provider가 지원하는 OpenAI-compatible 표준 field와 provider extension field(`max_output_tokens`, `tools`, `store`, provider-specific knobs 등)는 보존한다. `stream:true`는 provider raw SSE로 relay한다. provider auth forwarding이 적용되고, response model echo rewrite는 적용하지 않는다. 이 경로는 normalized `SubmitRun`으로 fallback하지 않는다.
|
||||
- provider-pool model group route는 provider candidate를 먼저 선택한다. 선택된 provider가 OpenAI-compatible 호출 방식을 지원하면 `ProviderTunnelRequest` passthrough를 사용하고, Ollama/CLI/native provider이면 normalized `RunRequest`를 사용한다. provider type만으로 Ollama를 candidate set에서 제거하지 않으며, OpenAI-compatible provider의 tunnel 구현이 없으면 normalized fallback이 아니라 unsupported/implementation error다.
|
||||
- provider-pool pending request는 lease 반환, config refresh, provider disable, Node disconnect/reconnect 때 live config와 dispatch-ready registry에서 candidate를 다시 계산한다. 후보가 full인 상태는 queue policy에 따라 계속 대기하지만 live candidate가 모두 사라지면 원래 queue timeout까지 기다리지 않고 terminal unavailable로 끝난다.
|
||||
- provider-pool admission/unavailable 실패는 현재 외부 error envelope를 유지해 HTTP `502`와 `type="node_dispatch_error"`로 반환한다. 별도 public status code나 response field를 추가하지 않으며 error message에는 raw token이나 private endpoint를 포함하지 않는다.
|
||||
- direct legacy provider route(`openai.model_routes[]`의 `openai_compat`/`vllm` adapter)도 OpenAI-compatible provider이면 raw provider tunnel을 사용한다. Non-provider normalized route는 raw tunnel을 쓰지 않고 normalized IOP output path를 사용한다.
|
||||
- Responses provider passthrough success usage metric label은 endpoint와 model_group=request alias를 기준으로 집계한다. 관측/usage 정보는 provider body에 섞지 않는다.
|
||||
- `metadata`는 최대 16개 string key/value를 허용한다. key는 64자 이하, value는 512자 이하를 기준으로 한다.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Node daemon이 최초 연결 실패와 장기 단절에도 원격 가용성만
|
|||
|
||||
## 상태
|
||||
|
||||
[진행중]
|
||||
[완료]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
|
|
@ -84,16 +84,18 @@ Node transport lifecycle을 provider resource eligibility와 운영 관측의
|
|||
|
||||
소유권 변경이 외부 model alias와 Edge-Node 실행 경로에서 재발하지 않도록 계약과 검증 근거를 남긴다.
|
||||
|
||||
- [ ] [cross-model-tests] 같은 provider를 공유하는 `ornith:35b`와 `ornith-fast` 형태의 두 model key를 포함해 capacity, long-context, queue fairness, cancel, send 실패, delayed initial connect, long-outage reconnect, graceful disconnect, heartbeat-timeout fallback, duplicate/stale disconnect, event fanout 포화, reconnect된 provider의 기존 waiter 즉시 wake-up과 refresh 회귀·race 테스트가 추가되어 있다.
|
||||
- [ ] [contract-spec-sync] 구현 결과가 [Edge Config And Runtime Refresh Contract](../../../../agent-contract/inner/edge-config-runtime-refresh.md), [Edge-Node Runtime Wire Contract](../../../../agent-contract/inner/edge-node-runtime-wire.md), [Control Plane-Edge Wire Contract](../../../../agent-contract/inner/control-plane-edge-wire.md), [OpenAI-Compatible API Contract](../../../../agent-contract/outer/openai-compatible-api.md)과 관련 `agent-spec`에 동기화되어 있다.
|
||||
- [ ] [capacity-smoke] local 또는 dev-corp의 공유 provider capacity 1 smoke에서 두 model key 동시 호출 시 backend peak concurrency가 1이고, 대기 또는 다른 provider fallback 뒤 모든 provider 상태가 0으로 회복된다.
|
||||
- [x] [cross-model-tests] 같은 provider를 공유하는 `ornith:35b`와 `ornith-fast` 형태의 두 model key를 포함해 capacity, long-context, queue fairness, cancel, send 실패, delayed initial connect, long-outage reconnect, graceful disconnect, heartbeat-timeout fallback, duplicate/stale disconnect, event fanout 포화, reconnect된 provider의 기존 waiter 즉시 wake-up과 refresh 회귀·race 테스트가 추가되어 있다.
|
||||
- [x] [contract-spec-sync] 구현 결과가 [Edge Config And Runtime Refresh Contract](../../../../agent-contract/inner/edge-config-runtime-refresh.md), [Edge-Node Runtime Wire Contract](../../../../agent-contract/inner/edge-node-runtime-wire.md), [Control Plane-Edge Wire Contract](../../../../agent-contract/inner/control-plane-edge-wire.md), [OpenAI-Compatible API Contract](../../../../agent-contract/outer/openai-compatible-api.md)과 관련 `agent-spec`에 동기화되어 있다.
|
||||
- [x] [capacity-smoke] local 또는 dev-corp의 공유 provider capacity 1 smoke에서 두 model key 동시 호출 시 backend peak concurrency가 1이고, 대기 또는 다른 provider fallback 뒤 모든 provider 상태가 0으로 회복된다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 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를 확인한다.
|
||||
- 상태: 통과
|
||||
- 요청일: 2026-07-22
|
||||
- 완료 근거: SDD와 선행 리팩터링 gate가 충족됐고, 같은 task group의 archive `complete.log`·현재 코드/테스트·git 이력을 대조했다. 마지막 `capacity-smoke`는 [PASS complete.log](../../../../agent-task/archive/2026/07/m-provider-resource-admission-ownership-alignment/16_capacity_smoke/complete.log)의 local two-alias capacity-1 smoke에서 backend peak 1과 모든 provider counter 0 회복으로 확인됐다.
|
||||
- 검토 항목: `make proto`, 관련 Go unit/race test, loopback capacity smoke, mock smoke와 직접 Edge-Node reconnect diagnostic이 통과했다. live provider preflight는 local endpoint 미가용으로 rc=3 차단 증거이며 S11 local smoke 완료를 대체하거나 무효화하지 않는다.
|
||||
- 코드 감사: 통과. Required/Suggested 이슈 없음; shared provider lease, adapter-local tunnel guard, reconnect/offline snapshot과 Control Plane view 계약을 현재 코드·테스트로 재확인했다.
|
||||
- Spec sync: Spec updated — [Edge-Node 실행 경로](../../../../agent-spec/runtime/edge-node-execution.md), [Provider Pool과 Config Refresh](../../../../agent-spec/runtime/provider-pool-config-refresh.md), [OpenAI-Compatible 입력 표면](../../../../agent-spec/input/openai-compatible-surface.md), [Control Plane 운영 기능](../../../../agent-spec/control/control-plane-operations.md).
|
||||
- agent-ui 상태 반영: 해당 없음
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
|
|
@ -42,9 +42,9 @@ Phase를 가로지르는 실제 다음 작업 선택은 [전역 마일스톤 실
|
|||
- 경로: [daily-usage-cost-roi-report-mvp](../../archive/phase/operational-observability-provider-management/milestones/daily-usage-cost-roi-report-mvp.md)
|
||||
- 요약: 기존 OpenAI-compatible token usage metric을 일별/월별로 rollup하고, 운영자가 관리하는 cloud price baseline으로 환산해 사용자/토큰/model/endpoint별 cloud-equivalent cost와 ROI 판단용 avoided-cost를 Grafana/query 중심으로 보는 문서 표면을 완료했다.
|
||||
|
||||
- [진행중] Provider Resource Admission Ownership 정합화
|
||||
- 경로: [provider-resource-admission-ownership-alignment](milestones/provider-resource-admission-ownership-alignment.md)
|
||||
- 요약: 같은 provider를 여러 model key가 공유할 때 capacity와 long-context slot을 하나의 provider resource lease로 적용하고, Node의 initial/long-outage 연결 supervision, disconnect의 즉시 offline/라우팅 제외, reconnect 후보 복구와 queue wake-up, 운영 snapshot과 Node adapter-local 안전장치를 정렬한다.
|
||||
- [완료] Provider Resource Admission Ownership 정합화
|
||||
- 경로: [provider-resource-admission-ownership-alignment](../../archive/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
|
||||
- 요약: 공유 provider의 capacity·long-context lease, 공통 queue policy, Node reconnect/offline fencing과 Control Plane snapshot을 정렬하고 local two-alias capacity-1 smoke까지 검증했다.
|
||||
|
||||
- [계획] Provider 부하 메트릭과 Live Queue Dashboard
|
||||
- 경로: [provider-load-metrics-queue-dashboard](milestones/provider-load-metrics-queue-dashboard.md)
|
||||
|
|
|
|||
|
|
@ -4,71 +4,68 @@
|
|||
|
||||
## 실행 순서
|
||||
|
||||
1. [Provider Resource Admission Ownership 정합화](phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
|
||||
같은 provider를 여러 model key가 공유해도 capacity와 long-context slot이 하나의 provider resource lease로 적용되게 하고, Node의 initial/long-outage 연결 복구와 Edge의 provider offline·후보 복구·queue 재평가를 연결한다.
|
||||
|
||||
2. [OpenAI-compatible 출력 검증 필터](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-output-validation-filters.md)
|
||||
1. [OpenAI-compatible 출력 검증 필터](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-output-validation-filters.md)
|
||||
OpenAI-compatible single-stream/cross-request assistant 반복 출력과 JSON contract 검증/repair 경로를 안정화한다.
|
||||
|
||||
3. [OpenAI-compatible Incomplete Tool Call Syntax Gate](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-incomplete-tool-call-syntax-gate.md)
|
||||
2. [OpenAI-compatible Incomplete Tool Call Syntax Gate](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-incomplete-tool-call-syntax-gate.md)
|
||||
terminal provider 응답의 incomplete tool-call syntax를 deterministic하게 판정한다.
|
||||
|
||||
4. [OpenAI-compatible Runtime Output Integrity Filter](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-runtime-output-integrity-filter.md)
|
||||
3. [OpenAI-compatible Runtime Output Integrity Filter](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-runtime-output-integrity-filter.md)
|
||||
terminal output invariant와 공통 filter/retry pipeline을 정의한다.
|
||||
|
||||
5. [LLM 판별 기반 Missing Tool Call 재시도 Gate](phase/knowledge-tool-optimization-extension/milestones/llm-judged-missing-tool-call-retry-gate.md)
|
||||
4. [LLM 판별 기반 Missing Tool Call 재시도 Gate](phase/knowledge-tool-optimization-extension/milestones/llm-judged-missing-tool-call-retry-gate.md)
|
||||
tool 사용 의도 누락 케이스를 LLM judge와 buffered retry 후보로 검토한다.
|
||||
|
||||
6. [Tool Call 판정 모델 Gate 리뷰](phase/knowledge-tool-optimization-extension/milestones/tool-call-validator-model-gate-review.md)
|
||||
5. [Tool Call 판정 모델 Gate 리뷰](phase/knowledge-tool-optimization-extension/milestones/tool-call-validator-model-gate-review.md)
|
||||
schema만으로 어려운 tool-call 후보에 validator 모델을 쓸지 검토한다.
|
||||
|
||||
7. [Pi CLI Provider Integration](phase/automation-runtime-bridge/milestones/pi-cli-provider-integration.md)
|
||||
6. [Pi CLI Provider Integration](phase/automation-runtime-bridge/milestones/pi-cli-provider-integration.md)
|
||||
Pi를 Node CLI provider 실행 후보에 추가하고 OpenAI-compatible route smoke로 안정화한다.
|
||||
|
||||
8. [CLI Agent Group Grade Routing](phase/automation-runtime-bridge/milestones/cli-agent-group-grade-routing.md)
|
||||
7. [CLI Agent Group Grade Routing](phase/automation-runtime-bridge/milestones/cli-agent-group-grade-routing.md)
|
||||
lane/grade 파일명과 `metadata.agent_group.task_file` 기반 CLI agent group 라우팅 계약을 정리한다.
|
||||
|
||||
9. [에이전트 작업 루프 오케스트레이션 MVP](phase/automation-runtime-bridge/milestones/agent-workflow-loop-orchestration-mvp.md)
|
||||
8. [에이전트 작업 루프 오케스트레이션 MVP](phase/automation-runtime-bridge/milestones/agent-workflow-loop-orchestration-mvp.md)
|
||||
교체 가능한 최초 요청 라우터, direct/Plan/Milestone 분류, 로컬 작업 파일, 하이브리드 실행과 상위 모델 리뷰를 연결하는 작업 루프를 스케치한다.
|
||||
|
||||
10. [CLI Agent 사용량 알림과 자동 이어받기 MVP](phase/automation-runtime-bridge/milestones/cli-agent-usage-notification-continuation.md)
|
||||
9. [CLI Agent 사용량 알림과 자동 이어받기 MVP](phase/automation-runtime-bridge/milestones/cli-agent-usage-notification-continuation.md)
|
||||
CLI Agent limit 감지, 사용자 알림, 작업 자동 이어받기 경계를 스케치한다.
|
||||
|
||||
11. [단계 호출과 검증 최적화 MVP](phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md)
|
||||
10. [단계 호출과 검증 최적화 MVP](phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md)
|
||||
planner/generator/verifier 단계 호출과 runtime schema 검증 실행 모드를 스케치한다.
|
||||
|
||||
12. [원격 코딩/유지보수 작업 환경](phase/automation-runtime-bridge/milestones/remote-workspace-operations-environment.md)
|
||||
11. [원격 코딩/유지보수 작업 환경](phase/automation-runtime-bridge/milestones/remote-workspace-operations-environment.md)
|
||||
workspace-bound execution 기반 원격 코딩/유지보수 운영 경계를 스케치한다.
|
||||
|
||||
13. [Personal Local Edge 패키징과 배포 모드 프로파일](phase/personal-edge-packaging-deployment/milestones/personal-local-edge-deployment-profiles.md)
|
||||
12. [Personal Local Edge 패키징과 배포 모드 프로파일](phase/personal-edge-packaging-deployment/milestones/personal-local-edge-deployment-profiles.md)
|
||||
personal/server/fleet 배포 모드와 capability gate 경계를 스케치한다.
|
||||
|
||||
14. [요청 실행 로그와 Usage Ledger 기반](phase/operational-observability-provider-management/milestones/request-execution-log-usage-ledger-foundation.md)
|
||||
13. [요청 실행 로그와 Usage Ledger 기반](phase/operational-observability-provider-management/milestones/request-execution-log-usage-ledger-foundation.md)
|
||||
요청별 provider/model 선택, timing, token, status/error를 구조화된 ledger로 남기는 기반을 스케치한다.
|
||||
|
||||
15. [Update Plane 안정 프로토콜](phase/update-plane-self-update-foundation/milestones/update-plane-stable-protocol.md)
|
||||
14. [Update Plane 안정 프로토콜](phase/update-plane-self-update-foundation/milestones/update-plane-stable-protocol.md)
|
||||
hello/status, manifest, command, event, recovery 최소 계약을 스케치한다.
|
||||
|
||||
16. [Host-local Manager 기반 자체 업데이트](phase/update-plane-self-update-foundation/milestones/host-local-manager-self-update.md)
|
||||
15. [Host-local Manager 기반 자체 업데이트](phase/update-plane-self-update-foundation/milestones/host-local-manager-self-update.md)
|
||||
manager/updater의 release staging, 검증, restart, rollback 실행 모델을 정리한다.
|
||||
|
||||
17. [Edge/Node 롤아웃과 복구 정책](phase/update-plane-self-update-foundation/milestones/edge-node-rollout-recovery-policy.md)
|
||||
16. [Edge/Node 롤아웃과 복구 정책](phase/update-plane-self-update-foundation/milestones/edge-node-rollout-recovery-policy.md)
|
||||
Edge/Node rolling update, 실패/재연결/rollback 보고 정책을 스케치한다.
|
||||
|
||||
18. [Provider Runtime 설정과 모델 획득 오케스트레이션](phase/operational-observability-provider-management/milestones/provider-runtime-model-acquisition-orchestration.md)
|
||||
17. [Provider Runtime 설정과 모델 획득 오케스트레이션](phase/operational-observability-provider-management/milestones/provider-runtime-model-acquisition-orchestration.md)
|
||||
provider runtime launch/profile, model download/cache/verification 경계를 스케치한다.
|
||||
|
||||
19. [Provider-Device-Model Qualification 리포트와 Lifecycle 관리](phase/operational-observability-provider-management/milestones/provider-device-model-qualification-report.md)
|
||||
18. [Provider-Device-Model Qualification 리포트와 Lifecycle 관리](phase/operational-observability-provider-management/milestones/provider-device-model-qualification-report.md)
|
||||
provider/device/model별 compatibility, performance, quality, lifecycle 리포트 경계를 정리한다.
|
||||
|
||||
20. [누적 요청 컨텍스트 구성과 최적화](phase/knowledge-tool-optimization-extension/milestones/request-context-assembly-optimization.md)
|
||||
19. [누적 요청 컨텍스트 구성과 최적화](phase/knowledge-tool-optimization-extension/milestones/request-context-assembly-optimization.md)
|
||||
Agent, Open WebUI, 일반 API caller와 cloud 요청의 누적 context를 target별로 구성·축소하는 방향을 스케치한다.
|
||||
|
||||
21. [장기 기억과 RAG 업데이트 사이클 (2차)](phase/knowledge-tool-optimization-extension/milestones/long-term-memory-rag-second-wave.md)
|
||||
20. [장기 기억과 RAG 업데이트 사이클 (2차)](phase/knowledge-tool-optimization-extension/milestones/long-term-memory-rag-second-wave.md)
|
||||
repo 장기 기억, RAG 저장소, update cycle, MCP 기반 context 절약 후보를 스케치한다.
|
||||
|
||||
22. [Advisor와 Context Hook 확장 (2차)](phase/knowledge-tool-optimization-extension/milestones/advisor-context-hook-second-wave.md)
|
||||
21. [Advisor와 Context Hook 확장 (2차)](phase/knowledge-tool-optimization-extension/milestones/advisor-context-hook-second-wave.md)
|
||||
advisor 역할과 여러 기능을 실행 흐름에 연결하는 Context Hook 경계를 스케치한다.
|
||||
|
||||
23. [oto 자동화 스케줄러와 CI-CD 연동 (2차)](phase/automation-runtime-bridge/milestones/oto-automation-scheduler-second-wave.md)
|
||||
22. [oto 자동화 스케줄러와 CI-CD 연동 (2차)](phase/automation-runtime-bridge/milestones/oto-automation-scheduler-second-wave.md)
|
||||
oto 기반 자동화, scheduler, CI-CD 연동 후보를 스케치한다.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ source_evidence:
|
|||
- type: test
|
||||
path: apps/control-plane/internal/wire/edge_server_test.go
|
||||
notes: Control Plane-Edge wire 검증
|
||||
- type: test
|
||||
path: apps/control-plane/cmd/control-plane/edge_registry_handler_test.go
|
||||
notes: provider 일반·long counter의 typed view와 HTTP JSON mapping 검증
|
||||
- type: test
|
||||
path: apps/edge/internal/service/status_provider_test.go
|
||||
notes: configured offline Node/provider snapshot과 reconnect capacity 복구 검증
|
||||
---
|
||||
|
||||
# 스펙: Control Plane 운영 기능
|
||||
|
|
@ -44,6 +50,7 @@ Control Plane과 Client가 Edge 운영 상태를 어떻게 관찰하고 명령
|
|||
| Edge outbound enrollment | Edge가 Control Plane TCP wire로 outbound 연결하고 `EdgeHelloRequest`를 보낸다. `edge_id`가 비어 있으면 거부된다. |
|
||||
| Edge connection registry | Control Plane은 Edge connection을 in-memory로 관리하고 reconnect stale cleanup을 connection token으로 방지한다. |
|
||||
| Edge status request | Control Plane이 connected Edge에 `EdgeStatusRequest`를 보내 node/provider snapshot을 받는다. |
|
||||
| connectivity-aligned provider snapshot | configured Node는 disconnect/pending 상태에도 `connected=false`로 남고 provider identity는 offline/zero effective state로 유지되며 reconnect ready 뒤 capacity와 함께 복구된다. |
|
||||
| Edge command dispatch | Control Plane이 `EdgeCommandRequest`를 connected Edge에 보내고 response와 lifecycle event를 bounded audit view에 기록한다. |
|
||||
| Edge/Fleet HTTP view | `/edges`, `/edges/{id}`, `/edges/{id}/status`, `/edges/{id}/events`, `/edges/{id}/operations`, `/edges/{id}/commands`와 fleet status 계열을 제공한다. |
|
||||
| Flutter status repository | Flutter Client가 HTTP repository로 Edge/fleet status, events, operations, command response를 가져온다. |
|
||||
|
|
@ -87,6 +94,8 @@ sequenceDiagram
|
|||
- Client WS listen은 `IOP_WIRE_LISTEN`, Edge TCP listen은 `IOP_EDGE_WIRE_LISTEN`으로 override할 수 있다.
|
||||
- Edge connector 설정은 `configs/edge.yaml`의 `control_plane` 섹션이다.
|
||||
- Edge registry recent node events와 command audit는 bounded in-memory buffer다. durable audit store가 아니다.
|
||||
- `EdgeNodeSnapshot.connected`는 current dispatch-ready ownership과 같고 accepted/pending connection은 false다. configured offline provider는 `status=unavailable`, `health=offline`, capacity/in-flight/queued/long-context 관련 수치를 0으로 보고한다.
|
||||
- online provider의 in-flight는 Edge provider lease state, queued 값은 Edge queue의 candidate pressure다. current owner의 ready/disconnect 전이 뒤에만 관측 event가 relay되고 stale/rejected close는 live snapshot/event를 바꾸지 않는다.
|
||||
- Client build-time endpoint는 `IOP_CONTROL_PLANE_HTTP_URL`, `IOP_CONTROL_PLANE_WIRE_URL` Dart define으로 주입된다.
|
||||
|
||||
## 검증
|
||||
|
|
@ -109,3 +118,4 @@ sequenceDiagram
|
|||
|
||||
- 2026-07-07: 현재 Control Plane/Client 코드와 wire 계약 기준으로 bootstrap spec 작성.
|
||||
- 2026-07-07: 기능 목록 중심으로 축소하고 주요 흐름을 Mermaid sequence diagram으로 정리.
|
||||
- 2026-07-22: dispatch-ready connectivity와 configured offline provider snapshot, reconnect capacity 복구, current-owner event 의미를 현재 Edge status 구현과 계약 기준으로 동기화.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ source_evidence:
|
|||
- type: test
|
||||
path: apps/edge/internal/openai/provider_tunnel_test.go
|
||||
notes: provider-pool raw tunnel passthrough 검증
|
||||
- type: test
|
||||
path: apps/edge/internal/service/model_queue_admission_test.go
|
||||
notes: 공유 provider cross-model capacity와 no-candidate unavailable 검증
|
||||
- type: test
|
||||
path: apps/edge/internal/openai/workspace_metadata_test.go
|
||||
notes: workspace와 metadata 전달 검증
|
||||
|
|
@ -65,6 +68,8 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
|
|||
| model catalog | `/v1/models`는 provider-pool `models[]`, legacy `openai.model_routes[]`, `openai.models` 또는 `openai.target` 순서로 노출 모델을 만든다. |
|
||||
| model dispatch | request `model`은 provider-pool catalog, legacy model route, single target fallback 순서로 해석된다. |
|
||||
| provider-pool handoff | provider-pool catalog에 model이 있으면 service 요청은 `ProviderPool=true`로 전달되고 adapter/target은 provider selection 이후 확정된다. |
|
||||
| cross-model provider admission | 서로 다른 외부 model key가 같은 provider id를 참조하면 Edge의 provider resource lease 하나에서 일반·long capacity를 합산한다. |
|
||||
| provider-pool queue/unavailable | root provider-pool queue policy를 모든 model group에 공통 적용하고, pending request의 live candidate가 모두 사라지면 timeout을 기다리지 않고 기존 `502 node_dispatch_error` envelope로 종료한다. |
|
||||
| mixed provider dispatch | model group 안에 OpenAI-compatible provider와 Ollama/CLI/native provider가 함께 있어도 request field가 아니라 selected provider capability가 passthrough 또는 normalized 실행 경로를 결정한다. |
|
||||
| legacy route 변환 | legacy route는 외부 `model`을 route entry의 `adapter`, `target`, `node`, `session_id`, queue policy로 변환한다. |
|
||||
| metadata/workspace 처리 | `metadata.workspace`는 `RunRequest.workspace`로 분리하고, 일반 metadata는 최대 16개 string key/value만 허용한다. |
|
||||
|
|
@ -123,6 +128,8 @@ sequenceDiagram
|
|||
- `configs/edge.yaml`의 `openai` 섹션이 listener, bearer token, legacy adapter/target, model routes, strict output을 제공한다.
|
||||
- top-level `models[]`가 있으면 OpenAI model list와 provider-pool dispatch에서 legacy route보다 우선한다.
|
||||
- provider-pool model group은 capacity + priority + availability 기준으로 provider candidate를 먼저 선택하고, 선택된 provider가 OpenAI-compatible 호출 방식을 지원하면 raw tunnel passthrough로 dispatch한다. Ollama/CLI/native provider가 선택되면 normalized `RunRequest` path로 dispatch한다.
|
||||
- provider capacity와 long-context slot은 model alias별이 아니라 `node_id + provider_id`별로 공유한다. queue pending 상한과 timeout은 Edge root `provider_pool` policy이며, lease 반환·refresh·disconnect/reconnect가 모든 model group waiter를 global enqueue 순서로 재평가한다.
|
||||
- provider가 full이면 queue policy에 따라 대기하지만 live candidate가 모두 사라지면 즉시 unavailable로 수렴한다. Chat Completions와 Responses provider-pool 표면은 새 public status/field 없이 HTTP 502 `node_dispatch_error`를 유지한다.
|
||||
- `openai.provider_auth`는 provider tunnel forwarding rule만 저장하고 raw provider token 값은 request-time header에서만 읽는다. inbound IOP `Authorization` header를 provider token source로 재사용하지 않는다.
|
||||
- OpenAI request의 `metadata.workspace`는 absolute path가 필요한 route에서만 필수 검증된다.
|
||||
- Chat Completions와 Responses request는 caller metadata로 provider raw tunnel과 normalized response shape를 선택하지 않는다. route/provider capability만 실행 경로를 결정한다.
|
||||
|
|
@ -176,3 +183,4 @@ sequenceDiagram
|
|||
- 2026-07-14: OpenAI-compatible Provider Passthrough 계약 동기화 종료 검토 기준으로 caller-facing response selector 설명을 제거하고, `model` 기반 route와 provider-native field 보존 기준을 반영.
|
||||
- 2026-07-14: provider 미보고 reasoning text에 estimated-token counter(`iop_openai_reasoning_estimated_tokens_total`, `estimation_method="chars_div_4"`) 추가와 provider-reported reasoning 우선 기준을 반영.
|
||||
- 2026-07-18: 저장소 구조 분해 뒤 streaming, provider tunnel, split test의 `source_evidence`를 현재 경로로 동기화.
|
||||
- 2026-07-22: cross-model provider resource admission, provider-pool 공통 queue policy와 live candidate 소진 시 502 unavailable 의미를 현재 service/OpenAI 구현과 계약 기준으로 동기화.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ source_evidence:
|
|||
- type: code
|
||||
path: apps/edge/internal/service/provider_tunnel.go
|
||||
notes: provider tunnel dispatch와 request-bound frame relay
|
||||
- type: code
|
||||
path: apps/edge/internal/service/model_queue_release.go
|
||||
notes: connection generation fencing, lease 반환, disconnect/reconnect queue 재평가
|
||||
- type: code
|
||||
path: apps/edge/internal/service/status_provider.go
|
||||
notes: configured offline Node/provider snapshot과 dispatch-ready connectivity join
|
||||
- type: code
|
||||
path: apps/node/internal/bootstrap/runtime_supervisor.go
|
||||
notes: initial connect와 established-session reconnect를 공유하는 connectivity supervisor
|
||||
- type: code
|
||||
path: apps/node/internal/node/run_handler.go
|
||||
notes: Node RunRequest 처리와 adapter 실행
|
||||
|
|
@ -54,6 +63,15 @@ source_evidence:
|
|||
- type: test
|
||||
path: apps/edge/internal/bootstrap/reconnect_readiness_integration_test.go
|
||||
notes: 실제 iop-node reconnect 뒤 queued waiter의 ready-gated dispatch와 terminal/counter 수렴 검증
|
||||
- type: test
|
||||
path: apps/edge/internal/service/queue_reservation_test.go
|
||||
notes: provider lease exactly-once 반환과 connection generation race 검증
|
||||
- type: test
|
||||
path: apps/node/internal/bootstrap/module_test.go
|
||||
notes: delayed initial connect, unlimited/finite retry, fatal, shutdown과 disabled metrics listener 검증
|
||||
- type: test
|
||||
path: scripts/dev/edge-node-reconnect-diagnostic.sh
|
||||
notes: 별도 Edge·Node 프로세스의 메시지 relay 순서, terminal ordering, reconnect user-flow 검증
|
||||
---
|
||||
|
||||
# 스펙: Edge-Node 실행 경로
|
||||
|
|
@ -74,6 +92,10 @@ Edge와 Node 사이에 현재 구현된 실행 기능을 기능 단위로 정리
|
|||
| 실행 이벤트 스트림 | Node adapter가 낸 start, delta, reasoning_delta, complete, error, cancelled 이벤트를 `RunEvent`로 Edge에 relay한다. |
|
||||
| provider raw tunnel | Edge가 `ProviderTunnelRequest`를 보내면 Node가 provider HTTP/SSE response를 열고 ordered `ProviderTunnelFrame`으로 status/header/body/end/error/usage 후보를 relay한다. |
|
||||
| mixed provider dispatch wire | provider-pool model group은 Edge service에서 provider를 먼저 선택한 뒤 OpenAI-compatible provider에는 `ProviderTunnelRequest`, Ollama/CLI/native provider에는 normalized `RunRequest`를 보낸다. |
|
||||
| provider resource lease | 여러 model key가 같은 provider를 참조해도 Edge가 `node_id + provider_id` lease에서 일반·long capacity를 합산하고 terminal/send 실패/disconnect가 lease를 정확히 한 번 반환한다. |
|
||||
| Node connectivity supervision | 단일 supervisor가 retryable initial connect 실패와 established-session disconnect를 같은 reconnect policy로 처리하고 local shutdown, fatal 오류, 유한 exhaustion만 terminal로 구분한다. |
|
||||
| disconnect/reconnect fencing | current dispatch-ready owner의 generation만 provider를 offline/excluded로 만들고 queue를 재평가하며, reconnect ready는 새 generation candidate와 기존 waiter를 즉시 복구한다. |
|
||||
| adapter-local capacity guard | Node의 normalized 실행과 provider tunnel 실행은 같은 stable adapter instance capacity gate를 공유해 Edge admission 우회 실행도 backend 한도를 넘지 않는다. |
|
||||
| usage breakdown relay | `RunEvent.usage`와 `ProviderTunnelFrame.usage`는 provider가 보고한 input/output/reasoning/cached input token count를 Edge 관측 계층으로 전달한다. |
|
||||
| terminal event 합성 | adapter가 terminal event 없이 종료하면 Node가 terminal event를 합성한다. |
|
||||
| run cancel | Edge가 `CancelRequest`를 보내면 Node run manager가 active run context를 cancel한다. |
|
||||
|
|
@ -113,6 +135,8 @@ sequenceDiagram
|
|||
end
|
||||
```
|
||||
|
||||
Node process는 이 handshake 바깥에서 단일 connectivity supervisor를 실행한다. retryable initial dial/register 실패와 session disconnect는 같은 bounded cadence로 재시도하고, 명시적 `reconnect.max_attempts=0`은 local shutdown까지 unlimited로 동작한다.
|
||||
|
||||
### 실행 요청과 이벤트
|
||||
|
||||
```mermaid
|
||||
|
|
@ -178,6 +202,9 @@ 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로 처리한다.
|
||||
- current owner disconnect는 event bus와 분리된 authoritative service 경로에서 해당 generation의 provider lease를 exactly-once 반환하고 resource를 offline으로 fence한 뒤 모든 model group waiter를 live candidate로 재평가한다. 후보가 없어진 waiter는 queue timeout을 기다리지 않고 unavailable로 끝난다.
|
||||
- configured Node/provider는 연결이 끊겨도 snapshot catalog에서 사라지지 않는다. Node는 `connected=false`, enabled provider는 `status=unavailable`, `health=offline`, effective capacity/counter 0으로 보이며 ready reconnect 뒤 새 generation의 configured capacity가 복구된다.
|
||||
- `reconnect.max_attempts` 생략은 `10`, 명시적 `0`은 unlimited, 양수는 유한 limit이다. unlimited mode는 양수 `interval_sec`가 필요하고 생략값은 `10`이다. fatal config/credential 오류와 유한 exhaustion은 non-zero terminal, local shutdown은 정상 종료다.
|
||||
- `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에 합쳐지지 않는다.
|
||||
|
|
@ -192,6 +219,7 @@ sequenceDiagram
|
|||
- `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 수렴을 확인한다.
|
||||
- `./scripts/e2e-provider-capacity-smoke.sh` - loopback provider에서 두 model alias가 capacity 1 resource를 공유하고 final normal/long counter가 0으로 회복하는지 확인한다.
|
||||
- `make test-e2e` - Edge-Node와 OpenAI 보조 smoke를 함께 실행한다. runtime path 변경 시 사용자 흐름 검증을 대체하지 않는다.
|
||||
|
||||
## 한계와 주의사항
|
||||
|
|
@ -211,3 +239,4 @@ sequenceDiagram
|
|||
- 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를 여는 순서를 반영.
|
||||
- 2026-07-22: provider resource lease, connection generation fencing, initial/장기 reconnect supervision, offline snapshot과 adapter-local capacity guard를 현재 구현·계약·회귀 테스트 기준으로 동기화.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ source_evidence:
|
|||
- type: code
|
||||
path: packages/go/config/provider_types.go
|
||||
notes: provider/model catalog 설정 타입
|
||||
- type: code
|
||||
path: packages/go/config/edge_types.go
|
||||
notes: Edge root provider_pool canonical queue policy 타입과 기본값
|
||||
- type: code
|
||||
path: packages/go/config/load.go
|
||||
notes: Edge config load와 default 적용
|
||||
|
|
@ -23,7 +26,13 @@ source_evidence:
|
|||
notes: provider 후보 해석과 capacity/priority policy 구성
|
||||
- type: code
|
||||
path: apps/edge/internal/service/model_queue_admission.go
|
||||
notes: provider queue와 long-context slot admission
|
||||
notes: provider 전역 lease, 공통 pending 상한, global enqueue 순서와 long-context admission
|
||||
- type: code
|
||||
path: apps/edge/internal/service/model_queue_release.go
|
||||
notes: lease 반환, disconnect/reconnect candidate 재구성과 global queue pump
|
||||
- type: code
|
||||
path: apps/edge/internal/service/status_provider.go
|
||||
notes: lease state와 candidate pressure 기반 online/offline provider snapshot
|
||||
- type: code
|
||||
path: apps/edge/internal/configrefresh/classify.go
|
||||
notes: dry-run/apply classification과 changed path report 생성
|
||||
|
|
@ -48,9 +57,18 @@ source_evidence:
|
|||
- type: test
|
||||
path: apps/edge/internal/configrefresh/provider_classify_test.go
|
||||
notes: provider field refresh classification 검증
|
||||
- type: test
|
||||
path: apps/edge/internal/service/model_queue_admission_test.go
|
||||
notes: cross-model capacity, terminal unavailable, disconnect/event-drop 회귀 검증
|
||||
- type: test
|
||||
path: apps/edge/internal/service/status_provider_test.go
|
||||
notes: cross-model candidate pressure와 offline/reconnect snapshot 검증
|
||||
- type: test
|
||||
path: apps/edge/internal/bootstrap/reconnect_readiness_integration_test.go
|
||||
notes: dispatch-ready reconnect가 기존 queued waiter를 실제 Node terminal까지 수렴시키는 검증
|
||||
- type: test
|
||||
path: scripts/e2e-provider-capacity-smoke.sh
|
||||
notes: loopback OpenAI-compatible provider에서 two-alias capacity-1 queue와 final counter 회복 검증
|
||||
---
|
||||
|
||||
# 스펙: Provider Pool과 Config Refresh
|
||||
|
|
@ -68,7 +86,10 @@ Edge 설정에서 provider-pool이 어떻게 모델 실행 후보를 고르고,
|
|||
| 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는 dispatch-ready connection을 가진 Node의 provider 후보 중 catalog match, enabled, healthy/available, capacity 조건을 만족하는 후보만 사용한다. |
|
||||
| capacity/priority dispatch | in-flight가 capacity 미만인 후보를 고르고, 동률이면 낮은 `priority`와 round-robin을 적용한다. |
|
||||
| provider 전역 capacity/priority dispatch | `node_id + provider_id` lease가 여러 model group의 일반·long in-flight를 합산한다. available 후보 중 낮은 in-flight를 고르고 동률이면 낮은 `priority`와 round-robin을 적용한다. |
|
||||
| provider-pool 공통 queue policy | Edge root `provider_pool.max_queue`가 모든 model group의 전체 pending 상한을, `queue_timeout_ms`가 각 pending request timeout을 소유한다. |
|
||||
| global queue 재평가 | lease 반환, capacity/priority/enabled refresh, disconnect/reconnect 뒤 global enqueue 순서에서 현재 dispatch 가능한 가장 이른 waiter부터 candidate를 다시 구성한다. |
|
||||
| provider snapshot | 일반·long in-flight는 provider lease state, queued 값은 Edge queue에서 해당 provider를 후보로 포함하는 고유 pending request pressure에서 계산한다. offline provider는 catalog identity를 유지하고 effective 수치를 0으로 보고한다. |
|
||||
| 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한다. |
|
||||
|
|
@ -119,9 +140,11 @@ sequenceDiagram
|
|||
## 설정/데이터/이벤트
|
||||
|
||||
- `long_context_threshold_tokens` 기본 예시는 `100000`이고 0 이하 값은 config load에서 거부된다.
|
||||
- `provider_pool.max_queue`는 0/생략 시 기본값 `16`, `queue_timeout_ms`는 생략 시 `30000`이고 명시적 0은 timeout 없음이다. canonical root key가 없을 때만 서로 같은 legacy provider queue pair를 승격하며 값이 다르면 load를 거부한다.
|
||||
- `nodes[].providers[].capacity`와 `long_context_capacity`는 provider resource 속성이고 같은 provider를 공유하는 model alias가 합산 점유한다. `total_context_tokens`는 runtime ledger가 아니라 `context_window_tokens * long_context_capacity` 정적 validation 값이다.
|
||||
- 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 대상으로 분류된다.
|
||||
- provider capacity, long-context capacity, priority, enabled toggle, root queue policy와 model generation policy는 live apply 대상으로 분류된다. apply는 기존 lease를 보존하고 이후 admission 및 모든 관련 waiter의 live candidate/deadline을 새 값으로 재평가한다.
|
||||
- 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에 저장하지 않는다.
|
||||
- 여러 `openai.principal_tokens[]` entry가 같은 `principal_ref`를 공유할 수 있으며, 이때 `token_ref`가 앱/통합/용도별 사용량 분해 기준이다.
|
||||
|
|
@ -139,6 +162,7 @@ sequenceDiagram
|
|||
- `go test ./apps/edge/internal/node`
|
||||
- `go test ./apps/node/internal/adapters ./apps/node/internal/node`
|
||||
- `go test ./apps/edge/internal/bootstrap -run '^TestActualNodeReconnectReadyPumpsQueuedWaiterExactlyOnce$'`
|
||||
- `./scripts/e2e-provider-capacity-smoke.sh`
|
||||
|
||||
## 한계와 주의사항
|
||||
|
||||
|
|
@ -147,6 +171,7 @@ sequenceDiagram
|
|||
- adapter structural 변경은 contract상 restart-required로 분류된다. Node handler가 registry swap을 지원하더라도 Edge refresh classifier가 허용한 변경만 apply해야 한다.
|
||||
- no-change apply는 runtime snapshot을 교체하지만 Node push는 생략한다.
|
||||
- `NodeRuntimeConfig.concurrency`는 legacy metadata이며 provider-pool admission의 node-wide capacity로 쓰지 않는다.
|
||||
- provider가 full인 상태는 일시적인 queue block이지만, disconnect/disable로 live candidate가 모두 사라지면 waiter는 기존 queue timeout까지 남지 않고 unavailable로 종료된다.
|
||||
- credential, private endpoint, bearer token 원문은 tracked config/docs/spec에 남기지 않는다.
|
||||
- principal token mapping은 완성된 사용자/테넌트 source of truth가 아니라 외부 `principal_ref` 또는 내부 alias에 대한 얇은 운영 매핑이다.
|
||||
- Seulgivibe provider endpoint와 raw user token은 환경별 private config 또는 request-time header로 주입해야 하며 tracked 예시에 실제 값을 남기지 않는다.
|
||||
|
|
@ -160,3 +185,4 @@ sequenceDiagram
|
|||
- 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가 일어나는 현재 동작을 반영.
|
||||
- 2026-07-22: root provider-pool queue policy, cross-model provider lease, global queue 재평가, refresh lease 보존과 connectivity 기반 snapshot 의미를 현재 구현·계약·테스트 기준으로 동기화.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
<!-- task=m-provider-resource-admission-ownership-alignment/16_capacity_smoke plan=0 tag=TEST -->
|
||||
|
||||
# Code Review Reference - TEST
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST]** Fill implementation-owned sections, report `review-ready`, and stop. The implementation model must differ from Plan and Review. Do not review or start another agent.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-07-22
|
||||
task=m-provider-resource-admission-ownership-alignment/16_capacity_smoke, plan=0, tag=TEST
|
||||
|
||||
## 역할 모델
|
||||
|
||||
- Plan: `gpt-5.6-terra`
|
||||
- Implementation: `gpt-5`
|
||||
- Review: `gpt-5.6-sol`
|
||||
|
||||
> Runtime records each identity at its first role start. All three identities must differ.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
|
||||
- Milestone link: [Milestone 문서](../../../agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
|
||||
- Task ids:
|
||||
- `capacity-smoke`: shared capacity-1 two-alias smoke with backend peak 1 and counter recovery.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** Review must differ from Plan/Implementation, must not edit source/tests or execute plan work, and finishes by leaving exactly one of a follow-up pair, `USER_REVIEW.md`, or `complete.log`.
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| TEST-1 Control Plane long provider counters | [x] |
|
||||
| TEST-2 Local two-alias capacity smoke | [x] |
|
||||
| TEST-3 Verification evidence | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] [TEST-1] Control Plane status JSON에 existing long provider capacity/in-flight/queued wire values를 loss 없이 전달하고 typed/raw handler regression으로 고정한다.
|
||||
- [x] [TEST-2] temporary fake OpenAI-compatible backend, Control Plane, Edge, Node config의 local smoke에서 `ornith:35b`/`ornith-fast` 동시 호출, queue/fallback, peak=1, final normal/long zero를 evidence로 기록한다.
|
||||
- [x] [TEST-3] `gofmt -d apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/edge_registry_handler_test.go`, `go test -count=1 ./apps/control-plane/...`, `go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/bootstrap`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap`, `bash -n scripts/e2e-provider-capacity-smoke.sh`, `./scripts/e2e-provider-capacity-smoke.sh`, `IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh` actual output을 stub에 기록한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]**
|
||||
|
||||
- [x] 판정과 Required/Suggested/Nit가 evidence와 일치한다.
|
||||
- [x] Runtime Plan/Implementation/Review identities are all distinct.
|
||||
- [x] status view change is read-only rendering; no Control Plane ownership or wire/config semantics changed.
|
||||
- [x] local smoke is no-secret/no-external-endpoint, bounded, cleanup-safe, and captures two aliases, queue/fallback, peak=1, final normal/long zero.
|
||||
- [ ] PASS이면 `capacity-smoke` Roadmap Completion을 포함한 `complete.log`와 archive handoff를 만들되 roadmap을 직접 수정하지 않는다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
없음. 계획의 세 파일만 변경했다. local test rule에 따라 `make proto`와 live long-context preflight를 추가 실행했으며, preflight의 rc=3은 외부 endpoint/Control Plane 미준비 증거로 기록했고 S11 local pass로 해석하지 않았다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- Control Plane view는 기존 `ProviderSnapshot` getter의 long capacity/in-flight/queued만 read-only JSON field로 복사한다. wire/config/ownership 의미는 변경하지 않는다.
|
||||
- local smoke는 두 alias가 하나의 provider resource를 참조하도록 임시 config를 만들고, fake backend의 첫 요청을 release sentinel까지 hold한다. 따라서 queue 관측 전에 backend peak가 2가 되면 smoke가 실패한다.
|
||||
- fake backend stats와 Control Plane status를 독립적으로 확인해 backend peak=1, queue 관측, 모든 normal/long final counter=0을 각각 증명한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_선택된 Milestone `구현 잠금 > 결정 필요`가 구현을 막을 때만 채운다. 구현은 이 섹션을 포함한 모든 구현 소유 섹션을 채운 뒤 `review-ready`를 보고하고 종료한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 후속으로 해결할 수 없는 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `http_views.go` must map existing long wire getters without exposing config internals/secrets.
|
||||
- `edge_registry_handler_test.go` must prove typed and raw JSON long field preservation.
|
||||
- The new script must not alter default configs, live long smoke, contracts/spec/roadmap, or root dirty Edge service tests.
|
||||
- Compare actual backend stats and Control Plane status evidence, not reconstructed prose.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### `gofmt -d apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/edge_registry_handler_test.go`
|
||||
|
||||
exit 0, stdout/stderr 없음.
|
||||
|
||||
### `make proto` (local profile 추가 검증)
|
||||
|
||||
exit 0. `protoc --go_out=. --go_opt=module=iop`가 runtime/node/control/job proto를 재생성했고 생성물 diff는 없었다.
|
||||
|
||||
### `go test -count=1 ./apps/control-plane/...`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
ok iop/apps/control-plane/cmd/control-plane 0.141s
|
||||
ok iop/apps/control-plane/internal/wire 1.599s
|
||||
```
|
||||
|
||||
### `go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/bootstrap`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
ok iop/apps/edge/internal/node 0.011s
|
||||
ok iop/apps/edge/internal/transport 4.735s
|
||||
ok iop/apps/edge/internal/service 5.868s
|
||||
ok iop/apps/edge/internal/bootstrap 3.214s
|
||||
```
|
||||
|
||||
### `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
ok iop/apps/node/internal/transport 5.545s
|
||||
ok iop/apps/node/internal/bootstrap 1.261s
|
||||
```
|
||||
|
||||
### `bash -n scripts/e2e-provider-capacity-smoke.sh`
|
||||
|
||||
exit 0, stdout/stderr 없음.
|
||||
|
||||
### `./scripts/e2e-provider-capacity-smoke.sh`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
[provider-capacity-smoke] aliases=ornith:35b,ornith-fast queue_observed=true
|
||||
[provider-capacity-smoke] backend={"calls":2,"active":0,"peak":1}
|
||||
[provider-capacity-smoke] final_provider_counters=[{"id":"ornith-provider","in_flight":0,"queued":0,"long_context_capacity":1,"long_in_flight":0,"long_queued":0}]
|
||||
[provider-capacity-smoke] PASS evidence=/tmp/iop-provider-capacity-smoke.uTQzbS
|
||||
```
|
||||
|
||||
기본 cleanup이 위 temporary evidence directory를 process 종료 뒤 제거한다. `IOP_PROVIDER_CAPACITY_SMOKE_KEEP_TMP=1`이면 조사용으로 유지한다.
|
||||
|
||||
### `IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh`
|
||||
|
||||
exit 0. `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 별도 process로 실행해 `/nodes`, `/capabilities`, `/transport`, 같은 session의 두 foreground message, background run, `/sessions`, `/terminate-session`을 통과했다. 출력의 마지막 상태는 `Auxiliary smoke test PASSED.`다.
|
||||
|
||||
### `./scripts/e2e-long-context-admission-smoke.sh --preflight --out-dir /tmp/iop-provider-capacity-live-preflight-20260722`
|
||||
|
||||
exit 3 (외부 환경 blocker, S11 local pass 아님). config check는 통과했고, live `/v1/models`와 local default Control Plane status endpoint가 도달 불가였다. evidence는 `/tmp/iop-provider-capacity-live-preflight-20260722`에 남았다. 재개 조건은 live endpoint와 `edge-toki-labs-dev` status endpoint가 준비되는 것이다.
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING]** Fill implementation-owned sections, report `review-ready`, and stop. Do not execute code-review or start another agent.
|
||||
|
||||
## 섹션 소유권
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header, overview, Roadmap Targets, checkpoints | Fixed | Implementation must not expand scope |
|
||||
| 역할 모델 | Mixed | Runtime records distinct Implementation then Review identities |
|
||||
| completion table, implementation checklist, changes, decisions, user review, verification output | Implementation | Record actual facts only |
|
||||
| review checklist and verdict | Review | Implementation must not modify |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Pass
|
||||
- code quality: Fail
|
||||
- implementation deviation: Fail
|
||||
- verification trust: Fail
|
||||
- spec conformance: Fail
|
||||
- 발견된 문제:
|
||||
- Required — `scripts/e2e-provider-capacity-smoke.sh:245`: Edge와 Node의 `metrics.port`는 `packages/go/observability/observability.go:105`의 wildcard `:<port>` listener를 시작하므로, review가 주장한 loopback-only/no-external-endpoint harness가 아니다. metrics listener에 explicit loopback bind를 지원해 이 smoke가 `127.0.0.1`에만 bind하도록 하거나, Edge와 Node 모두에서 `port <= 0`을 일관되게 disable한 뒤 smoke config에서 비활성화하고 listener scope를 검증하는 회귀를 추가한다.
|
||||
- Required — `scripts/e2e-provider-capacity-smoke.sh:86`: `wait_for_http`와 status/stats/model probes는 `--connect-timeout`만 사용하거나 timeout 자체가 없어 peer가 TCP 연결 후 응답을 멈추면 deadline 검사로 돌아오지 않는다. loopback hang-server fault injection에서 `curl --connect-timeout 1`은 외부 `timeout 2`에 의해 rc=124로 종료됐다. 모든 HTTP 호출에 total `--max-time`을 적용하는 공통 helper를 사용하고, `FIRST_PID`/`SECOND_PID`도 초기화해 실패 시 cleanup에서 kill/wait하여 bounded·cleanup-safe 요구를 증명한다.
|
||||
- Required — `scripts/e2e-provider-capacity-smoke.sh:411`: final jq predicate는 counter 4개만 0이면 성공하므로 `connected=false`, `status=unavailable`, `health=offline`, effective capacity 0인 Node disconnect snapshot도 recovery로 오판한다. 합성 offline snapshot이 현재 predicate에서 exit 0/`true`였다. `ornith-node`가 connected이고 `ornith-provider`가 expected identity/status/health/configured capacity를 유지한 상태에서 normal/long counters가 모두 0인지 함께 검증한다.
|
||||
- Required — `agent-task/m-provider-resource-admission-ownership-alignment/16_capacity_smoke/CODE_REVIEW-local-G08.md:143`: 기록된 `e2e-smoke.sh`는 자체 출력과 `scripts/e2e-smoke.sh:7`에서 auxiliary-only이며 direct dev user-flow를 대체하지 않는다고 명시한다. `agent-test/local/testing-smoke.md:39`의 `scripts/dev/edge.sh`/`scripts/dev/node.sh` full-cycle을 별도로 수행하고, 같은 session 두 메시지와 요구 command payload/terminal 순서의 raw evidence를 기록한다.
|
||||
- Suggested — `scripts/e2e-provider-capacity-smoke.sh:70`: tool preflight에 없는 `seq`를 사용해 기본 macOS 같은 환경에서 숨은 의존성이 된다. Bash 산술 loop로 바꾸거나 required tool로 명시·검증한다.
|
||||
- Nit — `agent-task/m-provider-resource-admission-ownership-alignment/16_capacity_smoke/PLAN-local-G08.md:1`: canonical task header 앞에 stray `ㄹ`이 있다. 후속 pair에서는 task header를 첫 바이트부터 정상 생성한다.
|
||||
- 다음 단계: 다른 Plan 모델이 archived plan/review findings를 입력으로 follow-up PLAN/CODE_REVIEW pair를 작성한다.
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
<!-- task=m-provider-resource-admission-ownership-alignment/16_capacity_smoke plan=1 tag=REVIEW_TEST -->
|
||||
|
||||
# Code Review Reference - REVIEW_TEST
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST]** Fill implementation-owned sections, report `review-ready`, and stop. The implementation model must differ from Plan and Review. Do not review or start another agent.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-07-22
|
||||
task=m-provider-resource-admission-ownership-alignment/16_capacity_smoke, plan=1, tag=REVIEW_TEST
|
||||
|
||||
## 역할 모델
|
||||
|
||||
- Plan: `gpt-5.6-terra`
|
||||
- Implementation: `gpt-5` (runtime identity; Plan 및 Review와 다름)
|
||||
- Review: `gpt-5.6-sol` (runtime identity; Plan 및 Implementation과 다름)
|
||||
|
||||
> Runtime records each identity at its first role start. All three identities must differ.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
|
||||
- Milestone link: [Milestone 문서](../../../agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
|
||||
- Task ids:
|
||||
- `capacity-smoke`: shared capacity-1 two-alias smoke, backend peak 1, online snapshot recovery, direct full-cycle evidence.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## Archive Evidence Snapshot
|
||||
|
||||
- Prior `plan_local_G08_0.log` and `code_review_local_G08_0.log` in this task directory recorded FAIL.
|
||||
- Required: wildcard metrics listener, unbounded curl/request cleanup, offline snapshot false-positive, auxiliary-only evidence. Suggested: remove `seq`. Nit: header has no stray prefix.
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** Review must differ from Plan/Implementation, must not edit source/tests or execute plan work, and finishes by leaving exactly one of a follow-up pair, `USER_REVIEW.md`, or `complete.log`.
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|---|---|
|
||||
| REVIEW_TEST-1 Disable temporary metrics listeners | [x] |
|
||||
| REVIEW_TEST-2 Bound HTTP probes and cleanup | [x] |
|
||||
| REVIEW_TEST-3 Online final recovery predicate | [x] |
|
||||
| REVIEW_TEST-4 S11/direct full-cycle evidence | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] [REVIEW_TEST-1] Node `metrics.port <= 0`는 listener를 시작하지 않게 하고 smoke의 Edge/Node metrics를 모두 disabled로 고정한다.
|
||||
- [x] [REVIEW_TEST-2] 모든 harness HTTP probe에 total timeout을 적용하고 request child PID까지 cleanup하며 hidden `seq` dependency를 없앤다.
|
||||
- [x] [REVIEW_TEST-3] final status가 online configured `ornith-node`/`ornith-provider` identity와 capacity를 보존하면서 normal/long counters zero인 경우에만 pass하도록 한다.
|
||||
- [x] [REVIEW_TEST-4] fresh Go/script/capacity smoke, auxiliary smoke, 그리고 direct dev Edge-Node full-cycle의 raw evidence를 review stub에 기록한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]**
|
||||
|
||||
- [x] FAIL Required/Suggested/Nit가 source와 actual evidence에 모두 해소되었는지 확인한다.
|
||||
- [x] Runtime Plan/Implementation/Review identities are `gpt-5.6-terra`, `gpt-5`, `gpt-5.6-sol` and all distinct.
|
||||
- [x] `metrics.port <= 0` starts no Node listener; smoke does not allocate a metrics port and keeps Edge/Node metrics disabled.
|
||||
- [x] every readiness/status/models/stats HTTP probe uses the common total-timeout helper; FIRST/SECOND request PIDs are initialized, killed, and waited on failure.
|
||||
- [x] final zero predicate rejects the synthetic offline snapshot and requires connected `ornith-node`, available/healthy `ornith-provider`, capacity/long capacity 1, and normal/long zero counters.
|
||||
- [x] `seq` is absent without adding a hidden required tool.
|
||||
- [x] capacity smoke evidence and direct `scripts/dev/edge.sh`/`scripts/dev/node.sh` full-cycle output are distinct; raw output proves two same-session messages, payload order, terminal order, and commands.
|
||||
- [x] PASS이면 `capacity-smoke` Roadmap Completion을 포함한 `complete.log`와 archive handoff를 만들되 roadmap을 직접 수정하지 않는다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 없음. 계획에 지정된 네 source/test 파일과 이 구현 기록만 변경했다.
|
||||
- Control Plane view/handler, Edge service test, contract/spec/roadmap, 기본 config는 기존 dirty worktree 상태를 보존하고 수정하지 않았다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- `Module`의 production default는 계속 `observability.ServeMetrics`이며, test-only `WithMetricsStarter` seam으로 `metrics.port <= 0`에서 시작 호출이 없음을 회귀 검증했다.
|
||||
- smoke의 Edge/Node metrics를 모두 `0`으로 고정하고 metrics 포트 할당을 제거했다. HTTP probe는 공통 1초 connect/2초 total helper를, 두 completion은 50초 total helper를 사용한다.
|
||||
- `FIRST_PID`/`SECOND_PID`를 초기화하고 cleanup에서 kill/wait한다. completion subprocess는 `exec curl`로 대체해 PID가 요청 process를 가리킨다.
|
||||
- fake backend `/hang`은 bounded probe의 실패/경과 시간을 검증한다. final predicate는 configured Node 연결, provider identity/status/health, normal/long capacity, 네 counter zero를 함께 요구하며 synthetic offline snapshot을 먼저 거부한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_선택된 Milestone `구현 잠금 > 결정 필요`가 구현을 막을 때만 채운다. 이 섹션을 포함한 모든 구현 소유 섹션을 채운 뒤 `review-ready`를 보고하고 종료한다. code-review를 실행하거나 review model을 시작하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 후속으로 해결할 수 없는 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `module.go` test seam must retain `observability.ServeMetrics` as production default and only skip listener start for `port <= 0`.
|
||||
- No default config, wire/schema, Control Plane view, contract/spec/roadmap, or root-owned dirty Edge service test may change.
|
||||
- Inspect every curl call, all cleanup paths, arithmetic retry loop, and synthetic offline negative assertion rather than trusting prose.
|
||||
- Treat `e2e-smoke.sh` as auxiliary. Require separate raw direct diagnostic output from both dev scripts before PASS.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### `gofmt -d apps/node/internal/bootstrap/module.go apps/node/internal/bootstrap/runtime_supervisor.go apps/node/internal/bootstrap/module_test.go`
|
||||
|
||||
exit 0; stdout/stderr 없음.
|
||||
|
||||
### `make proto`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
`proto/gen/iop` 생성물 diff 및 untracked 생성물 없음.
|
||||
|
||||
### `go test -count=1 ./apps/node/internal/bootstrap`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
ok iop/apps/node/internal/bootstrap 1.371s
|
||||
```
|
||||
|
||||
`TestSupervisorDoesNotStartMetricsWhenPortDisabled`를 포함한다.
|
||||
|
||||
### `go test -count=1 ./apps/control-plane/...`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
ok iop/apps/control-plane/cmd/control-plane 0.147s
|
||||
ok iop/apps/control-plane/internal/wire 1.494s
|
||||
```
|
||||
|
||||
### `go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/bootstrap`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
ok iop/apps/edge/internal/node 0.006s
|
||||
ok iop/apps/edge/internal/transport 4.746s
|
||||
ok iop/apps/edge/internal/service 5.860s
|
||||
ok iop/apps/edge/internal/bootstrap 5.322s
|
||||
```
|
||||
|
||||
### `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
ok iop/apps/node/internal/transport 5.548s
|
||||
ok iop/apps/node/internal/bootstrap 1.582s
|
||||
```
|
||||
|
||||
### `bash -n scripts/e2e-provider-capacity-smoke.sh`
|
||||
|
||||
exit 0; stdout/stderr 없음.
|
||||
|
||||
### `! rg -n '(^|[^[:alnum:]_])seq([^[:alnum:]_]|$)' scripts/e2e-provider-capacity-smoke.sh`
|
||||
|
||||
exit 0; 출력 없음.
|
||||
|
||||
### `./scripts/e2e-provider-capacity-smoke.sh`
|
||||
|
||||
exit 0.
|
||||
|
||||
```text
|
||||
[provider-capacity-smoke] metrics=disabled bounded_probe_timeout_sec=2
|
||||
[provider-capacity-smoke] offline_snapshot_rejected=true
|
||||
[provider-capacity-smoke] aliases=ornith:35b,ornith-fast queue_observed=true
|
||||
[provider-capacity-smoke] backend={"calls":2,"active":0,"peak":1}
|
||||
[provider-capacity-smoke] final_provider=[{"node_id":"ornith-node","connected":true,"providers":[{"id":"ornith-provider","status":"available","health":"available","capacity":1,"in_flight":0,"queued":0,"long_context_capacity":1,"long_in_flight":0,"long_queued":0}]}]
|
||||
[provider-capacity-smoke] PASS
|
||||
```
|
||||
|
||||
`/hang` probe는 2초에 실패했고, cleanup은 initialized `FIRST_PID`/`SECOND_PID`를 포함해 terminate/wait하도록 구현됐다.
|
||||
|
||||
### `IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh`
|
||||
|
||||
exit 0 (auxiliary-only).
|
||||
|
||||
```text
|
||||
[e2e] NOTE: auxiliary smoke only; completion requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
|
||||
[e2e] Auxiliary smoke test PASSED.
|
||||
```
|
||||
|
||||
mock profile에서 node 등록, `/nodes`, `/capabilities`, `/transport`, 동일 session foreground message 2회, `/sessions`, `/terminate-session`을 확인했다. 이 결과는 direct full-cycle을 대체하지 않는다.
|
||||
|
||||
### `bash -o pipefail -c './scripts/dev/edge-node-reconnect-diagnostic.sh 2>&1 | tee /tmp/iop-capacity-smoke-full-cycle.log'`
|
||||
|
||||
exit 0. Raw output: `/tmp/iop-capacity-smoke-full-cycle.log` (136 lines).
|
||||
|
||||
```text
|
||||
[diagnostic] Message 1 completed
|
||||
[diagnostic] Message 2 completed
|
||||
edge> [node0-capabilities] ...
|
||||
edge> [node0-transport] ...
|
||||
edge> [node0-sessions] ...
|
||||
edge> terminated session default node=node0
|
||||
[diagnostic] PASS: 3 runs verified — payload sequence, one terminal after the last payload, Node==Edge; reconnect observed; all five command responses present.
|
||||
```
|
||||
|
||||
원시 Edge/Node 출력은 `/nodes`, 같은 session의 두 foreground message, Node payload와 Edge payload의 순서 일치, 마지막 payload 뒤 단 한 번의 terminal, `/capabilities`, `/transport`, `/sessions`, `/terminate-session`을 담는다.
|
||||
|
||||
### `./scripts/e2e-long-context-admission-smoke.sh --preflight --out-dir /tmp/iop-capacity-live-preflight`
|
||||
|
||||
exit 3 — external live verification blocker이며 S11 성공 근거가 아니다.
|
||||
|
||||
```text
|
||||
[long-admission-smoke] === PREFLIGHT ===
|
||||
[long-admission-smoke] BLOCKER: provider /models 및 local Control Plane status가 unreachable
|
||||
[long-admission-smoke] === PREFLIGHT BLOCKED ===
|
||||
```
|
||||
|
||||
재개 조건: live provider endpoint와 local Control Plane status 경로가 준비된 환경에서 같은 preflight를 재실행한다. 상세 evidence는 `/tmp/iop-capacity-live-preflight`에 있다.
|
||||
|
||||
## 구현 상태
|
||||
|
||||
- `review-ready`
|
||||
- 코드·mock capacity smoke·auxiliary smoke·직접 Edge-Node full-cycle은 통과했다.
|
||||
- live preflight rc=3은 환경 차단으로 남아 있으며, code-review가 성공으로 재해석하면 안 된다.
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING]** Fill implementation-owned sections, report `review-ready`, and stop. Do not execute code-review or start another agent.
|
||||
|
||||
## 섹션 소유권
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---|---|---|
|
||||
| Header, overview, Roadmap Targets, archive snapshot, checkpoints | Fixed | Implementation must not expand scope |
|
||||
| 역할 모델 | Mixed | Runtime records distinct Implementation then Review identities |
|
||||
| completion table, implementation checklist, changes, decisions, user review, verification output | Implementation | Record actual facts only |
|
||||
| review checklist and verdict | Review | Implementation must not modify |
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- implementation deviation: Pass
|
||||
- verification trust: Pass
|
||||
- spec conformance: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계: `complete.log`를 작성하고 pair와 task를 월별 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Complete - m-provider-resource-admission-ownership-alignment/16_capacity_smoke
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-07-22T10:49:09Z
|
||||
|
||||
## 요약
|
||||
|
||||
공유 provider capacity-1 local smoke와 Node disabled-metrics 동작을 보강하고 2회 리뷰 루프를 최종 PASS로 완료했다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G08_0.log` | `code_review_local_G08_0.log` | FAIL | metrics listener scope, HTTP timeout/PID cleanup, offline snapshot predicate, direct full-cycle evidence를 보완하도록 후속 계획으로 전환 |
|
||||
| `plan_local_G09_1.log` | `code_review_local_G09_1.log` | PASS | 이전 Required/Suggested를 모두 해소하고 fresh local 검증과 SDD S11 evidence를 확인 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- Node는 `metrics.port <= 0`에서 metrics listener를 시작하지 않으며, production default starter는 `observability.ServeMetrics`를 유지한다.
|
||||
- capacity smoke는 Edge/Node metrics를 비활성화하고 모든 HTTP 요청에 total timeout을 적용하며 request child PID를 종료·wait하고 `seq` 의존성을 제거한다.
|
||||
- final predicate는 configured online Node/provider identity, available health/status, capacity 1, normal/long counter 0을 함께 요구하고 synthetic offline snapshot을 거부한다.
|
||||
- two-alias capacity smoke와 direct `scripts/dev/edge.sh`/`scripts/dev/node.sh` user-flow 증거를 별도로 검증했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `gofmt -d apps/node/internal/bootstrap/module.go apps/node/internal/bootstrap/runtime_supervisor.go apps/node/internal/bootstrap/module_test.go` - PASS; 출력 없음.
|
||||
- `make proto` - PASS; protobuf 생성 완료 후 생성물 추가 diff 없음.
|
||||
- `go test -count=1 ./apps/node/internal/bootstrap ./apps/node/internal/transport ./apps/control-plane/... ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/bootstrap` - PASS; 모든 대상 패키지 fresh 통과.
|
||||
- `bash -n scripts/e2e-provider-capacity-smoke.sh` 및 `rg` 기반 `seq` 부재 검사 - PASS.
|
||||
- `./scripts/e2e-provider-capacity-smoke.sh` - PASS; metrics disabled, bounded probe 2초, offline snapshot 거부, two aliases queue 관측, backend calls=2/peak=1, online capacity 1과 normal/long counter 0 회복.
|
||||
- `IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh` - PASS; auxiliary smoke로 분리 확인.
|
||||
- `bash -o pipefail -c ./scripts/dev/edge-node-reconnect-diagnostic.sh` - PASS; raw output `/tmp/iop-capacity-smoke-full-cycle-review.log`, 같은 session의 메시지 2회와 Node/Edge payload 순서, terminal ordering, 필수 command response 확인.
|
||||
- `./scripts/e2e-long-context-admission-smoke.sh --preflight --out-dir /tmp/iop-capacity-live-preflight-review` - BLOCKED(rc=3); live provider `/models`와 local Control Plane status 미준비이며 S11 local PASS 근거로 사용하지 않음.
|
||||
- `git diff --check` - PASS; 출력 없음.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
|
||||
- Milestone link: [Milestone 문서](agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
|
||||
- Completed task ids:
|
||||
- `capacity-smoke`: PASS; evidence=`agent-task/archive/2026/07/m-provider-resource-admission-ownership-alignment/16_capacity_smoke/plan_local_G09_1.log`, `agent-task/archive/2026/07/m-provider-resource-admission-ownership-alignment/16_capacity_smoke/code_review_local_G09_1.log`; verification=`./scripts/e2e-provider-capacity-smoke.sh`, `/tmp/iop-capacity-smoke-full-cycle-review.log`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
ㄹ<!-- task=m-provider-resource-admission-ownership-alignment/16_capacity_smoke plan=0 tag=TEST -->
|
||||
|
||||
# Provider capacity smoke
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
구현 모델은 Plan/Review 모델과 다른 identity여야 한다. 구현 소유 review stub 섹션을 채우고 `review-ready`를 보고한 뒤 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
|
||||
|
||||
## 역할 모델
|
||||
|
||||
- Plan: `gpt-5.6-terra`
|
||||
- Implementation/Review: [CODE_REVIEW-local-G08.md](CODE_REVIEW-local-G08.md)의 runtime ledger가 최초 역할 시작 시 기록한다. 세 역할 identity는 모두 달라야 한다.
|
||||
|
||||
## 배경
|
||||
|
||||
남은 `capacity-smoke`는 shared capacity-1 provider를 가리키는 `ornith:35b`와 `ornith-fast`의 동시 호출, backend peak=1, 종료 뒤 normal/long counter=0을 full-cycle로 남겨야 한다. 기존 long-context smoke는 live endpoint/token/status를 전제하고 단일 model 중심이라 S11의 deterministic local 증명이 아니다. Control Plane HTTP status view도 wire의 long counter를 JSON으로 옮기지 않아 final-zero assertion이 불가능하다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
선택 Milestone의 `구현 잠금 > 결정 필요`만 [사용자 리뷰 요청](CODE_REVIEW-local-G08.md#사용자-리뷰-요청)에 기록한다. 환경·도구·process blocker는 검증 evidence와 재개 조건으로 남기며 `USER_REVIEW.md`는 code-review 소유다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
|
||||
- Milestone link: [Milestone 문서](../../../agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
|
||||
- Task ids:
|
||||
- `capacity-smoke`: two model key concurrent shared-provider smoke, backend peak 1, waiting/fallback, and final counter recovery.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`, Phase/Milestone, approved SDD (`S11`, Evidence Map S11).
|
||||
- `agent-test/local/rules.md`, `testing-smoke.md`, `edge-smoke.md`, `node-smoke.md`, `control-plane-smoke.md`; testing/edge/control-plane domain rules.
|
||||
- matching contract index plus Edge-Node, Control Plane-Edge, Edge config/refresh, OpenAI-compatible contracts.
|
||||
- `scripts/e2e-long-context-admission-smoke.sh` (전체), `scripts/e2e-openai-vllm.sh` (전체), `scripts/e2e-openai-lemonade.sh` (targeted fake provider/config lifecycle), `scripts/e2e-smoke.sh` (targeted startup), `scripts/dev/edge.sh`, `scripts/dev/node.sh`, `Makefile`.
|
||||
- `apps/control-plane/cmd/control-plane/http_views.go` (`providerSnapshotView`/mapping), `edge_registry_handler_test.go` (`TestEdgeRegistryHTTPHandlersGetLiveStatus`), `main.go`, `server.go`, `http_edge_handlers.go`.
|
||||
- `apps/edge/internal/service/status_provider.go`, `apps/edge/internal/controlplane/connector.go`, `packages/go/config/edge_types.go`, `provider_types.go`, `proto/iop/runtime.proto` long provider fields, and targeted `reconnect_readiness_integration_test.go` local config/snapshot helpers.
|
||||
|
||||
### SDD 기준
|
||||
|
||||
- SDD `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`는 `[승인됨]`, SDD 잠금 `해제`다.
|
||||
- S11 / `capacity-smoke`의 Evidence Map은 local 또는 dev-corp cross-model smoke log에 backend peak 1과 종료 후 counter 0을 기록하도록 요구한다. 따라서 two-alias temporary config, deterministic queued observation, backend stats, Control Plane normal/long final-zero assertion을 한 closure로 둔다.
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- `test_env=local`; rules/profile은 `agent-test/local/rules.md`, testing/edge/node/control-plane smoke다.
|
||||
- build/review는 checkout에서 `curl`, `jq`, `go`, loopback process/port preflight를 기록한다. 외부 endpoint와 `IOP_LONG_SMOKE_TOKEN`은 local harness 입력이 아니다.
|
||||
- static fallback은 fake provider와 temporary Edge/Node process precedent 및 Control Plane `/edges/{id}/status` handler다. 현재 process/port가 살아 있다고 가정하지 않는다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- Control Plane JSON view의 long capacity/in-flight/queued regression이 없다. handler fixture의 typed/raw JSON assertion을 추가한다.
|
||||
- Existing live smoke는 two-alias capacity-1 deterministic queue/peak/final-zero를 다루지 않는다. 새 script가 fake backend + Control Plane + Edge + Node full-cycle regression이 된다.
|
||||
- Root가 작업 중인 `apps/edge/internal/service/model_queue_admission_test.go`와 `long_context_queue_test.go`는 읽기/수정 범위에서 제외한다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
없음. rename/remove하지 않는다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- candidate `capacity-smoke`는 `16_capacity_smoke` 단일 sibling으로 유지한다. write set은 `apps/control-plane/cmd/control-plane/http_views.go`, `apps/control-plane/cmd/control-plane/edge_registry_handler_test.go`, 새 `scripts/e2e-provider-capacity-smoke.sh`다.
|
||||
- shared mutable state는 temporary loopback process/port/output뿐이다. status JSON change가 script의 long-zero proof를 가능하게 하므로 분리하면 S11 closure가 깨진다. predecessor/dependency 없음.
|
||||
- archive sibling basename은 `15+14_controlplane_connected_mapping`까지이고 active sibling은 없어 index `16`을 할당했다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- `scripts/e2e-long-context-admission-smoke.sh`는 external live smoke 역할을 유지한다. default config, contract/spec/roadmap, Edge admission source, root의 dirty contract/spec/Milestone/Edge service tests는 수정하지 않는다.
|
||||
- Control Plane change는 existing wire field의 read-only rendering뿐이며 Node scheduling, Edge state ownership, proto/config contract 의미를 바꾸지 않는다.
|
||||
|
||||
### 최종 라우팅
|
||||
|
||||
- `evaluation_mode: first-pass`.
|
||||
- build/review 모두 scope/context/verification/evidence/ownership/decision closure `true`; score `scope_coupling=2`, `state_concurrency=2`, `blast_irreversibility=1`, `evidence_diagnosis=1`, `verification_complexity=2`; lane `local`, grade `G08`.
|
||||
- canonical output: `PLAN-local-G08.md`, `CODE_REVIEW-local-G08.md`.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [TEST-1] Control Plane status JSON에 existing long provider capacity/in-flight/queued wire values를 loss 없이 전달하고 typed/raw handler regression으로 고정한다.
|
||||
- [ ] [TEST-2] temporary fake OpenAI-compatible backend, Control Plane, Edge, Node config의 local smoke에서 `ornith:35b`/`ornith-fast` 동시 호출, queue/fallback, peak=1, final normal/long zero를 evidence로 기록한다.
|
||||
- [ ] [TEST-3] `gofmt -d apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/edge_registry_handler_test.go`, `go test -count=1 ./apps/control-plane/...`, `go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/bootstrap`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap`, `bash -n scripts/e2e-provider-capacity-smoke.sh`, `./scripts/e2e-provider-capacity-smoke.sh`, `IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh` actual output을 stub에 기록한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
|
||||
|
||||
### [TEST-1] Long provider counters in Control Plane status
|
||||
|
||||
**문제**
|
||||
|
||||
`apps/control-plane/cmd/control-plane/http_views.go:147-161`와 `:195-218`은 normal fields만 JSON view에 복사한다. `proto/iop/runtime.proto:201-203` long fields를 local status poll에서 확인할 수 없다.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
`providerSnapshotView`에 `long_context_capacity`, `long_in_flight`, `long_queued` int32 fields를 추가하고 mapping이 corresponding getters를 복사한다. `TestEdgeRegistryHTTPHandlersGetLiveStatus` fixture에 nonzero long values를 넣어 typed view와 raw JSON을 확인하며 no-secret assertion은 유지한다.
|
||||
|
||||
Before (`apps/control-plane/cmd/control-plane/http_views.go:147-161`):
|
||||
|
||||
```go
|
||||
InFlight int32
|
||||
Queued int32
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```go
|
||||
InFlight int32
|
||||
Queued int32
|
||||
LongContextCapacity int32
|
||||
LongInFlight int32
|
||||
LongQueued int32
|
||||
```
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] `apps/control-plane/cmd/control-plane/http_views.go`: view fields와 getter mapping만 추가한다.
|
||||
- [ ] `apps/control-plane/cmd/control-plane/edge_registry_handler_test.go`: fixture, typed-view, raw JSON assertions을 추가한다.
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
- 작성: existing `TestEdgeRegistryHTTPHandlersGetLiveStatus`가 `ProviderSnapshot` → HTTP view → JSON long values 보존을 확인한다.
|
||||
|
||||
### [TEST-2] Deterministic local two-alias capacity smoke
|
||||
|
||||
**문제**
|
||||
|
||||
`scripts/e2e-long-context-admission-smoke.sh:1-584`는 live endpoint/token/status URL과 one `MODEL` scenario다. S11 two-alias shared capacity-1 proof를 no-secret local default로 만들 수 없다.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
새 script가 `mktemp -d` 아래 fake provider, temporary Control Plane/Edge/Node YAML, logs/evidence를 만들고 cleanup trap으로 process tree를 정리한다. fake backend는 첫 request를 release sentinel까지 hold하고 stats endpoint에서 active/peak를 제공한다. `models[]`의 `ornith:35b`와 `ornith-fast`는 하나의 capacity-1, long-context-capacity-1 resource로 map한다. 두 curl을 병렬 시작해 status `in_flight=1`/`queued>=1`을 확인한 뒤 release하고, two 200, backend peak=1/active=0, every provider normal/long final zero를 jq로 assert한다.
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] 새 `scripts/e2e-provider-capacity-smoke.sh`: loopback-only config, deterministic readiness/timeouts, concurrent requests, status/stat assertions, temp evidence path, secret-free output, safe cleanup.
|
||||
- [ ] `scripts/e2e-long-context-admission-smoke.sh`, default config, contracts/spec/roadmap, Edge admission implementation은 수정하지 않는다.
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
- 작성: new script가 regression/full-cycle harness다. second backend concurrent request, queue observation 부재, non-200 alias response, unavailable status, final normal/long nonzero에서 fail해야 한다.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|---|---|
|
||||
| `apps/control-plane/cmd/control-plane/http_views.go` | TEST-1 |
|
||||
| `apps/control-plane/cmd/control-plane/edge_registry_handler_test.go` | TEST-1 |
|
||||
| `scripts/e2e-provider-capacity-smoke.sh` | TEST-2 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
gofmt -d apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/edge_registry_handler_test.go
|
||||
go test -count=1 ./apps/control-plane/...
|
||||
go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/bootstrap
|
||||
go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap
|
||||
bash -n scripts/e2e-provider-capacity-smoke.sh
|
||||
./scripts/e2e-provider-capacity-smoke.sh
|
||||
IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh
|
||||
```
|
||||
|
||||
Expected: format diff 없음; tests/smokes exit 0; evidence shows two aliases, queue/fallback, backend peak 1, all provider normal/long counters 0. Go test cache is not acceptable. External live preflight is not a S11 pass prerequisite.
|
||||
|
||||
검증 결과를 review stub에 기록하고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
<!-- task=m-provider-resource-admission-ownership-alignment/16_capacity_smoke plan=1 tag=REVIEW_TEST -->
|
||||
|
||||
# Provider capacity smoke follow-up
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
구현 모델은 Plan/Review 모델과 다른 identity여야 한다. 구현 소유 review stub 섹션을 채우고 `review-ready`를 보고한 뒤 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
|
||||
|
||||
## 역할 모델
|
||||
|
||||
- Plan: `gpt-5.6-terra`
|
||||
- Implementation: runtime ledger에 `gpt-5`로 기록한다. Plan/Review identity와 달라야 한다.
|
||||
- Review: runtime ledger에 `gpt-5.6-sol`로 기록한다. Plan/Implementation identity와 달라야 한다.
|
||||
|
||||
## 배경
|
||||
|
||||
이전 local smoke는 shared-provider queue와 peak=1을 보였지만 metrics listener가 wildcard로 열리고 HTTP probe가 무기한 멈출 수 있으며, offline snapshot도 final-zero recovery로 오인했다. S11은 두 alias의 실제 full-cycle과 online provider identity를 유지한 counter recovery를 요구한다. 보조 `e2e-smoke.sh`는 직접 `scripts/dev/edge.sh`/`scripts/dev/node.sh` user-flow를 대체하지 않는다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
선택 Milestone의 `구현 잠금 > 결정 필요`만 [사용자 리뷰 요청](CODE_REVIEW-local-G09.md#사용자-리뷰-요청)에 기록한다. 현재 lock은 해제이고 결정 필요는 없다. 환경·도구·process blocker는 검증 evidence와 재개 조건으로 기록하며, 구현 중 직접 사용자에게 묻지 않고 `USER_REVIEW.md` 작성은 code-review 소유다.
|
||||
|
||||
## Archive Evidence Snapshot
|
||||
|
||||
- 이전 pair: `agent-task/m-provider-resource-admission-ownership-alignment/16_capacity_smoke/plan_local_G08_0.log`, `code_review_local_G08_0.log`; verdict `FAIL`.
|
||||
- Required: metrics wildcard listener, total timeout/PID cleanup 부재, offline zero snapshot false-positive, auxiliary-only smoke를 direct full-cycle evidence로 대체한 기록을 해결한다.
|
||||
- Suggested: hidden `seq` dependency를 제거한다. Nit: header 앞 stray character를 재발시키지 않는다.
|
||||
- 이전 source evidence: `scripts/e2e-provider-capacity-smoke.sh`, `apps/node/internal/bootstrap/runtime_supervisor.go`; Control Plane long-counter view와 handler test는 이미 dirty worktree에 존재하므로 수정·재작성하지 않는다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
|
||||
- Milestone link: [Milestone 문서](../../../agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
|
||||
- Task ids:
|
||||
- `capacity-smoke`: shared capacity-1 two-alias smoke, backend peak 1, online snapshot recovery, direct full-cycle evidence.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `AGENTS.md`, `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/plan/SKILL.md`, `agent-ops/skills/common/finalize-task-routing/SKILL.md`.
|
||||
- `agent-ops/rules/project/domain/control-plane/rules.md`, `edge/rules.md`, `node/rules.md`, `testing/rules.md`; `agent-test/local/rules.md`, `control-plane-smoke.md`, `edge-smoke.md`, `node-smoke.md`, `testing-smoke.md`, `platform-common-smoke.md`; `agent-ops/skills/project/e2e-smoke/SKILL.md`.
|
||||
- selected Milestone, SDD (`S11`, `S14`, Evidence Map S11), `agent-contract/index.md`, `agent-contract/inner/control-plane-edge-wire.md`, `agent-spec/index.md`, `agent-spec/control/control-plane-operations.md`.
|
||||
- `plan_local_G08_0.log`, `code_review_local_G08_0.log`.
|
||||
- `scripts/e2e-provider-capacity-smoke.sh` (전체), `scripts/e2e-smoke.sh`와 `scripts/dev/edge.sh`/`node.sh` (targeted startup/full-cycle regions), `scripts/dev/edge-node-reconnect-diagnostic.sh` (targeted direct-flow evidence regions).
|
||||
- `packages/go/observability/observability.go`와 `observability_test.go` (전체), `apps/node/internal/bootstrap/module.go`, `runtime_supervisor.go`, `module_test.go` (targeted Module/supervisor fixtures), `apps/edge/internal/bootstrap/runtime.go` (`startMetrics`), `apps/control-plane/cmd/control-plane/http_views.go`와 `edge_registry_handler_test.go` (targeted status view/test), `apps/edge/internal/service/status_provider.go` (offline snapshot range), `apps/edge/internal/controlplane/connector.go` (status response range), `proto/iop/runtime.proto` (ProviderSnapshot range).
|
||||
|
||||
### SDD 기준
|
||||
|
||||
- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`, status approved/unlocked.
|
||||
- S11 / `capacity-smoke`: two aliases sharing capacity 1 must leave backend peak 1 and all counters recovered; Evidence Map S11 requires a local or dev-corp cross-model smoke log.
|
||||
- S14 / `offline-snapshot`: `connected=false`, `unavailable`/`offline`, and zero effective capacity are an offline state, not successful online recovery. Therefore final predicate requires `ornith-node` connected plus the configured `ornith-provider` available/healthy capacity before it accepts zero counters.
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- `test_env=local`; read profiles are the files above. Fresh Go results require `-count=1`; `make proto` is a local quick check and no generated diff is permitted.
|
||||
- The harness uses temporary config and loopback ports only. It must not require a live endpoint or a secret. Every HTTP probe uses its helper's `--connect-timeout` and total `--max-time`; each request child PID is cleaned up on every exit path.
|
||||
- Direct full-cycle evidence is a separate `scripts/dev/edge.sh` plus `scripts/dev/node.sh` diagnostic with raw Edge/Node output. `e2e-smoke.sh` remains auxiliary. Live preflight is recorded separately; rc=3 is a blocker, not S11 pass.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- Node currently starts `ServeMetrics(0)` and can bind an ephemeral wildcard listener. Add a deterministic injected metrics-starter regression that proves `metrics.port <= 0` does not invoke a listener.
|
||||
- The shell harness has no unit-test runner. Its executable regression must fail for a hanging peer, missing queue/200/peak evidence, offline snapshot, or nonzero final counters; `bash -n` covers syntax only.
|
||||
- Existing Control Plane long-counter mapping/typed/raw JSON regression is preserved, not edited.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
없음. rename/remove하지 않는다. New test-only `WithMetricsStarter` is consumed by `module_test.go`; production `Module` supplies `observability.ServeMetrics` and `runtimeSupervisor.start` invokes it only for a positive port.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- candidate `capacity-smoke` remains the single sibling `16_capacity_smoke`; write set is `apps/node/internal/bootstrap/module.go`, `apps/node/internal/bootstrap/runtime_supervisor.go`, `apps/node/internal/bootstrap/module_test.go`, and `scripts/e2e-provider-capacity-smoke.sh`.
|
||||
- The metrics disable guarantee and the script's `metrics.port: 0` need one closure; timeout/cleanup, online recovery predicate, and two-alias evidence share the same temporary process state. No predecessor, parallel sibling, or unresolved collision exists.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- Do not edit the dirty Control Plane view/handler test, root-owned Edge service tests, contract/spec/roadmap files, default configs, `scripts/e2e-long-context-admission-smoke.sh`, or admission source. This only hardens the S11 local harness and Node's disabled-metrics behavior.
|
||||
- Do not add a metrics bind-address schema. `port <= 0` is already disabled by Edge and Control Plane; make Node match it and set both temporary Edge/Node configs to zero.
|
||||
|
||||
### 최종 라우팅
|
||||
|
||||
- `evaluation_mode: isolated-reassessment`; previous lane/grade/score/rationale were excluded from the input.
|
||||
- build closures: scope/context/verification/evidence/ownership/decision all `true` — bounded four-file change, static FAIL evidence, local deterministic commands, root-owned dirty files excluded, no Milestone decision pending. Scores: `scope_coupling=2`, `state_concurrency=2`, `blast_irreversibility=1`, `evidence_diagnosis=2`, `verification_complexity=2`; lane `local`, grade `G09`, canonical `PLAN-local-G09.md`.
|
||||
- review closures: scope/context/verification/evidence/ownership/decision all `true` — review can inspect code plus saved raw output and rerun local bounded commands without external ownership. Scores: `scope_coupling=2`, `state_concurrency=2`, `blast_irreversibility=1`, `evidence_diagnosis=2`, `verification_complexity=2`; lane `local`, grade `G09`, canonical `CODE_REVIEW-local-G09.md`.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [REVIEW_TEST-1] Node `metrics.port <= 0`는 listener를 시작하지 않게 하고 smoke의 Edge/Node metrics를 모두 disabled로 고정한다.
|
||||
- [ ] [REVIEW_TEST-2] 모든 harness HTTP probe에 total timeout을 적용하고 request child PID까지 cleanup하며 hidden `seq` dependency를 없앤다.
|
||||
- [ ] [REVIEW_TEST-3] final status가 online configured `ornith-node`/`ornith-provider` identity와 capacity를 보존하면서 normal/long counters zero인 경우에만 pass하도록 한다.
|
||||
- [ ] [REVIEW_TEST-4] fresh Go/script/capacity smoke, auxiliary smoke, 그리고 direct dev Edge-Node full-cycle의 raw evidence를 review stub에 기록한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
|
||||
|
||||
### [REVIEW_TEST-1] Disable temporary metrics listeners
|
||||
|
||||
**문제**
|
||||
|
||||
`scripts/e2e-provider-capacity-smoke.sh:245-246,302-303` gives Edge and Node positive metrics ports. `packages/go/observability/observability.go:96-106` listens on `:<port>`; while Edge already skips `port <= 0`, `apps/node/internal/bootstrap/runtime_supervisor.go:48-52` does not.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
Add `metricsStarter func(int) error` to the Node module test options, default it to `observability.ServeMetrics`, pass it to `runtimeSupervisor`, and guard the goroutine with `cfg.Metrics.Port > 0`. Add `WithMetricsStarter` only as the test seam. The smoke writes `metrics.port: 0` for both Edge and Node and removes metrics-port allocation; a test starter must observe zero calls for disabled Node metrics.
|
||||
|
||||
Before (`apps/node/internal/bootstrap/runtime_supervisor.go:48-52`):
|
||||
|
||||
```go
|
||||
go func() {
|
||||
if err := observability.ServeMetrics(s.cfg.Metrics.Port); err != nil {
|
||||
s.logger.Warn("metrics server exited", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```go
|
||||
if s.cfg.Metrics.Port > 0 {
|
||||
go func() {
|
||||
if err := s.metricsStarter(s.cfg.Metrics.Port); err != nil {
|
||||
s.logger.Warn("metrics server exited", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] `apps/node/internal/bootstrap/module.go`: default/test-option wiring only; existing production caller remains `observability.ServeMetrics`.
|
||||
- [ ] `apps/node/internal/bootstrap/runtime_supervisor.go`: positive-port guard and injected starter only.
|
||||
- [ ] `apps/node/internal/bootstrap/module_test.go`: `TestSupervisorDoesNotStartMetricsWhenPortDisabled` starts Module with `Metrics.Port=0`, a non-blocking dial/sleeper fixture, and a counting starter; assert no call before clean stop.
|
||||
- [ ] `scripts/e2e-provider-capacity-smoke.sh`: set both temporary metrics ports to `0`, remove their port selection, and log `metrics=disabled` as harness evidence.
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
- 작성: the new module test is the Node listener-scope regression; the capacity script is the Edge+Node integration regression. No public config/wire API changes.
|
||||
|
||||
### [REVIEW_TEST-2] Bound every HTTP probe and clean request children
|
||||
|
||||
**문제**
|
||||
|
||||
`scripts/e2e-provider-capacity-smoke.sh:86-96,321,333,343,366,378,407,413` has probes with only connect timeout or none. A peer that accepts TCP but never responds can hang a loop. `FIRST_PID`/`SECOND_PID` are assigned only after launch and omitted from `cleanup`.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
Define one curl helper that always applies `--connect-timeout 1 --max-time 2` (with a named larger total timeout only for the two completion requests); route readiness, enrollment, status, models, and stats calls through it. Initialize request PIDs to empty and include them in the terminate/wait loops. Replace `for _ in $(seq 1 100)` with a Bash arithmetic loop.
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] `scripts/e2e-provider-capacity-smoke.sh`: central helper, all probe call sites, PID initialization/cleanup, arithmetic retry loop, and no new external tools.
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
- 작성: add a loopback hang-server fault mode/fixture to the script's temporary fake backend or a focused shell branch; its probe must fail within the helper deadline and cleanup must wait request PIDs. Record elapsed/exit evidence in the stub. `bash -n` and the ordinary smoke remain separate checks.
|
||||
|
||||
### [REVIEW_TEST-3] Require online identity for final recovery
|
||||
|
||||
**문제**
|
||||
|
||||
`scripts/e2e-provider-capacity-smoke.sh:411-418` accepts any nonempty provider list with four counters zero. The S14 offline shape (`connected=false`, `status=unavailable`, `health=offline`, effective capacity zero) therefore passes without proving recovery.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
Keep the existing queued-state assertion, then make the final jq predicate require exactly the configured `ornith-node` connected, exactly one `ornith-provider`, `status=="available"`, `health=="available"`, normal and long configured capacity `1`, and all normal/long counters zero. Add a synthetic offline JSON negative assertion in the script before the live final poll so the predicate must reject it.
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] `scripts/e2e-provider-capacity-smoke.sh`: strict final predicate, synthetic offline negative fixture/assertion, and final output that includes node connectivity, provider status/health/capacities with counters.
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
- 작성: the synthetic offline fixture is the regression for the prior false-positive; the live two-alias run proves the positive path. Do not modify Edge snapshot ownership/source.
|
||||
|
||||
### [REVIEW_TEST-4] Preserve S11 and direct full-cycle evidence separately
|
||||
|
||||
**문제**
|
||||
|
||||
Previous evidence treated `scripts/e2e-smoke.sh` as sufficient even though it declares itself auxiliary-only. S11 needs the capacity harness, while local testing rules require a separately captured direct Edge/Node user-flow.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
Run the capacity harness as S11 evidence, then run `scripts/dev/edge-node-reconnect-diagnostic.sh`, which starts `scripts/dev/edge.sh` and `scripts/dev/node.sh` as distinct processes with temporary config. Save its raw Edge and Node output outside the repo and paste/point to it in the review stub. Verify `/nodes`, two same-session foreground messages, run-local versus Edge payload order, terminal-after-last-payload, `/capabilities`, `/transport`, `/sessions`, and `/terminate-session`; label `e2e-smoke.sh` auxiliary only.
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] No additional product/test source file. Fill the review stub with actual raw command output, output-file path, and any blocker/restart condition.
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
- 작성: direct diagnostic output is required execution evidence, not a replacement unit test. A missing direct cycle is FAIL even when auxiliary smoke passes.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|---|---|
|
||||
| `apps/node/internal/bootstrap/module.go` | REVIEW_TEST-1 |
|
||||
| `apps/node/internal/bootstrap/runtime_supervisor.go` | REVIEW_TEST-1 |
|
||||
| `apps/node/internal/bootstrap/module_test.go` | REVIEW_TEST-1 |
|
||||
| `scripts/e2e-provider-capacity-smoke.sh` | REVIEW_TEST-1, REVIEW_TEST-2, REVIEW_TEST-3 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
gofmt -d apps/node/internal/bootstrap/module.go apps/node/internal/bootstrap/runtime_supervisor.go apps/node/internal/bootstrap/module_test.go
|
||||
make proto
|
||||
go test -count=1 ./apps/node/internal/bootstrap
|
||||
go test -count=1 ./apps/control-plane/...
|
||||
go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/bootstrap
|
||||
go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap
|
||||
bash -n scripts/e2e-provider-capacity-smoke.sh
|
||||
! rg -n '(^|[^[:alnum:]_])seq([^[:alnum:]_]|$)' scripts/e2e-provider-capacity-smoke.sh
|
||||
./scripts/e2e-provider-capacity-smoke.sh
|
||||
IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh
|
||||
bash -o pipefail -c './scripts/dev/edge-node-reconnect-diagnostic.sh 2>&1 | tee /tmp/iop-capacity-smoke-full-cycle.log'
|
||||
./scripts/e2e-long-context-admission-smoke.sh --preflight --out-dir /tmp/iop-capacity-live-preflight
|
||||
```
|
||||
|
||||
Expected: no gofmt/proto diff; fresh tests and capacity/auxiliary/direct diagnostics exit 0; `rg` has no output; capacity evidence proves metrics disabled, bounded probes/cleanup, two aliases, queue, backend peak 1, connected available provider capacity 1, and normal/long final zero. The direct output records two same-session messages and required command/terminal ordering. Preflight rc=3 is recorded as external blocker and never S11 success. Go test cache is not acceptable.
|
||||
|
||||
검증 결과를 review stub에 기록하고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
|
||||
|
|
@ -240,6 +240,9 @@ func TestEdgeRegistryHTTPHandlersGetLiveStatus(t *testing.T) {
|
|||
Capacity: 4,
|
||||
InFlight: 1,
|
||||
Queued: 2,
|
||||
LongContextCapacity: 3,
|
||||
LongInFlight: 1,
|
||||
LongQueued: 2,
|
||||
Id: "provider-vllm-primary",
|
||||
Type: "vllm",
|
||||
Category: "api",
|
||||
|
|
@ -313,6 +316,9 @@ func TestEdgeRegistryHTTPHandlersGetLiveStatus(t *testing.T) {
|
|||
if p0.InFlight != 1 || p0.Queued != 2 {
|
||||
t.Fatalf("unexpected in_flight/queued: in_flight=%d queued=%d", p0.InFlight, p0.Queued)
|
||||
}
|
||||
if p0.LongContextCapacity != 3 || p0.LongInFlight != 1 || p0.LongQueued != 2 {
|
||||
t.Fatalf("unexpected long counters: capacity=%d in_flight=%d queued=%d", p0.LongContextCapacity, p0.LongInFlight, p0.LongQueued)
|
||||
}
|
||||
if p0.ID != "provider-vllm-primary" || p0.Type != "vllm" || p0.Category != "api" {
|
||||
t.Fatalf("unexpected id/type/category: id=%q type=%q category=%q", p0.ID, p0.Type, p0.Category)
|
||||
}
|
||||
|
|
@ -386,6 +392,9 @@ func TestEdgeRegistryHTTPHandlersGetLiveStatus(t *testing.T) {
|
|||
if rawP0["capacity"] != float64(4) {
|
||||
t.Fatalf("provider_snapshots[0].capacity: got %v", rawP0["capacity"])
|
||||
}
|
||||
if rawP0["long_context_capacity"] != float64(3) || rawP0["long_in_flight"] != float64(1) || rawP0["long_queued"] != float64(2) {
|
||||
t.Fatalf("provider_snapshots[0] long counters: got %#v", rawP0)
|
||||
}
|
||||
if rawP0["load_ratio"] != float64(0.25) {
|
||||
t.Fatalf("provider_snapshots[0].load_ratio: got %v", rawP0["load_ratio"])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,9 @@ type providerSnapshotView struct {
|
|||
Capacity int32 `json:"capacity"`
|
||||
InFlight int32 `json:"in_flight"`
|
||||
Queued int32 `json:"queued"`
|
||||
LongContextCapacity int32 `json:"long_context_capacity"`
|
||||
LongInFlight int32 `json:"long_in_flight"`
|
||||
LongQueued int32 `json:"long_queued"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
|
|
@ -204,6 +207,9 @@ func providerSnapshotViews(snaps []*iop.ProviderSnapshot) []providerSnapshotView
|
|||
Capacity: p.GetCapacity(),
|
||||
InFlight: p.GetInFlight(),
|
||||
Queued: p.GetQueued(),
|
||||
LongContextCapacity: p.GetLongContextCapacity(),
|
||||
LongInFlight: p.GetLongInFlight(),
|
||||
LongQueued: p.GetLongQueued(),
|
||||
ID: p.GetId(),
|
||||
Type: p.GetType(),
|
||||
Category: p.GetCategory(),
|
||||
|
|
|
|||
|
|
@ -356,17 +356,22 @@ func TestModelQueueReasonCapacityFull(t *testing.T) {
|
|||
<-done
|
||||
}
|
||||
|
||||
func TestProviderLongCapacitySharedAcrossModelGroups(t *testing.T) {
|
||||
entry := &edgenode.NodeEntry{NodeID: "node-long-shared"}
|
||||
func TestProviderLongCapacitySharedAcrossOrnithModelAliases(t *testing.T) {
|
||||
const (
|
||||
nodeID = "rtx5090-lemonade-node"
|
||||
providerID = "rtx5090-lemonade"
|
||||
)
|
||||
modelAliases := []string{"ornith:35b", "ornith-fast"}
|
||||
entry := &edgenode.NodeEntry{NodeID: nodeID}
|
||||
cands := []candidateNode{
|
||||
{entry: entry, capacity: 5, providerID: "prov-long-shared", longContextCapacity: 1},
|
||||
{entry: entry, capacity: 5, providerID: providerID, longContextCapacity: 1},
|
||||
}
|
||||
store := edgenode.NewNodeStore()
|
||||
store.Add(&edgenode.NodeRecord{
|
||||
ID: "node-long-shared",
|
||||
ID: nodeID,
|
||||
Providers: []config.NodeProviderConf{
|
||||
{
|
||||
ID: "prov-long-shared",
|
||||
ID: providerID,
|
||||
Capacity: 5,
|
||||
LongContextCapacity: 1,
|
||||
Adapter: "vllm",
|
||||
|
|
@ -384,7 +389,7 @@ func TestProviderLongCapacitySharedAcrossModelGroups(t *testing.T) {
|
|||
<-start
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
groupKey := "g-long-shared-" + string(rune('a'+id))
|
||||
groupKey := modelAliases[id]
|
||||
sel, err := m.admit(ctx, groupKey, "", "", cands, groupPolicy{maxQueue: 1, queueTimeout: 30 * time.Millisecond}, nil, true, false)
|
||||
resCh <- concurrentAdmitResult{candidate: sel, err: err}
|
||||
}(i)
|
||||
|
|
@ -414,7 +419,7 @@ func TestProviderLongCapacitySharedAcrossModelGroups(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify long peak was 1
|
||||
if got := m.longInFlightForProvider("node-long-shared", "prov-long-shared"); got != 1 {
|
||||
if got := m.longInFlightForProvider(nodeID, providerID); got != 1 {
|
||||
t.Errorf("expected long peak to be 1, got %d", got)
|
||||
}
|
||||
|
||||
|
|
@ -423,7 +428,7 @@ func TestProviderLongCapacitySharedAcrossModelGroups(t *testing.T) {
|
|||
m.releaseRun("run-shared-1", "complete")
|
||||
|
||||
// Verify long counts recovered to 0
|
||||
if got := m.longInFlightForProvider("node-long-shared", "prov-long-shared"); got != 0 {
|
||||
if got := m.longInFlightForProvider(nodeID, providerID); got != 0 {
|
||||
t.Errorf("expected long counts to recover to 0 after release, got %d", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -463,16 +463,21 @@ func TestModelQueueContextCancelRemovesQueuedItem(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProviderResourceCapacitySharedAcrossModelGroups(t *testing.T) {
|
||||
entry := &edgenode.NodeEntry{NodeID: "node-shared"}
|
||||
func TestProviderResourceCapacitySharedAcrossOrnithModelAliases(t *testing.T) {
|
||||
const (
|
||||
nodeID = "rtx5090-lemonade-node"
|
||||
providerID = "rtx5090-lemonade"
|
||||
)
|
||||
modelAliases := []string{"ornith:35b", "ornith-fast"}
|
||||
entry := &edgenode.NodeEntry{NodeID: nodeID}
|
||||
cands := []candidateNode{
|
||||
{entry: entry, capacity: 1, providerID: "prov-shared"},
|
||||
{entry: entry, capacity: 1, providerID: providerID},
|
||||
}
|
||||
store := edgenode.NewNodeStore()
|
||||
store.Add(&edgenode.NodeRecord{
|
||||
ID: "node-shared",
|
||||
ID: nodeID,
|
||||
Providers: []config.NodeProviderConf{
|
||||
{ID: "prov-shared", Capacity: 1, Adapter: "vllm"},
|
||||
{ID: providerID, Capacity: 1, Adapter: "vllm"},
|
||||
},
|
||||
})
|
||||
m := newModelQueueManager(store)
|
||||
|
|
@ -486,7 +491,7 @@ func TestProviderResourceCapacitySharedAcrossModelGroups(t *testing.T) {
|
|||
<-start
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
groupKey := "g-shared-" + string(rune('a'+id))
|
||||
groupKey := modelAliases[id]
|
||||
sel, err := m.admit(ctx, groupKey, "", "", cands, groupPolicy{maxQueue: 1, queueTimeout: 30 * time.Millisecond}, nil, false, false)
|
||||
resCh <- concurrentAdmitResult{candidate: sel, err: err}
|
||||
}(i)
|
||||
|
|
@ -501,9 +506,11 @@ func TestProviderResourceCapacitySharedAcrossModelGroups(t *testing.T) {
|
|||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
var successSel *candidateNode
|
||||
for _, res := range results {
|
||||
if res.err == nil {
|
||||
successCount++
|
||||
successSel = res.candidate
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
|
|
@ -515,7 +522,7 @@ func TestProviderResourceCapacitySharedAcrossModelGroups(t *testing.T) {
|
|||
|
||||
// Verify in-flight count peak is 1
|
||||
m.mu.Lock()
|
||||
res := m.resources[providerResourceKey{nodeID: "node-shared", providerID: "prov-shared"}]
|
||||
res := m.resources[providerResourceKey{nodeID: nodeID, providerID: providerID}]
|
||||
if res == nil {
|
||||
m.mu.Unlock()
|
||||
t.Fatal("expected provider resource state to exist")
|
||||
|
|
@ -525,8 +532,8 @@ func TestProviderResourceCapacitySharedAcrossModelGroups(t *testing.T) {
|
|||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Release slot
|
||||
m.releaseSlot("g-shared-a", "node-shared", "prov-shared")
|
||||
// Release the lease held by whichever alias won the shared provider slot.
|
||||
m.releaseLease(successSel.leaseID, "test-complete")
|
||||
|
||||
// Verify count is 0
|
||||
m.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type Option func(*moduleOpts)
|
|||
type moduleOpts struct {
|
||||
dialer DialFunc
|
||||
sleeper func(ctx context.Context, d time.Duration)
|
||||
metricsStarter func(port int) error
|
||||
}
|
||||
|
||||
// WithDialer replaces the transport dial function.
|
||||
|
|
@ -49,6 +50,11 @@ func WithSleeper(fn func(ctx context.Context, d time.Duration)) Option {
|
|||
return func(o *moduleOpts) { o.sleeper = fn }
|
||||
}
|
||||
|
||||
// WithMetricsStarter replaces the metrics listener starter for tests.
|
||||
func WithMetricsStarter(fn func(port int) error) Option {
|
||||
return func(o *moduleOpts) { o.metricsStarter = fn }
|
||||
}
|
||||
|
||||
// runtimeOwner holds one connection's resources and closes them idempotently.
|
||||
type runtimeOwner struct {
|
||||
reg *adapters.Registry
|
||||
|
|
@ -134,6 +140,7 @@ func connectRuntime(ctx context.Context, cfg *config.NodeConfig, logger *zap.Log
|
|||
func Module(cfg *config.NodeConfig, opts ...Option) fx.Option {
|
||||
mo := &moduleOpts{
|
||||
dialer: transport.DialEdge,
|
||||
metricsStarter: observability.ServeMetrics,
|
||||
sleeper: func(ctx context.Context, d time.Duration) {
|
||||
if d <= 0 {
|
||||
return
|
||||
|
|
@ -165,6 +172,7 @@ func Module(cfg *config.NodeConfig, opts ...Option) fx.Option {
|
|||
logger: logger,
|
||||
dialer: mo.dialer,
|
||||
sleeper: mo.sleeper,
|
||||
metricsStarter: mo.metricsStarter,
|
||||
shutdowner: shutdowner,
|
||||
}
|
||||
lc.Append(fx.Hook{
|
||||
|
|
|
|||
|
|
@ -425,6 +425,70 @@ func TestSupervisorShutdownCancelsRetry(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSupervisorDoesNotStartMetricsWhenPortDisabled guards the Node default
|
||||
// against treating port zero as an ephemeral wildcard listener. It waits for
|
||||
// the reconnect supervisor to be active before asserting the injected starter
|
||||
// was never called, then verifies normal shutdown still cancels the retry.
|
||||
func TestSupervisorDoesNotStartMetricsWhenPortDisabled(t *testing.T) {
|
||||
useTempCwd(t)
|
||||
_, _, addr := freeAddrForBootstrap(t)
|
||||
|
||||
fakeDialer := bootstrap.DialFunc(func(_ context.Context, _, _ string, _ *zap.Logger) (*transport.RegisterResult, error) {
|
||||
return nil, errors.New("edge unavailable")
|
||||
})
|
||||
sleeping := make(chan struct{}, 1)
|
||||
fakeSleeper := func(ctx context.Context, _ time.Duration) {
|
||||
select {
|
||||
case sleeping <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-ctx.Done()
|
||||
}
|
||||
metricsStarted := make(chan struct{}, 1)
|
||||
metricsStarter := func(int) error {
|
||||
metricsStarted <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := &config.NodeConfig{
|
||||
Transport: config.TransportConf{EdgeAddr: addr, Token: "tok"},
|
||||
Reconnect: config.ReconnectConf{MaxAttempts: 0, IntervalSec: 1},
|
||||
Logging: config.LoggingConf{Level: "error"},
|
||||
Metrics: config.MetricsConf{Port: 0},
|
||||
}
|
||||
app := fx.New(
|
||||
bootstrap.Module(
|
||||
cfg,
|
||||
bootstrap.WithDialer(fakeDialer),
|
||||
bootstrap.WithSleeper(fakeSleeper),
|
||||
bootstrap.WithMetricsStarter(metricsStarter),
|
||||
),
|
||||
fx.NopLogger,
|
||||
)
|
||||
startCtx, startCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer startCancel()
|
||||
if err := app.Start(startCtx); err != nil {
|
||||
t.Fatalf("app start: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-sleeping:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout waiting for supervisor to enter retry sleep")
|
||||
}
|
||||
select {
|
||||
case <-metricsStarted:
|
||||
t.Fatal("metrics starter must not run when metrics port is disabled")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer stopCancel()
|
||||
if err := app.Stop(stopCtx); err != nil {
|
||||
t.Fatalf("app stop must be clean, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupervisorShutdownWinsInFlightDialFatal fixes the ordering between a
|
||||
// local stop and a dialer that observes cancellation but returns a fatal result
|
||||
// afterward. Cancellation must produce a clean stop, no exit 1, and no retry.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
|
||||
"iop/apps/node/internal/transport"
|
||||
"iop/packages/go/config"
|
||||
"iop/packages/go/observability"
|
||||
)
|
||||
|
||||
// runtimeSupervisor owns the node's single active edge connection across its
|
||||
|
|
@ -23,6 +22,7 @@ type runtimeSupervisor struct {
|
|||
logger *zap.Logger
|
||||
dialer DialFunc
|
||||
sleeper func(ctx context.Context, d time.Duration)
|
||||
metricsStarter func(port int) error
|
||||
shutdowner fx.Shutdowner
|
||||
|
||||
mu sync.Mutex
|
||||
|
|
@ -45,11 +45,13 @@ func (s *runtimeSupervisor) start(_ context.Context) error {
|
|||
s.run(supCtx)
|
||||
}()
|
||||
|
||||
if s.cfg.Metrics.Port > 0 {
|
||||
go func() {
|
||||
if err := observability.ServeMetrics(s.cfg.Metrics.Port); err != nil {
|
||||
if err := s.metricsStarter(s.cfg.Metrics.Port); err != nil {
|
||||
s.logger.Warn("metrics server exited", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
520
scripts/e2e-provider-capacity-smoke.sh
Executable file
520
scripts/e2e-provider-capacity-smoke.sh
Executable file
|
|
@ -0,0 +1,520 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Deterministic local provider-pool capacity smoke.
|
||||
#
|
||||
# This starts a loopback-only fake OpenAI-compatible backend, Control Plane,
|
||||
# Edge, and Node. Two external model aliases share one capacity-1 provider.
|
||||
# The fake backend holds its first request until this script creates a release
|
||||
# sentinel, so the second alias must remain queued at Edge. The smoke then
|
||||
# proves backend peak concurrency stays at one and the Control Plane status
|
||||
# returns every normal/long counter to zero.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TMP_DIR="$(mktemp -d /tmp/iop-provider-capacity-smoke.XXXXXX)"
|
||||
KEEP_TMP="${IOP_PROVIDER_CAPACITY_SMOKE_KEEP_TMP:-0}"
|
||||
HTTP_CONNECT_TIMEOUT=1
|
||||
HTTP_PROBE_TIMEOUT=2
|
||||
HTTP_COMPLETION_TIMEOUT=50
|
||||
|
||||
FAKE_PID=""
|
||||
CP_PID=""
|
||||
EDGE_PID=""
|
||||
NODE_PID=""
|
||||
FIRST_PID=""
|
||||
SECOND_PID=""
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
local pid
|
||||
for pid in "$FIRST_PID" "$SECOND_PID" "$NODE_PID" "$EDGE_PID" "$CP_PID" "$FAKE_PID"; do
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
for pid in "$FIRST_PID" "$SECOND_PID" "$NODE_PID" "$EDGE_PID" "$CP_PID" "$FAKE_PID"; do
|
||||
if [ -n "$pid" ]; then
|
||||
wait "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "[provider-capacity-smoke] FAIL evidence=$TMP_DIR"
|
||||
for log_file in "$TMP_DIR"/{fake,control-plane,edge,node}.log; do
|
||||
if [ -s "$log_file" ]; then
|
||||
echo "=== $(basename "$log_file") ==="
|
||||
tail -n 80 "$log_file"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$KEEP_TMP" = "1" ]; then
|
||||
echo "[provider-capacity-smoke] keeping evidence=$TMP_DIR"
|
||||
else
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
log() {
|
||||
echo "[provider-capacity-smoke] $*"
|
||||
}
|
||||
|
||||
die() {
|
||||
log "ERROR: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
for required in curl jq go; do
|
||||
command -v "$required" >/dev/null 2>&1 || die "$required is required"
|
||||
done
|
||||
|
||||
declare -a USED_PORTS=()
|
||||
pick_port() {
|
||||
local base="$1"
|
||||
local attempt=0 candidate used
|
||||
while (( attempt < 100 )); do
|
||||
((attempt += 1))
|
||||
candidate=$((base + RANDOM % 1000))
|
||||
used=0
|
||||
for port in "${USED_PORTS[@]}"; do
|
||||
[ "$port" = "$candidate" ] && used=1
|
||||
done
|
||||
[ "$used" -eq 0 ] || continue
|
||||
if ! (echo >/dev/tcp/127.0.0.1/"$candidate") 2>/dev/null; then
|
||||
USED_PORTS+=("$candidate")
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
die "could not reserve a loopback port"
|
||||
}
|
||||
|
||||
curl_with_timeout() {
|
||||
local max_time="$1"
|
||||
shift
|
||||
curl --connect-timeout "$HTTP_CONNECT_TIMEOUT" --max-time "$max_time" "$@"
|
||||
}
|
||||
|
||||
curl_with_timeout_exec() {
|
||||
local max_time="$1"
|
||||
shift
|
||||
exec curl --connect-timeout "$HTTP_CONNECT_TIMEOUT" --max-time "$max_time" "$@"
|
||||
}
|
||||
|
||||
curl_probe() {
|
||||
curl_with_timeout "$HTTP_PROBE_TIMEOUT" "$@"
|
||||
}
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
local label="$2"
|
||||
local deadline=$((SECONDS + 30))
|
||||
while ! curl_probe -fsS "$url" >/dev/null 2>&1; do
|
||||
if (( SECONDS >= deadline )); then
|
||||
die "$label did not become ready: $url"
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
FAKE_PORT="$(pick_port 41000)"
|
||||
CP_HTTP_PORT="$(pick_port 29000)"
|
||||
CP_CLIENT_PORT="$(pick_port 30000)"
|
||||
CP_EDGE_PORT="$(pick_port 31000)"
|
||||
EDGE_NODE_PORT="$(pick_port 32000)"
|
||||
EDGE_BOOTSTRAP_PORT="$(pick_port 33000)"
|
||||
EDGE_OPENAI_PORT="$(pick_port 34000)"
|
||||
|
||||
RELEASE_FILE="$TMP_DIR/release"
|
||||
FAKE_SOURCE="$TMP_DIR/fake_provider.go"
|
||||
FAKE_BIN="$TMP_DIR/fake-provider"
|
||||
CP_BIN="$TMP_DIR/control-plane"
|
||||
EDGE_BIN="$TMP_DIR/iop-edge"
|
||||
NODE_BIN="$TMP_DIR/iop-node"
|
||||
CP_CONFIG="$TMP_DIR/control-plane.yaml"
|
||||
EDGE_CONFIG="$TMP_DIR/edge.yaml"
|
||||
NODE_CONFIG="$TMP_DIR/node.yaml"
|
||||
STATUS_URL="http://127.0.0.1:$CP_HTTP_PORT/edges/ornith-edge/status"
|
||||
OPENAI_URL="http://127.0.0.1:$EDGE_OPENAI_PORT/v1/chat/completions"
|
||||
FAKE_STATS_URL="http://127.0.0.1:$FAKE_PORT/stats"
|
||||
|
||||
cat > "$FAKE_SOURCE" <<'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type stats struct {
|
||||
mu sync.Mutex
|
||||
active int
|
||||
peak int
|
||||
calls int
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 4 {
|
||||
log.Fatal("usage: fake-provider <listen-addr> <release-file> <served-model>")
|
||||
}
|
||||
listenAddr, releaseFile, servedModel := os.Args[1], os.Args[2], os.Args[3]
|
||||
var state stats
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/hang", func(_ http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
return
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"object":"list","data":[{"id":%q}]}`, servedModel)
|
||||
})
|
||||
mux.HandleFunc("/stats", func(w http.ResponseWriter, _ *http.Request) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]int{"active": state.active, "peak": state.peak, "calls": state.calls})
|
||||
})
|
||||
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request chatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if request.Model != servedModel {
|
||||
http.Error(w, "unexpected model", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
state.calls++
|
||||
call := state.calls
|
||||
state.active++
|
||||
if state.active > state.peak {
|
||||
state.peak = state.active
|
||||
}
|
||||
state.mu.Unlock()
|
||||
defer func() {
|
||||
state.mu.Lock()
|
||||
state.active--
|
||||
state.mu.Unlock()
|
||||
}()
|
||||
|
||||
if call == 1 {
|
||||
deadline := time.Now().Add(45 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(releaseFile); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
http.Error(w, "release sentinel timeout", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
if request.Stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ORNITH_CAPACITY_OK\"},\"finish_reason\":\"stop\"}]}\n\n")
|
||||
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
if flush, ok := w.(http.Flusher); ok {
|
||||
flush.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"id":"ornith-%d","object":"chat.completion","model":%q,"choices":[{"index":0,"message":{"role":"assistant","content":"ORNITH_CAPACITY_OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`, call, servedModel)
|
||||
})
|
||||
log.Fatal(http.ListenAndServe(listenAddr, mux))
|
||||
}
|
||||
EOF
|
||||
|
||||
log "building loopback binaries"
|
||||
go build -o "$FAKE_BIN" "$FAKE_SOURCE"
|
||||
go build -o "$CP_BIN" "$REPO_ROOT/apps/control-plane/cmd/control-plane"
|
||||
go build -o "$EDGE_BIN" "$REPO_ROOT/apps/edge/cmd/edge"
|
||||
go build -o "$NODE_BIN" "$REPO_ROOT/apps/node/cmd/node"
|
||||
|
||||
cat > "$CP_CONFIG" <<EOF
|
||||
server:
|
||||
listen: "127.0.0.1:$CP_HTTP_PORT"
|
||||
wire_listen: "127.0.0.1:$CP_CLIENT_PORT"
|
||||
edge_wire_listen: "127.0.0.1:$CP_EDGE_PORT"
|
||||
logging:
|
||||
level: "error"
|
||||
pretty: false
|
||||
EOF
|
||||
|
||||
cat > "$EDGE_CONFIG" <<EOF
|
||||
edge:
|
||||
id: "ornith-edge"
|
||||
name: "Ornith Capacity Smoke"
|
||||
server:
|
||||
listen: "127.0.0.1:$EDGE_NODE_PORT"
|
||||
bootstrap:
|
||||
listen: "127.0.0.1:$EDGE_BOOTSTRAP_PORT"
|
||||
artifact_dir: "$TMP_DIR/artifacts"
|
||||
logging:
|
||||
level: "error"
|
||||
metrics:
|
||||
port: 0
|
||||
refresh:
|
||||
enabled: false
|
||||
listen: "127.0.0.1:0"
|
||||
openai:
|
||||
enabled: true
|
||||
listen: "127.0.0.1:$EDGE_OPENAI_PORT"
|
||||
timeout_sec: 45
|
||||
strict_output: false
|
||||
a2a:
|
||||
enabled: false
|
||||
listen: "127.0.0.1:0"
|
||||
control_plane:
|
||||
enabled: true
|
||||
wire_addr: "127.0.0.1:$CP_EDGE_PORT"
|
||||
reconnect_interval_sec: 1
|
||||
long_context_threshold_tokens: 100000
|
||||
provider_pool:
|
||||
max_queue: 2
|
||||
queue_timeout_ms: 30000
|
||||
models:
|
||||
- id: "ornith:35b"
|
||||
context_window_tokens: 4096
|
||||
providers:
|
||||
ornith-provider: "ornith-served"
|
||||
- id: "ornith-fast"
|
||||
context_window_tokens: 4096
|
||||
providers:
|
||||
ornith-provider: "ornith-served"
|
||||
nodes:
|
||||
- id: "ornith-node"
|
||||
alias: "ornith-node"
|
||||
token: "capacity-smoke-token"
|
||||
providers:
|
||||
- id: "ornith-provider"
|
||||
type: "openai_api"
|
||||
category: "api"
|
||||
provider: "vllm"
|
||||
endpoint: "http://127.0.0.1:$FAKE_PORT"
|
||||
models: ["ornith-served"]
|
||||
health: "available"
|
||||
capacity: 1
|
||||
long_context_capacity: 1
|
||||
total_context_tokens: 4096
|
||||
request_timeout_ms: 45000
|
||||
EOF
|
||||
|
||||
cat > "$NODE_CONFIG" <<EOF
|
||||
transport:
|
||||
edge_addr: "127.0.0.1:$EDGE_NODE_PORT"
|
||||
token: "capacity-smoke-token"
|
||||
reconnect:
|
||||
interval_sec: 1
|
||||
max_attempts: 0
|
||||
logging:
|
||||
level: "error"
|
||||
metrics:
|
||||
port: 0
|
||||
node:
|
||||
id: "ornith-node"
|
||||
alias: "ornith-node"
|
||||
EOF
|
||||
|
||||
"$FAKE_BIN" "127.0.0.1:$FAKE_PORT" "$RELEASE_FILE" "ornith-served" >"$TMP_DIR/fake.log" 2>&1 &
|
||||
FAKE_PID=$!
|
||||
wait_for_http "http://127.0.0.1:$FAKE_PORT/v1/models" "fake provider"
|
||||
hang_started=$SECONDS
|
||||
if curl_probe -fsS "http://127.0.0.1:$FAKE_PORT/hang" >"$TMP_DIR/hang-probe.out" 2>"$TMP_DIR/hang-probe.err"; then
|
||||
die "bounded probe unexpectedly succeeded against hanging peer"
|
||||
fi
|
||||
hang_elapsed=$((SECONDS - hang_started))
|
||||
if (( hang_elapsed > HTTP_PROBE_TIMEOUT + 2 )); then
|
||||
die "bounded probe exceeded ${HTTP_PROBE_TIMEOUT}s deadline: ${hang_elapsed}s"
|
||||
fi
|
||||
log "metrics=disabled bounded_probe_timeout_sec=$hang_elapsed"
|
||||
|
||||
"$CP_BIN" serve --config "$CP_CONFIG" >"$TMP_DIR/control-plane.log" 2>&1 &
|
||||
CP_PID=$!
|
||||
wait_for_http "http://127.0.0.1:$CP_HTTP_PORT/healthz" "Control Plane"
|
||||
|
||||
"$EDGE_BIN" serve --config "$EDGE_CONFIG" >"$TMP_DIR/edge.log" 2>&1 &
|
||||
EDGE_PID=$!
|
||||
|
||||
edge_deadline=$((SECONDS + 30))
|
||||
while ! curl_probe -fsS "http://127.0.0.1:$CP_HTTP_PORT/edges/ornith-edge" >/dev/null 2>&1; do
|
||||
if (( SECONDS >= edge_deadline )); then
|
||||
die "Edge did not enroll with Control Plane"
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
"$NODE_BIN" serve --config "$NODE_CONFIG" >"$TMP_DIR/node.log" 2>&1 &
|
||||
NODE_PID=$!
|
||||
|
||||
connected_deadline=$((SECONDS + 30))
|
||||
while true; do
|
||||
if curl_probe -fsS "$STATUS_URL" >"$TMP_DIR/status-connected.json" 2>/dev/null \
|
||||
&& jq -e '.nodes | any(.[]; .node_id == "ornith-node" and .connected == true)' "$TMP_DIR/status-connected.json" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if (( SECONDS >= connected_deadline )); then
|
||||
die "Node did not become dispatch-ready"
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
curl_probe -fsS "http://127.0.0.1:$EDGE_OPENAI_PORT/v1/models" >"$TMP_DIR/models.json"
|
||||
jq -e '[.data[].id] | index("ornith:35b") and index("ornith-fast")' "$TMP_DIR/models.json" >/dev/null \
|
||||
|| die "OpenAI model catalog did not expose both aliases"
|
||||
|
||||
fire_alias() {
|
||||
local alias="$1"
|
||||
local prefix="$2"
|
||||
jq -n --arg model "$alias" '{model:$model, stream:false, messages:[{role:"user", content:"capacity smoke"}]}' >"$prefix.request.json"
|
||||
(
|
||||
curl_with_timeout_exec "$HTTP_COMPLETION_TIMEOUT" \
|
||||
-sS -o "$prefix.response.json" -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "@$prefix.request.json" \
|
||||
"$OPENAI_URL" >"$prefix.code" 2>"$prefix.curl.err"
|
||||
) &
|
||||
REQUEST_PID=$!
|
||||
}
|
||||
|
||||
wait_for_request() {
|
||||
local pid="$1"
|
||||
local prefix="$2"
|
||||
local request_rc
|
||||
if wait "$pid"; then
|
||||
request_rc=0
|
||||
else
|
||||
request_rc=$?
|
||||
fi
|
||||
printf '%s\n' "$request_rc" >"$prefix.rc"
|
||||
}
|
||||
|
||||
fire_alias "ornith:35b" "$TMP_DIR/ornith-35b"
|
||||
FIRST_PID="$REQUEST_PID"
|
||||
backend_deadline=$((SECONDS + 20))
|
||||
while ! curl_probe -fsS "$FAKE_STATS_URL" >"$TMP_DIR/backend-before-second.json" 2>/dev/null \
|
||||
|| ! jq -e '.calls == 1 and .active == 1 and .peak == 1' "$TMP_DIR/backend-before-second.json" >/dev/null; do
|
||||
if (( SECONDS >= backend_deadline )); then
|
||||
die "first alias did not reach the held fake backend"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
fire_alias "ornith-fast" "$TMP_DIR/ornith-fast"
|
||||
SECOND_PID="$REQUEST_PID"
|
||||
queue_deadline=$((SECONDS + 20))
|
||||
while true; do
|
||||
if curl_probe -fsS "$STATUS_URL" >"$TMP_DIR/status-queued.json" 2>/dev/null \
|
||||
&& jq -e '
|
||||
[.nodes[].provider_snapshots[]? | select(.id == "ornith-provider")] as $providers
|
||||
| $providers | length == 1
|
||||
and $providers[0].capacity == 1
|
||||
and $providers[0].in_flight == 1
|
||||
and $providers[0].queued >= 1
|
||||
and $providers[0].long_context_capacity == 1
|
||||
and $providers[0].long_in_flight == 0
|
||||
and $providers[0].long_queued == 0
|
||||
' "$TMP_DIR/status-queued.json" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if (( SECONDS >= queue_deadline )); then
|
||||
die "Control Plane status did not observe the shared-provider queue"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
touch "$RELEASE_FILE"
|
||||
wait_for_request "$FIRST_PID" "$TMP_DIR/ornith-35b"
|
||||
wait_for_request "$SECOND_PID" "$TMP_DIR/ornith-fast"
|
||||
|
||||
for prefix in "$TMP_DIR/ornith-35b" "$TMP_DIR/ornith-fast"; do
|
||||
[ "$(cat "$prefix.rc")" = "0" ] || die "$(basename "$prefix") curl failed"
|
||||
[ "$(cat "$prefix.code")" = "200" ] || die "$(basename "$prefix") returned HTTP $(cat "$prefix.code")"
|
||||
grep -q 'ORNITH_CAPACITY_OK' "$prefix.response.json" || die "$(basename "$prefix") response lacked success marker"
|
||||
done
|
||||
|
||||
curl_probe -fsS "$FAKE_STATS_URL" >"$TMP_DIR/backend-final.json"
|
||||
jq -e '.calls == 2 and .active == 0 and .peak == 1' "$TMP_DIR/backend-final.json" >/dev/null \
|
||||
|| die "fake backend stats did not recover with peak=1"
|
||||
|
||||
final_status_is_online_and_recovered() {
|
||||
local snapshot="$1"
|
||||
jq -e '
|
||||
[.nodes[] | select(.node_id == "ornith-node")] as $nodes
|
||||
| [$nodes[0].provider_snapshots[]? | select(.id == "ornith-provider")] as $providers
|
||||
| ($nodes | length == 1)
|
||||
and ($nodes[0].connected == true)
|
||||
and ($providers | length == 1)
|
||||
and ($providers[0].status == "available")
|
||||
and ($providers[0].health == "available")
|
||||
and ($providers[0].capacity == 1)
|
||||
and ($providers[0].long_context_capacity == 1)
|
||||
and ($providers[0].in_flight == 0)
|
||||
and ($providers[0].queued == 0)
|
||||
and ($providers[0].long_in_flight == 0)
|
||||
and ($providers[0].long_queued == 0)
|
||||
' "$snapshot" >/dev/null
|
||||
}
|
||||
|
||||
cat >"$TMP_DIR/status-offline.json" <<'EOF'
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "ornith-node",
|
||||
"connected": false,
|
||||
"provider_snapshots": [
|
||||
{
|
||||
"id": "ornith-provider",
|
||||
"status": "unavailable",
|
||||
"health": "offline",
|
||||
"capacity": 0,
|
||||
"in_flight": 0,
|
||||
"queued": 0,
|
||||
"long_context_capacity": 0,
|
||||
"long_in_flight": 0,
|
||||
"long_queued": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
if final_status_is_online_and_recovered "$TMP_DIR/status-offline.json"; then
|
||||
die "final recovery predicate accepted an offline snapshot"
|
||||
fi
|
||||
log "offline_snapshot_rejected=true"
|
||||
|
||||
final_deadline=$((SECONDS + 20))
|
||||
while true; do
|
||||
if curl_probe -fsS "$STATUS_URL" >"$TMP_DIR/status-final.json" 2>/dev/null \
|
||||
&& final_status_is_online_and_recovered "$TMP_DIR/status-final.json"; then
|
||||
break
|
||||
fi
|
||||
if (( SECONDS >= final_deadline )); then
|
||||
die "provider counters did not recover to zero"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
log "aliases=ornith:35b,ornith-fast queue_observed=true"
|
||||
log "backend=$(jq -c '{calls,active,peak}' "$TMP_DIR/backend-final.json")"
|
||||
log "final_provider=$(jq -c '[.nodes[] | select(.node_id == "ornith-node") | {node_id,connected,providers:[.provider_snapshots[]? | select(.id == "ornith-provider") | {id,status,health,capacity,in_flight,queued,long_context_capacity,long_in_flight,long_queued}]}]' "$TMP_DIR/status-final.json")"
|
||||
log "PASS evidence=$TMP_DIR"
|
||||
Loading…
Reference in a new issue