feat(node): inference provider availability probing을 추가한다

Adapter에 ProviderProber 인터페이스를 도입하여 provider endpoint와
target model의 가용성을 확인한다.

- runtime에 ProviderStatus, ProviderProbeResult 타입을 추가한다
- ollama/vllm/mock adapter에 ProbeProvider를 구현한다
- node handleCapabilitiesCommand에서 Prober 인터페이스를 호출하여
  target-aware provider status를 응답한다
- 각 adapter 및 node 테스트를 추가한다
- 로드맵 문서를 갱신한다
This commit is contained in:
toki 2026-06-14 16:39:49 +09:00
parent bf0265ed48
commit 0e02958684
15 changed files with 1434 additions and 32 deletions

View file

@ -20,7 +20,7 @@ Ollama 경로가 안정화된 뒤, 그 결과를 기준선으로 삼아 Lemonade
- 경로: `agent-roadmap/archive/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md`
- 요약: 하나의 Node 연결이 여러 CLI profile, terminal gateway, Ollama/vLLM/SGLang 같은 추론 엔진 연결, 여러 model target을 동시에 제공할 수 있도록 config/proto/routing/runtime 기준선을 정리했고, 최종 코드 리뷰와 검증까지 통과해 archive했다.
- [계획] Node provider 상태와 Capacity Queue 기반
- [진행중] Node provider 상태와 Capacity Queue 기반
- 경로: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
- 요약: NomadCode workspace 실행 계약이 닫힌 뒤 provider별 health/model probe 차이를 Node adapter 내부로 숨기고, Edge가 공통으로 볼 수 있는 최소 상태와 capacity/in-flight/queued snapshot, FIFO admission queue 기준선을 만든다.

View file

@ -13,7 +13,7 @@ Ollama, Lemonade, vLLM, SGLang 같은 provider별 상태 확인 방식 차이를
## 상태
[계획]
[진행중]
## 승격 조건
@ -38,7 +38,7 @@ Ollama, Lemonade, vLLM, SGLang 같은 provider별 상태 확인 방식 차이를
Provider별 probe 차이를 Node 내부로 감추고 Edge가 라우팅 입력으로 사용할 수 있는 최소 상태 snapshot을 정의한다.
- [ ] [status-model] Node가 Edge에 노출하는 provider 상태 모델은 `unknown`, `available`, `unavailable`로 제한하고, provider가 직접 보장하지 않는 `degraded`, `starting`, `draining` 같은 상태는 넣지 않는다.
- [x] [status-model] Node가 Edge에 노출하는 provider 상태 모델은 `unknown`, `available`, `unavailable`로 제한하고, provider가 직접 보장하지 않는 `degraded`, `starting`, `draining` 같은 상태는 넣지 않는다.
- [ ] [probe-contract] Ollama, Lemonade, vLLM, SGLang 등 provider adapter가 endpoint 생존 여부와 target/model 사용 가능 여부를 확인하는 공통 probe 인터페이스를 구현한다.
- [ ] [edge-snapshot] Edge가 provider별 구현 세부를 모르고 Node의 provider 상태, capacity, in-flight, queued 값을 조회하거나 이벤트로 받을 수 있는 계약을 정리한다.
@ -77,6 +77,9 @@ Provider별 동시 처리 한도를 IOP가 소유하고, 한도를 넘는 요청
- 표준선(선택): provider가 직접 제공하지 않는 상태를 IOP 공통 상태로 만들지 않고, 초기 상태는 `unknown`, `available`, `unavailable`만 사용한다.
- 표준선(선택): capacity와 queue는 provider 기능이 아니라 Node의 공통 provider execution wrapper 책임으로 둔다.
- 표준선(선택): Edge routing은 provider별 endpoint가 아니라 Node가 제공하는 availability/load snapshot을 입력으로 삼는다.
- 진행 근거: `status-model``apps/node/internal/runtime/types.go`와 Node `CAPABILITIES` 응답에 제한 상태 모델을 추가했고 `go test ./apps/node/...`로 검증했다.
- 활성 plan: `agent-task/m-provider-availability-capacity-queue-foundation/01_probe_contract/PLAN-local-G05.md``probe-contract`를 다룬다.
- 활성 plan: `agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/PLAN-cloud-G06.md``edge-snapshot`을 다루며 `01_probe_contract` 완료에 의존한다.
- 선행 작업: Node 단일 통로 멀티 타겟 서빙 기반, OpenAI Workspace Agent Execution Contract
- 후속 작업: Lemonade provider 서빙 경로 추가, vLLM provider 서빙 경로 추가, SGLang provider 서빙 경로 추가, model group alias와 capacity-aware routing Milestone
- 확인 필요: 없음

View file

