diff --git a/agent-roadmap/phase/inference-provider-extension/PHASE.md b/agent-roadmap/phase/inference-provider-extension/PHASE.md index 3bce509..6996ed4 100644 --- a/agent-roadmap/phase/inference-provider-extension/PHASE.md +++ b/agent-roadmap/phase/inference-provider-extension/PHASE.md @@ -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 기준선을 만든다. diff --git a/agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md b/agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md index 5756819..0050257 100644 --- a/agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md +++ b/agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md @@ -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 - 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/code_review_local_G05_0.log b/agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/code_review_local_G05_0.log new file mode 100644 index 0000000..967f8f4 --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/code_review_local_G05_0.log @@ -0,0 +1,191 @@ + + +# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/complete.log b/agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/complete.log new file mode 100644 index 0000000..921e121 --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/plan_local_G05_0.log b/agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/plan_local_G05_0.log new file mode 100644 index 0000000..1c45d1d --- /dev/null +++ b/agent-task/archive/2026/06/m-provider-availability-capacity-queue-foundation/01_probe_contract/plan_local_G05_0.log @@ -0,0 +1,186 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/CODE_REVIEW-cloud-G06.md b/agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/CODE_REVIEW-cloud-G06.md new file mode 100644 index 0000000..4c70d2f --- /dev/null +++ b/agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/CODE_REVIEW-cloud-G06.md @@ -0,0 +1,152 @@ + + +# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 | diff --git a/agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/PLAN-cloud-G06.md b/agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/PLAN-cloud-G06.md new file mode 100644 index 0000000..ab6aab6 --- /dev/null +++ b/agent-task/m-provider-availability-capacity-queue-foundation/02+01_edge_snapshot/PLAN-cloud-G06.md @@ -0,0 +1,209 @@ + + +# 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 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/node/internal/adapters/mock/mock.go b/apps/node/internal/adapters/mock/mock.go index e441a03..57950ab 100644 --- a/apps/node/internal/adapters/mock/mock.go +++ b/apps/node/internal/adapters/mock/mock.go @@ -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 } diff --git a/apps/node/internal/adapters/ollama/ollama.go b/apps/node/internal/adapters/ollama/ollama.go index f99ffc1..e6ff7bd 100644 --- a/apps/node/internal/adapters/ollama/ollama.go +++ b/apps/node/internal/adapters/ollama/ollama.go @@ -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) { diff --git a/apps/node/internal/adapters/ollama/ollama_test.go b/apps/node/internal/adapters/ollama/ollama_test.go index 506e1da..d663e08 100644 --- a/apps/node/internal/adapters/ollama/ollama_test.go +++ b/apps/node/internal/adapters/ollama/ollama_test.go @@ -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) + } + }) +} diff --git a/apps/node/internal/adapters/vllm/vllm.go b/apps/node/internal/adapters/vllm/vllm.go index 50f983e..5312041 100644 --- a/apps/node/internal/adapters/vllm/vllm.go +++ b/apps/node/internal/adapters/vllm/vllm.go @@ -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 { diff --git a/apps/node/internal/adapters/vllm/vllm_test.go b/apps/node/internal/adapters/vllm/vllm_test.go index 4a45bf6..9375a34 100644 --- a/apps/node/internal/adapters/vllm/vllm_test.go +++ b/apps/node/internal/adapters/vllm/vllm_test.go @@ -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) + } + }) +} diff --git a/apps/node/internal/node/node.go b/apps/node/internal/node/node.go index 4a083d4..187ae6e 100644 --- a/apps/node/internal/node/node.go +++ b/apps/node/internal/node/node.go @@ -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}, } } diff --git a/apps/node/internal/node/node_test.go b/apps/node/internal/node/node_test.go index cf18883..33c648f 100644 --- a/apps/node/internal/node/node_test.go +++ b/apps/node/internal/node/node_test.go @@ -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) { diff --git a/apps/node/internal/runtime/types.go b/apps/node/internal/runtime/types.go index a425f57..268ce42 100644 --- a/apps/node/internal/runtime/types.go +++ b/apps/node/internal/runtime/types.go @@ -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