@ -0,0 +1,191 @@
<!-- task=m-provider-availability-capacity-queue-foundation/01_probe_contract plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-14
task=m-provider-availability-capacity-queue-foundation/01_probe_contract, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
- Task ids:
- `probe-contract`: Ollama, Lemonade, vLLM, SGLang 등 provider adapter가 endpoint 생존 여부와 target/model 사용 가능 여부를 확인하는 공통 probe 인터페이스를 구현한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-provider-availability-capacity-queue-foundation/01_probe_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-provider-availability-capacity-queue-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Runtime Provider Probe Contract | [x] |
| [API-2] Provider Adapter Probe Implementations | [x] |
| [API-3] Node Capabilities Uses Probe | [x] |
## 구현 체크리스트
- [x] runtime에 provider probe 결과 타입과 interface를 추가하고 `ProviderStatus` 제한 모델을 재사용한다.
- [x] Ollama adapter가 `/api/tags` endpoint 생존과 target/model 존재 여부를 `available|unavailable`로 반환하도록 구현하고 테스트한다.
- [x] vLLM adapter가 `/v1/models` endpoint 생존과 target/model 존재 여부를 `available|unavailable`로 반환하도록 구현하고 SGLang 호환 경로를 문서화한다.
- [x] Node `CAPABILITIES` command가 provider prober를 발견하면 target-aware probe 결과로 `provider_status`와 targets를 채우도록 구현한다.
- [x] `go test ./apps/node/...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-provider-availability-capacity-queue-foundation/01_probe_contract/`를 `agent-task/archive/YYYY/MM/m-provider-availability-capacity-queue-foundation/01_probe_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-provider-availability-capacity-queue-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
없음
## 주요 설계 결정
- `Ollama` 및 `vLLM` 어댑터가 `runtime.ProviderProber` 인터페이스를 구현하도록 추가하여, 각 모델 공급자의 엔드포인트 생존과 특정 타겟 모델의 존재 여부를 헬스체크할 수 있는 공통 프로브 로직을 캡슐화하였습니다.
- `vLLM` 어댑터 구현 내 SGLang 호환성 언급을 추가하여 OpenAI 호환 규격을 통한 프로빙이 SGLang에서도 동일하게 적용됨을 보장하였습니다.
- Node의 `handleCapabilitiesCommand` 내에서 어댑터가 `ProviderProber` 인터페이스를 지원하면 실행 시점에 `ProbeProvider`를 능동적으로 호출해 그 결과를 `provider_status` 및 `targets`에 병합하도록 하였습니다. 프로빙 에러는 커맨드 전체의 실패로 처리하지 않고 `unavailable` 상태 및 상세 에러 메시지를 `provider_detail`에 담아 리턴하도록 설계했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Provider status가 `unknown|available|unavailable` 외 값으로 노출되지 않는지 확인한다.
- Probe 실패가 command transport error와 혼동되지 않는지 확인한다.
- Ollama/vLLM target miss와 endpoint failure 테스트가 모두 있는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```bash
$ go test ./apps/node/internal/runtime ./apps/node/internal/node
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/node 0.121s
```
### API-2 중간 검증
```bash
$ go test ./apps/node/internal/adapters/ollama ./apps/node/internal/adapters/vllm
ok iop/apps/node/internal/adapters/ollama 0.010s
ok iop/apps/node/internal/adapters/vllm 0.010s
```
### API-3 중간 검증
```bash
$ go test ./apps/node/internal/node
ok iop/apps/node/internal/node 0.121s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/node/...
ok iop/apps/node/cmd/node 0.012s
ok iop/apps/node/internal/adapters 0.010s
ok iop/apps/node/internal/adapters/cli 47.166s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 39.924s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.011s
ok iop/apps/node/internal/adapters/vllm 0.011s
ok iop/apps/node/internal/bootstrap 0.269s
ok iop/apps/node/internal/node 0.129s
ok iop/apps/node/internal/router 0.010s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.051s
ok iop/apps/node/internal/terminal 0.569s
ok iop/apps/node/internal/transport 5.144s
```
---
## Ownership
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify |
| 구현 항목별 완료 여부 | Implementing agent checks only | Item names remain fixed |
| 구현 체크리스트 | Implementing agent checks only | Text/order remain fixed |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user-only blocker exists |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
| 검증 결과 | Implementing agent | Paste actual output |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
- 발견된 문제: 없음
- 리뷰 중 직접 정리:
- `apps/node/internal/adapters/ollama/ollama_test.go`와 `apps/node/internal/adapters/vllm/vllm_test.go`의 EOF formatting nit를 정리했다.
- 검증:
- `go test -count=1 ./apps/node/internal/runtime ./apps/node/internal/node ./apps/node/internal/adapters/ollama ./apps/node/internal/adapters/vllm` - PASS
- `go test -count=1 ./apps/node/...` - PASS
- `./scripts/e2e-smoke.sh` - PASS; 보조 edge-node smoke로 `/capabilities`, `/transport`, `/sessions`, foreground/background 메시지 relay를 확인했다.
- `git diff --check` - PASS
- 다음 단계: PASS 종결. active plan/review를 로그로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,44 @@
# Complete - m-provider-availability-capacity-queue-foundation/01_probe_contract
## 완료 일시
2026-06-14
## 요약
Provider probe contract 구현 리뷰 1회차를 PASS로 종결했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | runtime provider probe contract, Ollama/vLLM/mock probe 구현, Node CAPABILITIES probe 반영, 관련 테스트와 보조 smoke 검증 통과 |
## 구현/정리 내용
- `runtime.ProviderStatus`, `ProviderProbeResult`, `ProviderProber` 계약을 추가하고 provider status normalization 기준을 유지했다.
- Ollama `/api/tags`와 vLLM/SGLang 호환 `/v1/models` probe를 target-aware `available|unavailable` 결과로 반환하도록 구현했다.
- Node `CAPABILITIES` command가 optional provider prober를 사용해 `provider_status`, `targets`, `provider_detail`을 응답에 반영하도록 했다.
- 리뷰 중 `ollama_test.go`, `vllm_test.go` EOF formatting nit를 정리했다.
## 최종 검증
- `go test -count=1 ./apps/node/internal/runtime ./apps/node/internal/node ./apps/node/internal/adapters/ollama ./apps/node/internal/adapters/vllm` - PASS; runtime/node/adapter probe 관련 대상 패키지 통과
- `go test -count=1 ./apps/node/...` - PASS; node 전체 패키지 통과
- `./scripts/e2e-smoke.sh` - PASS; 보조 edge-node smoke에서 `/capabilities`, `/transport`, `/sessions`, foreground/background 메시지 relay 확인
- `git diff --check` - PASS; whitespace 문제 없음
## Roadmap Completion
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
- Completed task ids:
- `probe-contract`: PASS; evidence=`agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,186 @@
<!-- task=m-provider-availability-capacity-queue-foundation/01_probe_contract plan=0 tag=API -->
# Probe Contract Plan - API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 구현 후 검증을 실행하고 실제 출력, 설계 결정, 계획 대비 변경 사항을 기록한 뒤 active 파일을 남긴 채 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, scope 충돌로 막히면 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 적고 멈춘다. 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 명령 재실행이나 산출물 수집으로 해소 가능한 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
현재 Node adapter는 `Capabilities`에서 targets와 concurrency만 반환하며 provider endpoint 생존과 target/model 가용성 확인 계약이 없다. `status-model` 작은 작업으로 `unknown|available|unavailable` 모델은 생겼지만, Ollama/vLLM/SGLang 계열 provider가 같은 방식으로 상태를 생산하는 인터페이스는 아직 없다. 이 작업은 provider별 probe 차이를 adapter 내부에 숨기는 공통 probe 경계를 만든다.
## 사용자 리뷰 요청 흐름
구현 중 차단은 `CODE_REVIEW-local-G05.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 구현 에이전트는 채팅으로 직접 선택지를 내거나 사용자 입력 도구를 호출하지 않는다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
- Task ids:
- `probe-contract`: Ollama, Lemonade, vLLM, SGLang 등 provider adapter가 endpoint 생존 여부와 target/model 사용 가능 여부를 확인하는 공통 probe 인터페이스를 구현한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/inference-provider-extension/PHASE.md`
- `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `apps/node/internal/runtime/types.go`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/adapters/ollama/ollama.go`
- `apps/node/internal/adapters/ollama/ollama_test.go`
- `apps/node/internal/adapters/vllm/vllm.go`
- `apps/node/internal/adapters/vllm/vllm_test.go`
- `apps/node/internal/adapters/mock/mock.go`
### 테스트 환경 규칙
test_env는 `local`이다. `agent-test/local/rules.md`를 읽었고, 이 subtask의 실제 변경 범위는 `apps/node/**`라 `agent-test/local/node-smoke.md`를 적용한다. 기본 검증은 `go test ./apps/node/...`이며, adapter 실행/stream/cancel 경로를 바꾸면 repo 내부 edge-node 진단 또는 full-cycle 검증을 추가해야 한다. `edge-smoke`와 `platform-common-smoke`는 provider-state 전체 분류 때문에 읽었지만, 이 subtask가 proto/config/edge 파일을 수정하지 않으면 적용하지 않는다.
### 테스트 커버리지 공백
- `Capabilities` command는 `provider_status` 제한만 검증되어 있고 provider endpoint 생존 또는 target/model 가용성 probe는 아직 검증하지 않는다.
- Ollama `/api/tags` 실패와 vLLM `/v1/models` 실패가 `unavailable`로 접히는 회귀 테스트가 없다.
- target이 명시됐지만 provider model 목록에 없을 때 `unavailable`로 나오는 테스트가 없다.
### 심볼 참조
none. 새 interface/type 추가가 중심이며 기존 symbol rename/remove는 없다.
### 분할 판단
split decision policy를 평가했다. shared task group은 `m-provider-availability-capacity-queue-foundation`이다. `01_probe_contract`는 독립 subtask이며 predecessor가 없다. `02+01_edge_snapshot`은 이 probe 결과를 Edge-visible snapshot으로 운반하므로 `01_probe_contract`의 `complete.log`가 필요하다. API foundation과 Edge/proto rollout 경계가 분리되어 multi-plan이 맞다.
### 범위 결정 근거
이 subtask는 Node runtime interface와 provider adapter probe까지만 다룬다. proto schema, Edge service/Control Plane status, capacity queue admission은 `02+01_edge_snapshot` 또는 capacity-queue Epic에서 다룬다. Lemonade adapter 신규 구현도 후속 Milestone 범위라 제외한다.
### 빌드 등급
`local-G05`. 변경 범위가 Node 내부 interface와 adapter tests로 제한되고, httptest 기반 검증으로 reviewer가 재현 가능하다.
## 구현 체크리스트
- [ ] runtime에 provider probe 결과 타입과 interface를 추가하고 `ProviderStatus` 제한 모델을 재사용한다.
- [ ] Ollama adapter가 `/api/tags` endpoint 생존과 target/model 존재 여부를 `available|unavailable`로 반환하도록 구현하고 테스트한다.
- [ ] vLLM adapter가 `/v1/models` endpoint 생존과 target/model 존재 여부를 `available|unavailable`로 반환하도록 구현하고 SGLang 호환 경로를 문서화한다.
- [ ] Node `CAPABILITIES` command가 provider prober를 발견하면 target-aware probe 결과로 `provider_status`와 targets를 채우도록 구현한다.
- [ ] `go test ./apps/node/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] Runtime Provider Probe Contract
문제: [apps/node/internal/runtime/types.go](/config/workspace/iop/apps/node/internal/runtime/types.go:77)는 제한된 status 모델만 있고, [apps/node/internal/runtime/types.go](/config/workspace/iop/apps/node/internal/runtime/types.go:174)의 adapter 계약은 execution/capabilities만 가진다.
해결 방법:
```go
// before apps/node/internal/runtime/types.go:174
type Adapter interface {
Name() string
Capabilities(ctx context.Context) (Capabilities, error)
Execute(ctx context.Context, spec ExecutionSpec, sink EventSink) error
}
```
`ProviderProbeResult`와 optional `ProviderProber` interface를 추가한다. Result는 `AdapterName`, `InstanceKey`, `Target`, `Targets`, `Status`, `Detail`, `Metadata` 정도로 제한하고 `NormalizeProviderStatus`를 통과한 status만 외부로 내보낸다.
수정 파일 및 체크리스트:
- [ ] `apps/node/internal/runtime/types.go`에 `ProviderProbeResult`와 `ProviderProber` 추가
- [ ] status normalization helper 재사용
테스트 작성: 직접 test file은 만들지 않는다. Node command와 adapter tests에서 interface 동작을 검증한다.
중간 검증:
```bash
go test ./apps/node/internal/runtime ./apps/node/internal/node
```
### [API-2] Provider Adapter Probe Implementations
문제: [apps/node/internal/adapters/ollama/ollama.go](/config/workspace/iop/apps/node/internal/adapters/ollama/ollama.go:195)와 [apps/node/internal/adapters/vllm/vllm.go](/config/workspace/iop/apps/node/internal/adapters/vllm/vllm.go:165)는 model list fetch 실패를 `nil` targets로 숨긴다. 이 때문에 endpoint down과 empty model list를 Edge-visible 상태로 구분할 수 없다.
해결 방법: `fetchTargets`를 context-aware helper로 바꾸고 error를 반환한다. `ProbeProvider(ctx,target)`는 endpoint error면 `unavailable`, target이 비어 있고 endpoint/list decode가 성공하면 `available`, target이 목록에 있으면 `available`, 없으면 `unavailable`을 반환한다. vLLM adapter는 OpenAI-compatible `/v1/models` 기준이므로 SGLang도 같은 구현 경로를 사용한다.
수정 파일 및 체크리스트:
- [ ] `apps/node/internal/adapters/ollama/ollama.go`
- [ ] `apps/node/internal/adapters/ollama/ollama_test.go`
- [ ] `apps/node/internal/adapters/vllm/vllm.go`
- [ ] `apps/node/internal/adapters/vllm/vllm_test.go`
- [ ] 필요 시 `apps/node/internal/adapters/mock/mock.go`는 항상 `available`인 test-friendly probe로 둔다
테스트 작성: httptest server로 `200 + model list`, `500`, target miss를 각각 검증한다. Test names는 `TestOllamaProbeProviderAvailability`, `TestVllmProbeProviderAvailability`.
중간 검증:
```bash
go test ./apps/node/internal/adapters/ollama ./apps/node/internal/adapters/vllm
```
### [API-3] Node Capabilities Uses Probe
문제: [apps/node/internal/node/node.go](/config/workspace/iop/apps/node/internal/node/node.go:306)는 `Capabilities`만 호출한다. request target이 있어도 target/model 사용 가능 여부를 반영하지 못한다.
해결 방법:
```go
// before apps/node/internal/node/node.go:311
caps, err := adapter.Capabilities(ctx)
if err != nil {
return n.commandErrorResponse(req, err.Error())
}
```
`adapter.(runtime.ProviderProber)`가 가능하면 `ProbeProvider(ctx, req.GetTarget())`를 호출하고 result map의 `provider_status`, `targets`, optional `provider_detail`을 probe 결과로 채운다. Probe error는 command error로 만들지 말고 `provider_status=unavailable` 결과로 반환한다.
수정 파일 및 체크리스트:
- [ ] `apps/node/internal/node/node.go`
- [ ] `apps/node/internal/node/node_test.go`
테스트 작성: 기존 `TestOnCommandRequest_CapabilitiesProviderStatusModel` 옆에 prober test double을 추가해 target hit/miss와 probe error 결과를 검증한다.
중간 검증:
```bash
go test ./apps/node/internal/node
```
## 수정 파일 요약
| 파일 | 항목 |
|---|---|
| `apps/node/internal/runtime/types.go` | API-1 |
| `apps/node/internal/adapters/ollama/ollama.go` | API-2 |
| `apps/node/internal/adapters/ollama/ollama_test.go` | API-2 |
| `apps/node/internal/adapters/vllm/vllm.go` | API-2 |
| `apps/node/internal/adapters/vllm/vllm_test.go` | API-2 |
| `apps/node/internal/adapters/mock/mock.go` | API-2 |
| `apps/node/internal/node/node.go` | API-3 |
| `apps/node/internal/node/node_test.go` | API-3 |
## 최종 검증
```bash
go test ./apps/node/...
```
기대 결과: 모든 node package test가 통과한다. Go test cache 출력은 허용하지 않는다. fresh 실행이 필요하면 구현 에이전트가 `go test -count=1 ./apps/node/...`로 대체하고 사유와 출력을 review stub에 기록한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,152 @@
<!-- task=m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-14
task=m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
- Task ids:
- `edge-snapshot`: Edge가 provider별 구현 세부를 모르고 Node의 provider 상태, capacity, in-flight, queued 값을 조회하거나 이벤트로 받을 수 있는 계약을 정리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G06.md` -> `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` -> `plan_cloud_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-provider-availability-capacity-queue-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Typed Provider Snapshot Proto | [ ] |
| [API-2] Node Snapshot Population | [ ] |
| [API-3] Edge Service and Status Propagation | [ ] |
## 구현 체크리스트
- [ ] `01_probe_contract` predecessor의 `complete.log`가 있는지 확인하고 없으면 구현을 시작하지 않는다.
- [ ] provider snapshot typed proto를 추가하고 `make proto`로 Go generated files를 갱신한다.
- [ ] Node `CAPABILITIES` response가 status, capacity, in-flight, queued를 typed snapshot과 result map 양쪽에 일관되게 담도록 구현한다.
- [ ] Edge service DTO와 command path가 typed provider snapshot을 보존하도록 구현한다.
- [ ] Control Plane Edge status 또는 Edge-owned status surface가 provider snapshots를 노출하도록 구현한다.
- [ ] `go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G06_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G06_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/``agent-task/archive/YYYY/MM/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-provider-availability-capacity-queue-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md``CODE_REVIEW-cloud-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `01_probe_contract` predecessor completion evidence 없이 구현이 진행되지 않았는지 확인한다.
- proto 원본과 generated Go 파일이 `make proto` 결과로 일치하는지 확인한다.
- `queued=0` 기준선이 queue 미구현 상태로 명시되어 있고 capacity-queue 정책을 선구현하지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```bash
$ make proto
(output)
```
### API-2 중간 검증
```bash
$ go test ./apps/node/internal/node
(output)
```
### API-3 중간 검증
```bash
$ go test ./apps/edge/internal/service ./apps/edge/internal/controlplane ./apps/edge/internal/transport
(output)
```
### 최종 검증
```bash
$ go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...
(output)
```
---
## Ownership
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify |
| 구현 항목별 완료 여부 | Implementing agent checks only | Item names remain fixed |
| 구현 체크리스트 | Implementing agent checks only | Text/order remain fixed |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user-only blocker exists |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
| 검증 결과 | Implementing agent | Paste actual output |

View file

@ -0,0 +1,209 @@
<!-- task=m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot plan=0 tag=API -->
# Edge Snapshot Plan - API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 구현 후 검증을 실행하고 실제 출력, 설계 결정, 계획 대비 변경 사항을 기록한 뒤 active 파일을 남긴 채 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, scope 충돌로 막히면 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 적고 멈춘다. 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 명령 재실행이나 산출물 수집으로 해소 가능한 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
Edge는 현재 node registry와 generic run capability만 볼 수 있고, provider별 status/capacity/in-flight/queued snapshot은 typed contract로 갖고 있지 않다. `01_probe_contract`가 provider status를 생산한 뒤, 이 작업은 Edge가 provider 구현 세부를 몰라도 조회할 수 있는 snapshot 계약을 연결한다. capacity queue가 아직 완성되지 않았으므로 이 plan은 `queued=0` 기준선을 명시하고 이후 queue 작업이 값을 교체할 수 있게 한다.
## 사용자 리뷰 요청 흐름
구현 중 차단은 `CODE_REVIEW-cloud-G06.md``사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 구현 에이전트는 채팅으로 직접 선택지를 내거나 사용자 입력 도구를 호출하지 않는다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
- Task ids:
- `edge-snapshot`: Edge가 provider별 구현 세부를 모르고 Node의 provider 상태, capacity, in-flight, queued 값을 조회하거나 이벤트로 받을 수 있는 계약을 정리한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/inference-provider-extension/PHASE.md`
- `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `apps/node/internal/runtime/types.go`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/node/node_test.go`
- `apps/edge/internal/service/node_command.go`
- `apps/edge/internal/service/status_provider.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/controlplane/connector.go`
- `apps/edge/internal/opsconsole/status.go`
- `proto/iop/runtime.proto`
- `proto/iop/control.proto`
### 테스트 환경 규칙
test_env는 `local`이다. `node-smoke`, `edge-smoke`, `platform-common-smoke`를 모두 적용한다. proto 변경 시 `make proto`가 필수이며, generated Go diff는 원본 proto에서 만들어야 한다. 기본 검증은 `go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...`이고, Edge status/control-plane snapshot이 바뀌므로 관련 edge service/controlplane 테스트를 포함한다. 사용자 실행 파이프라인에 닿지만 이 plan은 query/status 계약 변경이므로 full-cycle 실제 구동은 구현 변경 규모에 따라 code-review에서 필수 여부를 다시 판단한다.
### 테스트 커버리지 공백
- `NodeCommandResponse`는 [proto/iop/runtime.proto](/config/workspace/iop/proto/iop/runtime.proto:103)의 free-form `result` map만 있고 typed provider snapshot payload가 없다.
- `EdgeNodeSnapshot`은 [proto/iop/control.proto](/config/workspace/iop/proto/iop/control.proto:77)에서 node/config만 담고 provider snapshot field가 없다.
- Edge service `NodeCommandView`는 [apps/edge/internal/service/node_command.go](/config/workspace/iop/apps/edge/internal/service/node_command.go:107)에서 result map만 보존한다.
- Control Plane status builder는 [apps/edge/internal/controlplane/connector.go](/config/workspace/iop/apps/edge/internal/controlplane/connector.go:367)에서 provider snapshot을 넣을 자리가 없다.
### 심볼 참조
none. 새 proto message/fields와 DTO field 추가가 중심이며 기존 symbol rename/remove는 없다.
### 분할 판단
split decision policy를 평가했다. shared task group은 `m-provider-availability-capacity-queue-foundation`이다. 이 subtask는 `02+01_edge_snapshot`이므로 predecessor index `01`이 필요하다. 현재 `agent-task/m-provider-availability-capacity-queue-foundation/01_probe_contract/complete.log`는 없어서 predecessor는 missing이다. 구현은 `01_probe_contract`가 PASS되어 `complete.log`가 생긴 뒤 시작해야 한다.
### 범위 결정 근거
이 subtask는 Edge가 조회할 수 있는 provider snapshot contract까지 다룬다. FIFO queue admission, `queue_timeout`, `max_queue` reject, request scheduling 정책은 capacity-queue Epic의 별도 task 범위다. queue가 없는 현재 기준선에서는 `queued=0`을 명시적으로 반환하고, queue task가 이 값을 실제 queue 길이로 교체하게 둔다.
### 빌드 등급
`cloud-G06`. proto schema, generated code, Node command, Edge service, Control Plane status가 함께 움직이는 cross-domain API 변경이다.
## 구현 체크리스트
- [ ] `01_probe_contract` predecessor의 `complete.log`가 있는지 확인하고 없으면 구현을 시작하지 않는다.
- [ ] provider snapshot typed proto를 추가하고 `make proto`로 Go generated files를 갱신한다.
- [ ] Node `CAPABILITIES` response가 status, capacity, in-flight, queued를 typed snapshot과 result map 양쪽에 일관되게 담도록 구현한다.
- [ ] Edge service DTO와 command path가 typed provider snapshot을 보존하도록 구현한다.
- [ ] Control Plane Edge status 또는 Edge-owned status surface가 provider snapshots를 노출하도록 구현한다.
- [ ] `go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
`02+01_edge_snapshot`은 directory name 기준으로 predecessor `01_probe_contract`에만 의존한다. 구현 전 `agent-task/m-provider-availability-capacity-queue-foundation/01_probe_contract/complete.log` 또는 matching archive `agent-task/archive/*/*/m-provider-availability-capacity-queue-foundation/01_probe_contract/complete.log`가 있어야 한다. 이 plan 작성 시점에는 missing이다.
### [API-1] Typed Provider Snapshot Proto
문제: [proto/iop/runtime.proto](/config/workspace/iop/proto/iop/runtime.proto:103)의 `NodeCommandResponse`는 map만 있고 provider snapshot의 `status`, `capacity`, `in_flight`, `queued`를 typed contract로 표현하지 못한다. [proto/iop/control.proto](/config/workspace/iop/proto/iop/control.proto:77)의 `EdgeNodeSnapshot`도 provider field가 없다.
해결 방법:
```proto
// before proto/iop/runtime.proto:103
message NodeCommandResponse {
string request_id = 1;
...
map<string, string> result = 8;
}
```
`runtime.proto``ProviderSnapshot` message를 추가하고 `NodeCommandResponse``repeated ProviderSnapshot provider_snapshots = 9;`를 추가한다. `control.proto``EdgeNodeSnapshot`에는 `repeated ProviderSnapshot provider_snapshots = 6;`를 추가한다. `ProviderSnapshot.status`는 string으로 두되 값은 `unknown|available|unavailable`만 허용한다.
수정 파일 및 체크리스트:
- [ ] `proto/iop/runtime.proto`
- [ ] `proto/iop/control.proto`
- [ ] `proto/gen/iop/runtime.pb.go``make proto`로만 갱신
- [ ] `proto/gen/iop/control.pb.go``make proto`로만 갱신
테스트 작성: proto generated compile 검증으로 충분하다. 별도 proto unit test는 추가하지 않는다.
중간 검증:
```bash
make proto
```
### [API-2] Node Snapshot Population
문제: [apps/node/internal/node/node.go](/config/workspace/iop/apps/node/internal/node/node.go:317)는 string map만 채우며, [apps/node/internal/node/run_manager.go](/config/workspace/iop/apps/node/internal/node/run_manager.go:55)의 active count는 snapshot으로 노출되지 않는다.
해결 방법:
```go
// before apps/node/internal/node/node.go:317
result := map[string]string{
"adapter": caps.AdapterName,
"instance_key": caps.InstanceKey,
"targets": strings.Join(targets, ","),
"max_concurrency": strconv.Itoa(caps.MaxConcurrency),
"provider_status": string(runtime.NormalizeProviderStatus(caps.ProviderStatus)),
}
```
Node command response에 provider snapshot을 채운다. `capacity``caps.MaxConcurrency`, `in_flight`는 해당 adapter permit의 `activeCount`, `queued`는 queue 미구현 기준선으로 `0`을 넣는다. result map에도 기존 console 호환을 위해 `capacity`, `in_flight`, `queued` keys를 추가한다.
수정 파일 및 체크리스트:
- [ ] `apps/node/internal/node/node.go`
- [ ] `apps/node/internal/node/run_manager.go` 필요 시 snapshot helper 추가
- [ ] `apps/node/internal/node/node_test.go`
테스트 작성: active run 중 `CAPABILITIES`를 호출해 `in_flight=1`, capacity 값, queued=0을 검증한다. status 값은 `01_probe_contract`의 probe 결과를 사용한다.
중간 검증:
```bash
go test ./apps/node/internal/node
```
### [API-3] Edge Service and Status Propagation
문제: [apps/edge/internal/service/node_command.go](/config/workspace/iop/apps/edge/internal/service/node_command.go:198)는 `resp.GetResult()`만 보존한다. [apps/edge/internal/service/status_provider.go](/config/workspace/iop/apps/edge/internal/service/status_provider.go:12)의 `NodeSnapshot`에도 provider snapshot field가 없다. [apps/edge/internal/controlplane/connector.go](/config/workspace/iop/apps/edge/internal/controlplane/connector.go:367)는 status response nodes에 provider data를 싣지 않는다.
해결 방법: `NodeCommandView``NodeSnapshot``ProviderSnapshots []*iop.ProviderSnapshot`를 추가한다. `sendNodeCommand``resp.GetProviderSnapshots()`를 보존한다. Control Plane status path는 이미 Edge-owned snapshot DTO를 쓰므로 `EdgeNodeSnapshot.ProviderSnapshots`에 복사한다. live query가 필요한 곳은 `Service.Capabilities` 응답을 우선 사용하고, registry-only snapshot에는 nil을 허용한다.
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/service/node_command.go`
- [ ] `apps/edge/internal/service/status_provider.go`
- [ ] `apps/edge/internal/controlplane/connector.go`
- [ ] `apps/edge/internal/opsconsole/status.go`는 typed snapshot이 있으면 stable sorted render에 주요 keys가 보이도록 유지
- [ ] `apps/edge/internal/service/service_test.go`
- [ ] `apps/edge/internal/controlplane/connector_test.go`
- [ ] `apps/edge/internal/transport/server_test.go`
테스트 작성: service command test에서 provider snapshots가 preserved 되는지, controlplane status test에서 EdgeNodeSnapshot에 provider snapshots가 복사되는지 검증한다.
중간 검증:
```bash
go test ./apps/edge/internal/service ./apps/edge/internal/controlplane ./apps/edge/internal/transport
```
## 수정 파일 요약
| 파일 | 항목 |
|---|---|
| `proto/iop/runtime.proto` | API-1 |
| `proto/iop/control.proto` | API-1 |
| `proto/gen/iop/runtime.pb.go` | API-1 |
| `proto/gen/iop/control.pb.go` | API-1 |
| `apps/node/internal/node/node.go` | API-2 |
| `apps/node/internal/node/run_manager.go` | API-2 |
| `apps/node/internal/node/node_test.go` | API-2 |
| `apps/edge/internal/service/node_command.go` | API-3 |
| `apps/edge/internal/service/status_provider.go` | API-3 |
| `apps/edge/internal/controlplane/connector.go` | API-3 |
| `apps/edge/internal/opsconsole/status.go` | API-3 |
| `apps/edge/internal/service/service_test.go` | API-3 |
| `apps/edge/internal/controlplane/connector_test.go` | API-3 |
| `apps/edge/internal/transport/server_test.go` | API-3 |
## 최종 검증
```bash
go test ./apps/node/... ./apps/edge/... ./packages/go/... ./proto/gen/...
```
기대 결과: 모든 listed package test가 통과한다. proto 변경 후 `make proto` 결과가 source proto와 일치해야 한다. Go test cache 출력은 허용하지 않는다. fresh 실행이 필요하면 구현 에이전트가 `go test -count=1 ...`로 대체하고 사유와 출력을 review stub에 기록한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -30,6 +30,17 @@ func (m *Mock) Capabilities(_ context.Context) (runtime.Capabilities, error) {
AdapterName: Name,
Targets: []string{"mock-echo", "mock-stream"},
MaxConcurrency: 16,
ProviderStatus: runtime.ProviderStatusAvailable,
}, nil
}
func (m *Mock) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
targets := []string{"mock-echo", "mock-stream"}
return runtime.ProviderProbeResult{
AdapterName: Name,
Target: target,
Targets: targets,
Status: runtime.ProviderStatusAvailable,
}, nil
}

View file

@ -50,13 +50,20 @@ func New(cfg config.OllamaConf, logger *zap.Logger, instanceName ...string) *Oll
func (o *Ollama) Name() string { return Name }
func (o *Ollama) Capabilities(_ context.Context) (runtime.Capabilities, error) {
targets := o.fetchTargets()
func (o *Ollama) Capabilities(ctx context.Context) (runtime.Capabilities, error) {
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
targets, err := o.fetchTargets(probeCtx)
status := runtime.ProviderStatusAvailable
if err != nil {
status = runtime.ProviderStatusUnavailable
}
return runtime.Capabilities{
AdapterName: Name,
InstanceKey: o.instanceName,
Targets: targets,
MaxConcurrency: 4,
ProviderStatus: runtime.NormalizeProviderStatus(status),
}, nil
}
@ -192,24 +199,65 @@ func (o *Ollama) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink r
return fmt.Errorf("ollama adapter: stream ended without done=true")
}
func (o *Ollama) fetchTargets() []string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
func (o *Ollama) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
targets, err := o.fetchTargets(probeCtx)
result := runtime.ProviderProbeResult{
AdapterName: Name,
InstanceKey: o.instanceName,
Target: target,
}
if err != nil {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
result.Detail = err.Error()
return result, nil
}
result.Targets = targets
if target == "" {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
return result, nil
}
found := false
for _, t := range targets {
if t == target || (strings.Index(target, ":") == -1 && t == target+":latest") {
found = true
break
}
}
if found {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
} else {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
result.Detail = fmt.Sprintf("target model %q not found in provider models", target)
}
return result, nil
}
func (o *Ollama) fetchTargets(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(o.baseURL, "/api/tags"), nil)
if err != nil {
return nil
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := o.client.Do(req)
if err != nil {
return nil
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil
return nil, fmt.Errorf("status code %d", resp.StatusCode)
}
var tags ollamaTagsResponse
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
return nil
return nil, fmt.Errorf("decode response: %w", err)
}
out := make([]string, 0, len(tags.Models))
for _, model := range tags.Models {
@ -217,7 +265,7 @@ func (o *Ollama) fetchTargets() []string {
out = append(out, model.Name)
}
}
return out
return out, nil
}
func (o *Ollama) HandleCommand(ctx context.Context, req runtime.CommandRequest) (runtime.CommandResponse, error) {

View file

@ -401,3 +401,78 @@ func TestOllamaExecuteStreamsThinkingDeltasSeparately(t *testing.T) {
t.Fatalf("unexpected usage: %+v", events[3].Usage)
}
}
func TestOllamaProbeProviderAvailability(t *testing.T) {
t.Run("200_ok_and_target_hit", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/tags" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"models":[{"name":"llama-a"},{"name":"llama-b"}]}`))
}))
defer server.Close()
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
res, err := adapter.ProbeProvider(context.Background(), "llama-a")
if err != nil {
t.Fatalf("ProbeProvider failed: %v", err)
}
if res.Status != noderuntime.ProviderStatusAvailable {
t.Errorf("expected Status available, got %s", res.Status)
}
if len(res.Targets) != 2 || res.Targets[0] != "llama-a" {
t.Errorf("unexpected Targets: %+v", res.Targets)
}
})
t.Run("200_ok_and_target_miss", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"models":[{"name":"llama-a"}]}`))
}))
defer server.Close()
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
res, err := adapter.ProbeProvider(context.Background(), "llama-b")
if err != nil {
t.Fatalf("ProbeProvider failed: %v", err)
}
if res.Status != noderuntime.ProviderStatusUnavailable {
t.Errorf("expected Status unavailable, got %s", res.Status)
}
if !strings.Contains(res.Detail, "not found") {
t.Errorf("expected 'not found' in detail, got %s", res.Detail)
}
})
t.Run("500_internal_error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
res, err := adapter.ProbeProvider(context.Background(), "llama-a")
if err != nil {
t.Fatalf("ProbeProvider failed: %v", err)
}
if res.Status != noderuntime.ProviderStatusUnavailable {
t.Errorf("expected Status unavailable, got %s", res.Status)
}
})
t.Run("empty_target", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"models":[{"name":"llama-a"}]}`))
}))
defer server.Close()
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
res, err := adapter.ProbeProvider(context.Background(), "")
if err != nil {
t.Fatalf("ProbeProvider failed: %v", err)
}
if res.Status != noderuntime.ProviderStatusAvailable {
t.Errorf("expected Status available, got %s", res.Status)
}
})
}

View file

@ -47,12 +47,20 @@ func New(cfg config.VllmConf, logger *zap.Logger, instanceName ...string) *Vllm
func (v *Vllm) Name() string { return Name }
func (v *Vllm) Capabilities(_ context.Context) (runtime.Capabilities, error) {
func (v *Vllm) Capabilities(ctx context.Context) (runtime.Capabilities, error) {
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
targets, err := v.fetchTargets(probeCtx)
status := runtime.ProviderStatusAvailable
if err != nil {
status = runtime.ProviderStatusUnavailable
}
return runtime.Capabilities{
AdapterName: Name,
InstanceKey: v.instanceName,
Targets: v.fetchTargets(),
Targets: targets,
MaxConcurrency: 8,
ProviderStatus: runtime.NormalizeProviderStatus(status),
}, nil
}
@ -162,27 +170,70 @@ func (v *Vllm) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink run
return fmt.Errorf("vllm adapter: stream ended without [DONE]")
}
func (v *Vllm) fetchTargets() []string {
if v.endpoint == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// ProbeProvider checks the availability of the vLLM (or OpenAI-compatible, e.g., SGLang) endpoint
// and the presence of the target model using the OpenAI-compatible /v1/models endpoint.
func (v *Vllm) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
targets, err := v.fetchTargets(probeCtx)
result := runtime.ProviderProbeResult{
AdapterName: Name,
InstanceKey: v.instanceName,
Target: target,
}
if err != nil {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
result.Detail = err.Error()
return result, nil
}
result.Targets = targets
if target == "" {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
return result, nil
}
found := false
for _, t := range targets {
if t == target {
found = true
break
}
}
if found {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
} else {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
result.Detail = fmt.Sprintf("target model %q not found in provider models", target)
}
return result, nil
}
func (v *Vllm) fetchTargets(ctx context.Context) ([]string, error) {
if v.endpoint == "" {
return nil, fmt.Errorf("vllm adapter: endpoint is required")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(v.endpoint, "/v1/models"), nil)
if err != nil {
return nil
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := v.client.Do(req)
if err != nil {
return nil
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil
return nil, fmt.Errorf("status code %d", resp.StatusCode)
}
var models vllmModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&models); err != nil {
return nil
return nil, fmt.Errorf("decode response: %w", err)
}
out := make([]string, 0, len(models.Data))
for _, model := range models.Data {
@ -190,7 +241,7 @@ func (v *Vllm) fetchTargets() []string {
out = append(out, model.ID)
}
}
return out
return out, nil
}
func messagesFromInput(input map[string]any) []vllmMessage {

View file

@ -188,3 +188,78 @@ func TestVllmInstanceKey(t *testing.T) {
t.Fatalf("AdapterName: got %q, want %q", caps.AdapterName, Name)
}
}
func TestVllmProbeProviderAvailability(t *testing.T) {
t.Run("200_ok_and_target_hit", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`))
}))
defer server.Close()
adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop())
res, err := adapter.ProbeProvider(context.Background(), "model-a")
if err != nil {
t.Fatalf("ProbeProvider failed: %v", err)
}
if res.Status != runtime.ProviderStatusAvailable {
t.Errorf("expected Status available, got %s", res.Status)
}
if len(res.Targets) != 2 || res.Targets[0] != "model-a" {
t.Errorf("unexpected Targets: %+v", res.Targets)
}
})
t.Run("200_ok_and_target_miss", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"model-a"}]}`))
}))
defer server.Close()
adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop())
res, err := adapter.ProbeProvider(context.Background(), "model-b")
if err != nil {
t.Fatalf("ProbeProvider failed: %v", err)
}
if res.Status != runtime.ProviderStatusUnavailable {
t.Errorf("expected Status unavailable, got %s", res.Status)
}
if !strings.Contains(res.Detail, "not found") {
t.Errorf("expected 'not found' in detail, got %s", res.Detail)
}
})
t.Run("500_internal_error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop())
res, err := adapter.ProbeProvider(context.Background(), "model-a")
if err != nil {
t.Fatalf("ProbeProvider failed: %v", err)
}
if res.Status != runtime.ProviderStatusUnavailable {
t.Errorf("expected Status unavailable, got %s", res.Status)
}
})
t.Run("empty_target", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"model-a"}]}`))
}))
defer server.Close()
adapter := New(config.VllmConf{Endpoint: server.URL}, zap.NewNop())
res, err := adapter.ProbeProvider(context.Background(), "")
if err != nil {
t.Fatalf("ProbeProvider failed: %v", err)
}
if res.Status != runtime.ProviderStatusAvailable {
t.Errorf("expected Status available, got %s", res.Status)
}
})
}

View file

@ -312,21 +312,65 @@ func (n *Node) handleCapabilitiesCommand(ctx context.Context, req *iop.NodeComma
if err != nil {
return n.commandErrorResponse(req, err.Error())
}
targets := append([]string(nil), caps.Targets...)
providerStatus := caps.ProviderStatus
providerDetail := ""
if prober, ok := adapter.(runtime.ProviderProber); ok {
probeRes, err := prober.ProbeProvider(ctx, req.GetTarget())
if err != nil {
providerStatus = runtime.ProviderStatusUnavailable
providerDetail = err.Error()
} else {
providerStatus = probeRes.Status
providerDetail = probeRes.Detail
if len(probeRes.Targets) > 0 {
targets = append([]string(nil), probeRes.Targets...)
}
}
}
sort.Strings(targets)
n.adapterPermitsMu.Lock()
pm, ok := n.adapterPermits[req.GetAdapter()]
n.adapterPermitsMu.Unlock()
inFlight := 0
if ok {
inFlight = pm.activeCount()
}
result := map[string]string{
"adapter": caps.AdapterName,
"instance_key": caps.InstanceKey,
"targets": strings.Join(targets, ","),
"max_concurrency": strconv.Itoa(caps.MaxConcurrency),
"provider_status": string(runtime.NormalizeProviderStatus(providerStatus)),
"capacity": strconv.Itoa(caps.MaxConcurrency),
"in_flight": strconv.Itoa(inFlight),
"queued": "0",
}
if providerDetail != "" {
result["provider_detail"] = providerDetail
}
providerSnapshot := &iop.ProviderSnapshot{
Adapter: req.GetAdapter(),
Status: string(runtime.NormalizeProviderStatus(providerStatus)),
Capacity: int32(caps.MaxConcurrency),
InFlight: int32(inFlight),
Queued: 0,
}
return &iop.NodeCommandResponse{
RequestId: req.GetRequestId(),
Type: req.GetType(),
Adapter: req.GetAdapter(),
Target: req.GetTarget(),
SessionId: req.GetSessionId(),
Result: result,
RequestId: req.GetRequestId(),
Type: req.GetType(),
Adapter: req.GetAdapter(),
Target: req.GetTarget(),
SessionId: req.GetSessionId(),
Result: result,
ProviderSnapshots: []*iop.ProviderSnapshot{providerSnapshot},
}
}

View file

@ -107,12 +107,18 @@ func (a *instanceKeyAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec,
}
type commandAdapter struct {
lastReq runtime.CommandRequest
lastReq runtime.CommandRequest
providerStatus runtime.ProviderStatus
}
func (a *commandAdapter) Name() string { return "command" }
func (a *commandAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
return runtime.Capabilities{AdapterName: "command", Targets: []string{"v2", "v1"}, MaxConcurrency: 3}, nil
return runtime.Capabilities{
AdapterName: "command",
Targets: []string{"v2", "v1"},
MaxConcurrency: 3,
ProviderStatus: a.providerStatus,
}, nil
}
func (a *commandAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
return nil
@ -136,6 +142,47 @@ func (a *commandAdapter) HandleCommand(ctx context.Context, req runtime.CommandR
}
}
type proberTestAdapter struct {
providerStatus runtime.ProviderStatus
probeTargets []string
probeErr error
}
func (a *proberTestAdapter) Name() string { return "prober" }
func (a *proberTestAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
return runtime.Capabilities{
AdapterName: "prober",
Targets: []string{"model-a"},
MaxConcurrency: 3,
ProviderStatus: a.providerStatus,
}, nil
}
func (a *proberTestAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
return nil
}
func (a *proberTestAdapter) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
if a.probeErr != nil {
return runtime.ProviderProbeResult{}, a.probeErr
}
status := runtime.ProviderStatusAvailable
found := false
for _, t := range a.probeTargets {
if t == target {
found = true
break
}
}
if target != "" && !found {
status = runtime.ProviderStatusUnavailable
}
return runtime.ProviderProbeResult{
AdapterName: "prober",
Target: target,
Targets: a.probeTargets,
Status: status,
}, nil
}
type fixedRouter struct {
adapterName string
adapter runtime.Adapter
@ -558,12 +605,241 @@ func TestOnCommandRequest_Capabilities(t *testing.T) {
if got := resp.GetResult()["max_concurrency"]; got != "3" {
t.Fatalf("result[max_concurrency]: got %q want %q", got, "3")
}
if got := resp.GetResult()["provider_status"]; got != "unknown" {
t.Fatalf("result[provider_status]: got %q want %q", got, "unknown")
}
if got := resp.GetResult()["capacity"]; got != "3" {
t.Fatalf("result[capacity]: got %q want %q", got, "3")
}
if got := resp.GetResult()["in_flight"]; got != "0" {
t.Fatalf("result[in_flight]: got %q want %q", got, "0")
}
if got := resp.GetResult()["queued"]; got != "0" {
t.Fatalf("result[queued]: got %q want %q", got, "0")
}
if len(resp.GetProviderSnapshots()) != 1 {
t.Fatalf("expected 1 provider snapshot, got %d", len(resp.GetProviderSnapshots()))
}
snap := resp.GetProviderSnapshots()[0]
if snap.GetAdapter() != "command" {
t.Fatalf("snap.Adapter: got %q want %q", snap.GetAdapter(), "command")
}
if snap.GetStatus() != "unknown" {
t.Fatalf("snap.Status: got %q want %q", snap.GetStatus(), "unknown")
}
if snap.GetCapacity() != 3 {
t.Fatalf("snap.Capacity: got %d want %d", snap.GetCapacity(), 3)
}
if snap.GetInFlight() != 0 {
t.Fatalf("snap.InFlight: got %d want %d", snap.GetInFlight(), 0)
}
if snap.GetQueued() != 0 {
t.Fatalf("snap.Queued: got %d want %d", snap.GetQueued(), 0)
}
// Capabilities must not go through the adapter CommandHandler.
if ca.lastReq.RequestID != "" {
t.Fatalf("expected CommandHandler to be skipped, got %+v", ca.lastReq)
}
}
func TestOnCommandRequest_Capabilities_InFlight(t *testing.T) {
ba := newBlockingAdapter()
router := &fixedRouter{adapterName: "blocking", adapters: make(map[string]runtime.Adapter)}
router.adapters["blocking"] = ba
n, _ := makeNode(t, router)
// Start a background run which will block.
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
RunId: "run-in-flight",
Adapter: "blocking",
Target: "v1",
Background: true,
})
if err != nil {
t.Fatalf("OnRunRequest: %v", err)
}
select {
case <-ba.started:
case <-time.After(2 * time.Second):
t.Fatal("adapter never started")
}
// Now query Capabilities while the run is active.
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
RequestId: "req-cap",
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
Adapter: "blocking",
Target: "v1",
})
if err != nil {
t.Fatalf("OnCommandRequest: %v", err)
}
if resp.GetError() != "" {
t.Fatalf("expected no error, got %q", resp.GetError())
}
// Verify capacity, in_flight, queued values.
if got := resp.GetResult()["capacity"]; got != "0" {
t.Fatalf("result[capacity]: got %q want %q", got, "0")
}
if got := resp.GetResult()["in_flight"]; got != "1" {
t.Fatalf("result[in_flight]: got %q want %q", got, "1")
}
if got := resp.GetResult()["queued"]; got != "0" {
t.Fatalf("result[queued]: got %q want %q", got, "0")
}
if len(resp.GetProviderSnapshots()) != 1 {
t.Fatalf("expected 1 provider snapshot, got %d", len(resp.GetProviderSnapshots()))
}
snap := resp.GetProviderSnapshots()[0]
if snap.GetAdapter() != "blocking" {
t.Fatalf("snap.Adapter: got %q want %q", snap.GetAdapter(), "blocking")
}
if snap.GetStatus() != "unknown" {
t.Fatalf("snap.Status: got %q want %q", snap.GetStatus(), "unknown")
}
if snap.GetCapacity() != 0 {
t.Fatalf("snap.Capacity: got %d want %d", snap.GetCapacity(), 0)
}
if snap.GetInFlight() != 1 {
t.Fatalf("snap.InFlight: got %d want %d", snap.GetInFlight(), 1)
}
if snap.GetQueued() != 0 {
t.Fatalf("snap.Queued: got %d want %d", snap.GetQueued(), 0)
}
// Clean up.
if err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{RunId: "run-in-flight"}); err != nil {
t.Fatalf("OnCancel: %v", err)
}
<-ba.done
}
func TestOnCommandRequest_CapabilitiesProviderStatusModel(t *testing.T) {
cases := []struct {
name string
status runtime.ProviderStatus
want string
}{
{name: "available", status: runtime.ProviderStatusAvailable, want: "available"},
{name: "unavailable", status: runtime.ProviderStatusUnavailable, want: "unavailable"},
{name: "invalid status folds to unknown", status: runtime.ProviderStatus("degraded"), want: "unknown"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ca := &commandAdapter{providerStatus: tc.status}
router := &fixedRouter{adapterName: "command", adapters: make(map[string]runtime.Adapter)}
router.adapters["command"] = ca
n, _ := makeNode(t, router)
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
RequestId: "req-cap-status",
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
Adapter: "command",
Target: "codex",
})
if err != nil {
t.Fatalf("OnCommandRequest: %v", err)
}
if resp.GetError() != "" {
t.Fatalf("expected no error, got %q", resp.GetError())
}
if got := resp.GetResult()["provider_status"]; got != tc.want {
t.Fatalf("result[provider_status]: got %q want %q", got, tc.want)
}
})
}
}
func TestOnCommandRequest_CapabilitiesWithProber(t *testing.T) {
t.Run("probe_target_hit", func(t *testing.T) {
pa := &proberTestAdapter{
providerStatus: runtime.ProviderStatusAvailable,
probeTargets: []string{"model-a", "model-b"},
}
router := &fixedRouter{adapterName: "prober", adapters: make(map[string]runtime.Adapter)}
router.adapters["prober"] = pa
n, _ := makeNode(t, router)
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
RequestId: "req-cap-prober",
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
Adapter: "prober",
Target: "model-a",
})
if err != nil {
t.Fatalf("OnCommandRequest: %v", err)
}
if resp.GetError() != "" {
t.Fatalf("expected no error, got %q", resp.GetError())
}
if got := resp.GetResult()["provider_status"]; got != "available" {
t.Fatalf("result[provider_status]: got %q want %q", got, "available")
}
if got := resp.GetResult()["targets"]; got != "model-a,model-b" {
t.Fatalf("result[targets]: got %q want %q", got, "model-a,model-b")
}
})
t.Run("probe_target_miss", func(t *testing.T) {
pa := &proberTestAdapter{
providerStatus: runtime.ProviderStatusAvailable,
probeTargets: []string{"model-a"},
}
router := &fixedRouter{adapterName: "prober", adapters: make(map[string]runtime.Adapter)}
router.adapters["prober"] = pa
n, _ := makeNode(t, router)
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
RequestId: "req-cap-prober",
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
Adapter: "prober",
Target: "model-c",
})
if err != nil {
t.Fatalf("OnCommandRequest: %v", err)
}
if resp.GetError() != "" {
t.Fatalf("expected no error, got %q", resp.GetError())
}
if got := resp.GetResult()["provider_status"]; got != "unavailable" {
t.Fatalf("result[provider_status]: got %q want %q", got, "unavailable")
}
})
t.Run("probe_error_folds_to_unavailable", func(t *testing.T) {
pa := &proberTestAdapter{
providerStatus: runtime.ProviderStatusAvailable,
probeErr: fmt.Errorf("network error"),
}
router := &fixedRouter{adapterName: "prober", adapters: make(map[string]runtime.Adapter)}
router.adapters["prober"] = pa
n, _ := makeNode(t, router)
resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{
RequestId: "req-cap-prober",
Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES,
Adapter: "prober",
Target: "model-a",
})
if err != nil {
t.Fatalf("OnCommandRequest: %v", err)
}
// Probe error should NOT cause a command error, but set provider_status to unavailable
if resp.GetError() != "" {
t.Fatalf("expected no error, got %q", resp.GetError())
}
if got := resp.GetResult()["provider_status"]; got != "unavailable" {
t.Fatalf("result[provider_status]: got %q want %q", got, "unavailable")
}
if got := resp.GetResult()["provider_detail"]; got != "network error" {
t.Fatalf("result[provider_detail]: got %q want %q", got, "network error")
}
})
}
// TestOnCommandRequest_CapabilitiesWithoutCommandHandler verifies CAPABILITIES
// succeeds for an adapter that does not implement runtime.CommandHandler.
func TestOnCommandRequest_CapabilitiesWithoutCommandHandler(t *testing.T) {

View file

@ -74,12 +74,32 @@ type UsageStats struct {
OutputTokens int
}
// ProviderStatus is the minimal Edge-visible provider availability state.
type ProviderStatus string
const (
ProviderStatusUnknown ProviderStatus = "unknown"
ProviderStatusAvailable ProviderStatus = "available"
ProviderStatusUnavailable ProviderStatus = "unavailable"
)
// NormalizeProviderStatus keeps provider state within the public baseline.
func NormalizeProviderStatus(status ProviderStatus) ProviderStatus {
switch status {
case ProviderStatusAvailable, ProviderStatusUnavailable:
return status
default:
return ProviderStatusUnknown
}
}
// Capabilities describes what an Adapter can do.
type Capabilities struct {
AdapterName string
InstanceKey string // stable registry instance key; empty for single-instance adapters
Targets []string
MaxConcurrency int
ProviderStatus ProviderStatus
}
// RunRequest is the node-domain representation of an incoming run request.
@ -146,6 +166,23 @@ type CommandHandler interface {
HandleCommand(ctx context.Context, req CommandRequest) (CommandResponse, error)
}
// ProviderProbeResult contains the target-aware probe result of a provider.
type ProviderProbeResult struct {
AdapterName string
InstanceKey string
Target string
Targets []string
Status ProviderStatus
Detail string
Metadata map[string]string
}
// ProviderProber is an optional interface that adapters may implement
// to support active endpoint and target availability checks.
type ProviderProber interface {
ProbeProvider(ctx context.Context, target string) (ProviderProbeResult, error)
}
// EventSink receives RuntimeEvents emitted during execution.
type EventSink interface {
Emit(ctx context.Context, event RuntimeEvent) error