From 30099e54d2194b1532ab532876c51889f81739d5 Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 11 Jun 2026 08:06:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20m-node-multi-target-serving-foundation?= =?UTF-8?q?=2002+01=5Fadapter=5Fregistry=20=EC=9E=91=EC=97=85=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adapter registry 코드 리뷰 및 계획 파일 업데이트 - Node adapters factory, registry 수정 - Node runtime 및 router 개선 - 테스트 파일 업데이트 --- .../code_review_cloud_G07_0.log | 140 ++++++++++++++ .../code_review_cloud_G07_1.log | 177 ++++++++++++++++++ .../02+01_adapter_registry/complete.log | 44 +++++ .../plan_cloud_G07_1.log | 65 +++++++ .../plan_local_G08_0.log} | 0 .../CODE_REVIEW-cloud-G07.md | 96 ---------- .../adapters/adapters_blackbox_test.go | 136 ++++++++++++++ apps/node/internal/adapters/factory.go | 12 +- apps/node/internal/adapters/registry.go | 90 ++++++--- apps/node/internal/node/node.go | 18 +- apps/node/internal/node/node_test.go | 151 ++++++++++++++- apps/node/internal/router/router.go | 22 ++- apps/node/internal/router/router_test.go | 92 +++++++++ apps/node/internal/runtime/types.go | 3 + 14 files changed, 901 insertions(+), 145 deletions(-) create mode 100644 agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/code_review_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/complete.log create mode 100644 agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/plan_cloud_G07_1.log rename agent-task/{m-node-multi-target-serving-foundation/02+01_adapter_registry/PLAN-local-G08.md => archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/plan_local_G08_0.log} (100%) delete mode 100644 agent-task/m-node-multi-target-serving-foundation/02+01_adapter_registry/CODE_REVIEW-cloud-G07.md diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/code_review_cloud_G07_0.log new file mode 100644 index 0000000..85835e2 --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/code_review_cloud_G07_0.log @@ -0,0 +1,140 @@ + + +# Code Review Reference - REFACTOR + +> **[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. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-10 +task=m-node-multi-target-serving-foundation/02+01_adapter_registry, plan=0, tag=REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Task ids: + - `adapter-registry`: Node adapter registry가 동일 adapter type의 여러 engine/profile instance를 덮어쓰지 않고 라우팅 가능한 target namespace로 관리한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. + +구현을 plan과 대조하고, registry/router namespace가 legacy 단일 요청과 multi instance 요청을 모두 안전하게 처리하는지 검토한다. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Registry Namespace | [x] | + +## 구현 체크리스트 + +- [x] Registry가 adapter type과 instance key를 보존하고 동일 adapter type 여러 instance를 덮어쓰지 않는다. +- [x] Factory가 `NodeConfigPayload`의 multi adapter instance를 registry namespace로 등록한다. +- [x] Router가 request의 adapter/target을 새 namespace로 해석하고 legacy 단일 adapter 요청을 호환한다. +- [x] registry/factory/router/node 단위 테스트를 추가 또는 갱신한다. +- [x] 최종 검증 명령을 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-cloud-G07.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [x] active `PLAN-local-G08.md`를 `plan_local_G08_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `complete.log` 작성 후 archive 이동한다. +- [x] WARN/FAIL이면 후속 active plan/review 또는 `USER_REVIEW.md`를 작성한다. + +## 계획 대비 변경 사항 + +- `runtime/types.go` 변경 없음: 계획에 "필요한 routing metadata 최소 추가"로 열려 있었으나, `ExecutionSpec.Adapter`가 instance key를 그대로 전달하는 구조로 충분하여 추가 필드 없이 구현. `Adapter` 필드의 의미가 type name → instance key로 전환됨. +- `node_test.go` 변경 없음: 기존 테스트가 `fixedRouter` stub을 사용하므로 registry namespace 변경과 무관하게 통과. node 계층 테스트 추가 불필요 판단. + +## 주요 설계 결정 + +- **`entry` 내부 구조체**: `Registry`가 `map[string]entry`로 각 등록 항목의 `typeName`(adapter 타입)과 `adapter` 인스턴스를 함께 보존. key는 instance key(proto `AdapterConfig.name`이 있으면 그것, 없으면 type name). +- **`RegisterKeyed(key, typeName, adapter)` 추가**: Factory가 proto payload의 `name` 필드를 instance key로 명시 등록할 수 있도록 분리. 기존 `Register(a)`는 `a.Name()`을 key와 typeName 양쪽에 사용해 backward compat 유지. +- **`Lookup(name)` 추가, `Get(key)` 유지**: `Get`은 exact key lookup 전용. `Lookup`은 exact → legacy type-name 단일 후보 → ambiguous error 순서로 처리. Router는 `Lookup`으로 전환. `GetAdapter(adapterName)` 인터페이스 시그니처는 유지하되 내부적으로 `Lookup`을 사용하고 error를 `(nil, false)`로 flattening. +- **ambiguous error 메시지**: instance key 목록을 포함시켜 운영자가 올바른 key를 식별할 수 있도록 안내. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- 동일 adapter type 여러 instance가 등록되어도 overwrite가 없는지 확인한다. +- ambiguous legacy adapter 요청이 조용히 임의 target으로 가지 않는지 확인한다. +- lifecycle `Start`/`Stop` 순서와 `All()` 진단 출력이 새 key 기준으로 안정적인지 확인한다. + +## 검증 결과 + +### REFACTOR-1 중간 검증 + +```bash +$ go test -count=1 ./apps/node/internal/adapters/... ./apps/node/internal/router/... +ok iop/apps/node/internal/adapters 0.006s +ok iop/apps/node/internal/adapters/cli 42.397s +? iop/apps/node/internal/adapters/cli/internal/testutil [no test files] +ok iop/apps/node/internal/adapters/cli/status 39.900s +? iop/apps/node/internal/adapters/mock [no test files] +ok iop/apps/node/internal/adapters/ollama 0.008s +ok iop/apps/node/internal/adapters/vllm 0.007s +ok iop/apps/node/internal/router 0.006s +``` + +### 최종 검증 + +```bash +$ go test -count=1 ./apps/node/... +ok iop/apps/node/cmd/node 0.012s +ok iop/apps/node/internal/adapters 0.008s +ok iop/apps/node/internal/adapters/cli 42.426s +? iop/apps/node/internal/adapters/cli/internal/testutil [no test files] +ok iop/apps/node/internal/adapters/cli/status 39.849s +? iop/apps/node/internal/adapters/mock [no test files] +ok iop/apps/node/internal/adapters/ollama 0.012s +ok iop/apps/node/internal/adapters/vllm 0.012s +ok iop/apps/node/internal/bootstrap 0.262s +ok iop/apps/node/internal/node 0.012s +ok iop/apps/node/internal/router 0.005s +? iop/apps/node/internal/runtime [no test files] +ok iop/apps/node/internal/store 0.045s +ok iop/apps/node/internal/terminal 0.445s +ok iop/apps/node/internal/transport 5.137s +``` + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - Correctness: Fail + - Completeness: Fail + - Test coverage: Fail + - API contract: Fail + - Code quality: Pass + - Plan deviation: Warn + - Verification trust: Fail +- 발견된 문제: + - Required: `apps/node/internal/router/router.go:67`의 `GetAdapter`가 `Registry.Lookup`의 ambiguous error를 `(nil, false)`로 접으면서, `apps/node/internal/node/node.go:162`와 `apps/node/internal/node/node.go:209`의 cancel/command 경로가 multi instance legacy adapter 요청을 `"not found"`로만 보고합니다. 계획은 ambiguous legacy 요청에 명확한 error와 instance key 안내를 요구했고, `Registry.Lookup` 자체는 `apps/node/internal/adapters/registry.go:76`에서 그 정보를 만들고 있습니다. 해결: router 인터페이스에 error-returning adapter lookup을 추가하거나 기존 `GetAdapter` 계약을 바꿔 command/cancel 경로가 ambiguous 원인과 key 후보를 그대로 응답하게 만들고, node command/cancel 테스트를 추가하세요. + - Required: 이 변경은 `apps/node/internal/adapters/**`와 `apps/node/internal/router/**`의 adapter registry/실행 요청 경로를 바꿉니다. Node domain rule은 해당 경로 변경 후 testing domain rule의 작업 후 검증 기준을 따르라고 하며, testing rule은 edge-node registry/input surface 변경이 사용자 실행 파이프라인에 닿으면 Go 테스트 외 repo 내부 edge-node 진단과 full-cycle 실제 구동 기준을 함께 요구합니다. 현재 review evidence는 `go test -count=1 ./apps/node/...`까지만 있어 필수 실행 검증이 비어 있습니다. 해결: `agent-test/local/node-smoke.md` 기준으로 보조 smoke 및 repo 내부 edge-node/full-cycle evidence를 수집하거나, 실행 불가 시 정확한 blocker를 기록하세요. +- 리뷰어 재검증: + - `go test -count=1 ./apps/node/...` - PASS; reviewer rerun 완료. +- 다음 단계: + - FAIL: 위 Required 항목을 해결하는 후속 `PLAN-*`/`CODE_REVIEW-*`를 작성한다. diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/code_review_cloud_G07_1.log new file mode 100644 index 0000000..513d499 --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/code_review_cloud_G07_1.log @@ -0,0 +1,177 @@ + + +# Code Review Reference - REVIEW_REFACTOR + +> **[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. + +## 개요 + +date=2026-06-11 +task=m-node-multi-target-serving-foundation/02+01_adapter_registry, plan=1, tag=REVIEW_REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Task ids: + - `adapter-registry`: Node adapter registry가 동일 adapter type의 여러 engine/profile instance를 덮어쓰지 않고 라우팅 가능한 target namespace로 관리한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. + +이 follow-up은 1차 리뷰 Required 항목만 본다: command/cancel adapter lookup의 ambiguous error 보존, node command/cancel 테스트, Node smoke/full-cycle evidence. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_REFACTOR-1] Error-Preserving Adapter Lookup | [x] | +| [REVIEW_REFACTOR-2] Required Runtime Verification Evidence | [x] | + +## 구현 체크리스트 + +- [x] Command/cancel adapter lookup 경로가 ambiguous legacy adapter 요청의 원인과 instance key 후보를 명확히 반환한다. +- [x] node command/cancel 단위 테스트가 multi-instance ambiguous lookup과 exact instance key lookup을 검증한다. +- [x] 최종 Go 검증과 Node smoke/full-cycle 검증을 실행하거나, 실행 불가 시 blocker 근거를 기록한다. +- [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_{review_lane}_GNN_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_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/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다. + +## 계획 대비 변경 사항 + +- 계획 대비 변경 없음. `LookupAdapter` 추가 옵션을 선택, `GetAdapter` 시그니처는 그대로 유지해 기존 call site 영향 최소화. + +## 주요 설계 결정 + +- **`LookupAdapter(adapterName string) (Adapter, error)` 추가**: `runtime.Router` 인터페이스에 새 메서드를 추가해 ambiguous error를 보존. `GetAdapter`는 backward compat 유지. +- **`node.go` 3개 경로 교체**: `OnCancel` (terminate session), `handleCapabilitiesCommand`, `dispatchAdapterCommand` 모두 `LookupAdapter`로 전환. 오류 메시지에 `Lookup` 원문 포함 (`fmt.Errorf("node: %w", err)` / `fmt.Sprintf("node: %s", err.Error())`). +- **`fixedRouter.lookupErrors` 필드 추가**: node_test.go 스텁에 per-adapter lookup error 주입 기능을 추가해 ambiguous 시나리오 테스트를 단순하게 유지. +- **full-cycle 차단**: 외부 Edge TCP 접근(toki-labs.com:19090)은 현재 컨테이너에서 불가. local rules.md 기준 Docker/code-server/full-cycle은 원격 runner 환경 요구. 보조 smoke(`./scripts/e2e-smoke.sh`)는 통과. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `Registry.Lookup`의 ambiguous error가 run, command, terminate-session 표면에서 조용히 `"not found"`로 바뀌지 않는지 확인한다. +- exact instance key 요청은 기존 command/cancel 흐름에서 정상 동작하는지 확인한다. +- 검증 결과에 `go test`, 보조 smoke, full-cycle 또는 명확한 blocker evidence가 있는지 확인한다. + +## 검증 결과 + +### REVIEW_REFACTOR-1 중간 검증 + +```bash +$ go test -count=1 ./apps/node/internal/router/... ./apps/node/internal/node/... +ok iop/apps/node/internal/router 0.005s +ok iop/apps/node/internal/node 0.012s +``` + +### REVIEW_REFACTOR-2 중간 검증 및 보조 smoke + +```bash +$ go test -count=1 ./apps/node/... +ok iop/apps/node/cmd/node 0.019s +ok iop/apps/node/internal/adapters 0.010s +ok iop/apps/node/internal/adapters/cli 42.279s +? iop/apps/node/internal/adapters/cli/internal/testutil [no test files] +ok iop/apps/node/internal/adapters/cli/status 39.811s +? 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.009s +ok iop/apps/node/internal/bootstrap 0.265s +ok iop/apps/node/internal/node 0.017s +ok iop/apps/node/internal/router 0.006s +? iop/apps/node/internal/runtime [no test files] +ok iop/apps/node/internal/store 0.059s +ok iop/apps/node/internal/terminal 0.461s +ok iop/apps/node/internal/transport 5.143s + +$ ./scripts/e2e-smoke.sh +[e2e] NOTE: auxiliary smoke only; completion requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification. +[e2e] prompt templates: first=status-ok second=ack-short background=ready-short base=6 +[e2e] starting smoke test (profile: mock, port: 39322, persistent: 1, has_status: 0) +[e2e] waiting for node registration (timeout: 60s) +[e2e] > /nodes +[e2e] > /capabilities +[e2e] > /transport +[e2e] > 상태가 정상인지 짧게 확인합니다. 답은 IOP_E2E_STATUS_OK 하나만 출력하세요. +[e2e] > 확인했다는 짧은 응답 테스트입니다. IOP_E2E_ACK_SHORT 만 답하세요. +[e2e] > /session session2 +[e2e] > /background on +[e2e] > 준비되었는지 묻는 짧은 테스트입니다. IOP_E2E_READY_SHORT 만 출력하세요. +[e2e] > /background off +[e2e] > /sessions +[e2e] > /terminate-session +[e2e] > /exit +... (node 등록, capabilities/transport command, 메시지 2회 왕복, background run, sessions, terminate-session 모두 확인) ... +[e2e] Auxiliary smoke test PASSED. +[e2e] Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification. +``` + +### 최종 검증 + +```bash +$ go test -count=1 ./apps/node/... +(REVIEW_REFACTOR-2 중간 검증과 동일 - 전체 통과) +``` + +Full-cycle evidence: + +- **실행한 명령**: `./scripts/e2e-smoke.sh` (보조 smoke) — PASSED +- **성공한 검증**: node 등록, /capabilities, /transport command, 메시지 2회 왕복 (session default + session2), background run, /sessions, /terminate-session +- **차단된 검증**: repo 내부 external edge-node full-cycle (toki-labs.com:19090 TCP) +- **차단 사유**: local rules.md — 현재 작업 컨테이너에서는 Docker-in-Docker 불가, full-cycle은 원격 runner 환경(ssh toki@toki-labs.com) 또는 code-server 필요. 외부 Edge TCP 포트 직접 접근 불가. +- **차단 주체**: user-owned external environment (원격 runner 접근, field Edge 실행) +- **남은 위험**: 실제 외부 Edge에 연결된 multi-instance registry 시나리오(ollama@local + ollama@dgx)의 end-to-end 라우팅 검증은 원격 runner에서 필요. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - Correctness: Pass + - Completeness: Pass + - Test coverage: Pass + - API contract: Pass + - Code quality: Pass + - Plan deviation: Pass + - Verification trust: Pass +- 발견된 문제: 없음 +- 리뷰어 재검증: + - `go test -count=1 ./apps/node/internal/router/... ./apps/node/internal/node/...` - PASS; router/node follow-up 테스트 통과. + - `go test -count=1 ./apps/node/...` - PASS; Node 전체 테스트 통과. + - `./scripts/e2e-smoke.sh` - PASS; 보조 smoke가 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 실행해 node 등록, `/capabilities`, `/transport`, 메시지 2회 왕복, background run, `/sessions`, `/terminate-session`을 확인. +- 다음 단계: + - PASS: `complete.log` 작성 후 task archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/complete.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/complete.log new file mode 100644 index 0000000..fb0e3bc --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/complete.log @@ -0,0 +1,44 @@ +# Complete - m-node-multi-target-serving-foundation/02+01_adapter_registry + +## 완료 일시 + +2026-06-10 + +## 요약 + +Node adapter registry namespace follow-up까지 2회 리뷰 루프로 완료했으며 최종 판정은 PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G08_0.log` | `code_review_cloud_G07_0.log` | FAIL | command/cancel ambiguous adapter error 보존과 Node smoke/full-cycle evidence 보완 필요 | +| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | `LookupAdapter`로 error-preserving lookup을 추가하고 reviewer 재검증까지 통과 | + +## 구현/정리 내용 + +- Registry가 adapter type과 instance key를 함께 보존하고, multi instance legacy lookup의 ambiguous error를 반환하도록 정리했다. +- Factory가 `NodeConfigPayload.adapters[].name`을 instance key로 등록하도록 갱신했다. +- Router/Node command/cancel 경로가 ambiguous adapter lookup 원인과 instance key 안내를 보존하도록 `LookupAdapter` 계약을 추가했다. +- registry, router, node command/cancel 테스트와 Node 보조 smoke evidence를 보강했다. + +## 최종 검증 + +- `go test -count=1 ./apps/node/internal/router/... ./apps/node/internal/node/...` - PASS; router/node follow-up 테스트 통과. +- `go test -count=1 ./apps/node/...` - PASS; Node 전체 테스트 통과. +- `./scripts/e2e-smoke.sh` - PASS; node 등록, `/capabilities`, `/transport`, 메시지 2회 왕복, background run, `/sessions`, `/terminate-session` 확인. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Completed task ids: + - `adapter-registry`: PASS; evidence=`plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`; verification=`go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/plan_cloud_G07_1.log new file mode 100644 index 0000000..89fab6b --- /dev/null +++ b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/plan_cloud_G07_1.log @@ -0,0 +1,65 @@ + + +# Plan - REVIEW_REFACTOR + +## 이 파일을 읽는 구현 에이전트에게 + +이 계획은 이전 리뷰의 Required 항목만 해결한다. 구현 완료의 마지막 단계는 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션 작성이다. 사용자 전용 결정이나 외부 환경 prerequisite 없이는 사용자에게 직접 질문하지 말고, 필요한 경우 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 기록한 뒤 멈춘다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` +- Task ids: + - `adapter-registry`: Node adapter registry가 동일 adapter type의 여러 engine/profile instance를 덮어쓰지 않고 라우팅 가능한 target namespace로 관리한다. +- Completion mode: check-on-pass + +## 배경 + +1차 리뷰는 `FAIL`이다. registry/router의 핵심 namespace 구현은 들어갔지만, `Router.GetAdapter`가 `Registry.Lookup`의 ambiguous error를 bool로 접어 command/cancel 경로에서 명확한 instance key 안내를 잃는다. 또한 adapter registry 실행 경로 변경에 대해 Node smoke/full-cycle 검증 증거가 부족하다. + +## 범위 결정 근거 + +- 새 adapter namespace 구조를 다시 설계하지 않는다. +- Edge route catalog, provider route table, engine profile 실행, concurrency scheduling은 sibling task 범위로 남긴다. +- 이 follow-up은 ambiguous error 보존과 검증 증거 회복만 수행한다. + +## 구현 체크리스트 + +- [ ] Command/cancel adapter lookup 경로가 ambiguous legacy adapter 요청의 원인과 instance key 후보를 명확히 반환한다. +- [ ] node command/cancel 단위 테스트가 multi-instance ambiguous lookup과 exact instance key lookup을 검증한다. +- [ ] 최종 Go 검증과 Node smoke/full-cycle 검증을 실행하거나, 실행 불가 시 blocker 근거를 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_REFACTOR-1] Error-Preserving Adapter Lookup + +- 문제: `apps/node/internal/router/router.go:67`의 `GetAdapter`가 `Lookup` error를 `(nil, false)`로 변환한다. `apps/node/internal/node/node.go:162`와 `apps/node/internal/node/node.go:209`는 이 bool만 보고 `"adapter ... not found"`를 반환하므로, multi-instance 상태에서 legacy `"ollama"` command/cancel 요청이 ambiguous임을 알 수 없다. +- 해결 방법: router/node 경계에 error-preserving lookup을 추가한다. 예: `LookupAdapter(adapterName string) (runtime.Adapter, error)`를 `runtime.Router`에 추가하거나, 기존 `GetAdapter` 계약을 error-returning 형태로 바꾸고 모든 call site/test stub을 갱신한다. node command/cancel 응답은 `Registry.Lookup`의 ambiguous 메시지 또는 동등하게 명확한 key 후보 안내를 보존해야 한다. +- 수정 파일 및 체크리스트: + - [ ] `apps/node/internal/runtime/types.go`: router adapter lookup 계약 갱신. + - [ ] `apps/node/internal/router/router.go`: ambiguous/not found error를 보존하는 lookup 구현. + - [ ] `apps/node/internal/node/node.go`: cancel, capabilities, adapter command dispatch의 error response 갱신. + - [ ] `apps/node/internal/node/node_test.go`: capabilities/adapter command/session termination에서 ambiguous legacy 요청과 exact instance key 요청을 검증. + - [ ] `apps/node/internal/router/router_test.go`: 갱신된 router lookup 계약 테스트 보강. +- 중간 검증: + - `go test -count=1 ./apps/node/internal/router/... ./apps/node/internal/node/...` + +### [REVIEW_REFACTOR-2] Required Runtime Verification Evidence + +- 문제: Node domain rule은 adapter/router 실행 경로 변경 후 testing domain rule의 검증 기준을 따르라고 한다. 현재 증거는 `go test -count=1 ./apps/node/...`뿐이라, adapter registry/input surface 변경에 필요한 edge-node smoke/full-cycle 증거가 없다. +- 해결 방법: 로컬 규칙의 `node-smoke` 기준에 맞춰 Go test, 보조 smoke, repo 내부 edge-node 진단/full-cycle 결과를 기록한다. full-cycle을 실행할 수 없으면 실행한 명령, 실제 stdout/stderr, 불가 사유, user-owned/external blocker 여부를 review stub의 `사용자 리뷰 요청` 또는 `검증 결과`에 명확히 남긴다. +- 수정 파일 및 체크리스트: + - [ ] 코드 변경이 없어도 검증 증거를 `CODE_REVIEW-cloud-G07.md`에 기록. + - [ ] 가능하면 `./scripts/e2e-smoke.sh`를 실행하고 결과를 기록. + - [ ] `agent-test/local/node-smoke.md`의 repo 내부 edge-node 진단/full-cycle 기준을 수행하고, `/nodes`, 메시지 2회, 관련 command 결과 증거를 기록. +- 중간 검증: + - `go test -count=1 ./apps/node/...` + - `./scripts/e2e-smoke.sh` + +## 최종 검증 + +```bash +go test -count=1 ./apps/node/... +./scripts/e2e-smoke.sh +``` + +또한 `agent-test/local/node-smoke.md`의 full-cycle 기준을 수행해 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. 수행하지 못한 항목은 정확한 명령과 stdout/stderr, 차단 사유를 남긴다. diff --git a/agent-task/m-node-multi-target-serving-foundation/02+01_adapter_registry/PLAN-local-G08.md b/agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/plan_local_G08_0.log similarity index 100% rename from agent-task/m-node-multi-target-serving-foundation/02+01_adapter_registry/PLAN-local-G08.md rename to agent-task/archive/2026/06/m-node-multi-target-serving-foundation/02+01_adapter_registry/plan_local_G08_0.log diff --git a/agent-task/m-node-multi-target-serving-foundation/02+01_adapter_registry/CODE_REVIEW-cloud-G07.md b/agent-task/m-node-multi-target-serving-foundation/02+01_adapter_registry/CODE_REVIEW-cloud-G07.md deleted file mode 100644 index e0b094e..0000000 --- a/agent-task/m-node-multi-target-serving-foundation/02+01_adapter_registry/CODE_REVIEW-cloud-G07.md +++ /dev/null @@ -1,96 +0,0 @@ - - -# Code Review Reference - REFACTOR - -> **[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. -> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. -> Finalization is review-agent-only. - -## 개요 - -date=2026-06-10 -task=m-node-multi-target-serving-foundation/02+01_adapter_registry, plan=0, tag=REFACTOR - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/node-multi-target-serving-foundation.md` -- Task ids: - - `adapter-registry`: Node adapter registry가 동일 adapter type의 여러 engine/profile instance를 덮어쓰지 않고 라우팅 가능한 target namespace로 관리한다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. - -구현을 plan과 대조하고, registry/router namespace가 legacy 단일 요청과 multi instance 요청을 모두 안전하게 처리하는지 검토한다. - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [REFACTOR-1] Registry Namespace | [ ] | - -## 구현 체크리스트 - -- [ ] Registry가 adapter type과 instance key를 보존하고 동일 adapter type 여러 instance를 덮어쓰지 않는다. -- [ ] Factory가 `NodeConfigPayload`의 multi adapter instance를 registry namespace로 등록한다. -- [ ] Router가 request의 adapter/target을 새 namespace로 해석하고 legacy 단일 adapter 요청을 호환한다. -- [ ] registry/factory/router/node 단위 테스트를 추가 또는 갱신한다. -- [ ] 최종 검증 명령을 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] active `CODE_REVIEW-cloud-G07.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. -- [ ] active `PLAN-local-G08.md`를 `plan_local_G08_M.log`로 아카이브한다. -- [ ] PASS이면 `complete.log` 작성 후 archive 이동한다. -- [ ] WARN/FAIL이면 후속 active plan/review 또는 `USER_REVIEW.md`를 작성한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- 동일 adapter type 여러 instance가 등록되어도 overwrite가 없는지 확인한다. -- ambiguous legacy adapter 요청이 조용히 임의 target으로 가지 않는지 확인한다. -- lifecycle `Start`/`Stop` 순서와 `All()` 진단 출력이 새 key 기준으로 안정적인지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -### REFACTOR-1 중간 검증 - -```bash -$ go test -count=1 ./apps/node/internal/adapters/... ./apps/node/internal/router/... -(output) -``` - -### 최종 검증 - -```bash -$ go test -count=1 ./apps/node/... -(output) -``` diff --git a/apps/node/internal/adapters/adapters_blackbox_test.go b/apps/node/internal/adapters/adapters_blackbox_test.go index 5640cce..e3afe0e 100644 --- a/apps/node/internal/adapters/adapters_blackbox_test.go +++ b/apps/node/internal/adapters/adapters_blackbox_test.go @@ -261,3 +261,139 @@ func TestRegistryLifecycle_NonLifecycleAdapterSkipped(t *testing.T) { t.Fatalf("Stop: %v", err) } } + +// --- Registry namespace tests --- + +func TestRegistry_MultiInstanceSameType(t *testing.T) { + log := []string{} + reg := adapters.NewRegistry() + reg.RegisterKeyed("ollama@local", "ollama", &lifecycleAdapter{name: "ollama@local", log: &log}) + reg.RegisterKeyed("ollama@dgx", "ollama", &lifecycleAdapter{name: "ollama@dgx", log: &log}) + + a1, ok1 := reg.Get("ollama@local") + a2, ok2 := reg.Get("ollama@dgx") + if !ok1 || !ok2 { + t.Fatalf("expected both instances to be retrievable by key; ok1=%v ok2=%v", ok1, ok2) + } + if a1 == a2 { + t.Fatal("expected distinct adapter instances") + } + if a1.Name() != "ollama@local" || a2.Name() != "ollama@dgx" { + t.Errorf("adapter names: got %q and %q", a1.Name(), a2.Name()) + } + + all := reg.All() + if len(all) != 2 { + t.Fatalf("expected 2 adapters in All(), got %d", len(all)) + } +} + +func TestRegistry_LegacyLookupSingleInstance(t *testing.T) { + reg := adapters.NewRegistry() + reg.RegisterKeyed("ollama@prod", "ollama", &lifecycleAdapter{name: "ollama@prod", log: nil}) + + // Exact key lookup must work. + a, err := reg.Lookup("ollama@prod") + if err != nil { + t.Fatalf("Lookup by instance key: %v", err) + } + if a.Name() != "ollama@prod" { + t.Errorf("expected ollama@prod, got %q", a.Name()) + } + + // Legacy type-name lookup works when there is exactly one instance. + a2, err := reg.Lookup("ollama") + if err != nil { + t.Fatalf("legacy Lookup by type name: %v", err) + } + if a2.Name() != "ollama@prod" { + t.Errorf("expected ollama@prod via legacy lookup, got %q", a2.Name()) + } +} + +func TestRegistry_AmbiguousLegacyLookup(t *testing.T) { + log := []string{} + reg := adapters.NewRegistry() + reg.RegisterKeyed("ollama@local", "ollama", &lifecycleAdapter{name: "ollama@local", log: &log}) + reg.RegisterKeyed("ollama@dgx", "ollama", &lifecycleAdapter{name: "ollama@dgx", log: &log}) + + _, err := reg.Lookup("ollama") + if err == nil { + t.Fatal("expected ambiguous error for type-name lookup with multiple instances") + } + if !strings.Contains(err.Error(), "ambiguous") { + t.Errorf("expected 'ambiguous' in error, got %v", err) + } + if !strings.Contains(err.Error(), "instance key") { + t.Errorf("expected guidance to use instance key, got %v", err) + } +} + +func TestRegistry_LifecycleOrderMultiInstance(t *testing.T) { + log := []string{} + reg := adapters.NewRegistry() + reg.RegisterKeyed("ollama@local", "ollama", &lifecycleAdapter{name: "ollama@local", log: &log}) + reg.RegisterKeyed("ollama@dgx", "ollama", &lifecycleAdapter{name: "ollama@dgx", log: &log}) + reg.RegisterKeyed("cli", "cli", &lifecycleAdapter{name: "cli", log: &log}) + + ctx := context.Background() + if err := reg.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + if err := reg.Stop(ctx); err != nil { + t.Fatalf("Stop: %v", err) + } + + want := []string{ + "start:ollama@local", "start:ollama@dgx", "start:cli", + "stop:cli", "stop:ollama@dgx", "stop:ollama@local", + } + if len(log) != len(want) { + t.Fatalf("expected log %v, got %v", want, log) + } + for i, got := range log { + if got != want[i] { + t.Fatalf("log[%d]: want %q, got %q", i, want[i], got) + } + } +} + +func TestBuildFromPayload_MultiInstanceSameType(t *testing.T) { + reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + { + Type: "ollama", + Name: "ollama@local", + Enabled: true, + Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "http://localhost:11434"}}, + }, + { + Type: "ollama", + Name: "ollama@dgx", + Enabled: true, + Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "http://192.168.1.10:11434"}}, + }, + }, + }, zap.NewNop()) + if err != nil { + t.Fatalf("build from payload: %v", err) + } + + if len(reg.All()) != 2 { + t.Fatalf("expected 2 adapters, got %d", len(reg.All())) + } + if _, ok := reg.Get("ollama@local"); !ok { + t.Error("expected ollama@local to be registered") + } + if _, ok := reg.Get("ollama@dgx"); !ok { + t.Error("expected ollama@dgx to be registered") + } + // Legacy type-name lookup must fail with ambiguous error. + if _, err := reg.Lookup("ollama"); err == nil { + t.Error("expected ambiguous error for legacy type-name lookup") + } + // Exact key lookup must succeed. + if _, err := reg.Lookup("ollama@local"); err != nil { + t.Errorf("Lookup ollama@local: %v", err) + } +} diff --git a/apps/node/internal/adapters/factory.go b/apps/node/internal/adapters/factory.go index 2b959d9..e799fe6 100644 --- a/apps/node/internal/adapters/factory.go +++ b/apps/node/internal/adapters/factory.go @@ -23,9 +23,11 @@ func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Regi if !ac.GetEnabled() { continue } - switch ac.GetType() { + instanceKey := ac.GetName() + typeName := ac.GetType() + switch typeName { case "mock": - reg.Register(mock.New(logger)) + reg.RegisterKeyed(instanceKey, typeName, mock.New(logger)) case "ollama": var cfg config.OllamaConf if m := ac.GetOllama(); m != nil { @@ -33,7 +35,7 @@ func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Regi } else { cfg = ollamaConfFromStruct(ac.GetSettings()) } - reg.Register(ollama.New(cfg, logger)) + reg.RegisterKeyed(instanceKey, typeName, ollama.New(cfg, logger)) case "vllm": return nil, fmt.Errorf(vllmDisabledError) case "cli": @@ -43,9 +45,9 @@ func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Regi } else { cfg = cliConfFromStruct(ac.GetSettings()) } - reg.Register(cli.New(cfg, logger)) + reg.RegisterKeyed(instanceKey, typeName, cli.New(cfg, logger)) default: - return nil, fmt.Errorf("adapters: unknown adapter type %q", ac.GetType()) + return nil, fmt.Errorf("adapters: unknown adapter type %q", typeName) } } return reg, nil diff --git a/apps/node/internal/adapters/registry.go b/apps/node/internal/adapters/registry.go index 09b6f5d..ccc2c3b 100644 --- a/apps/node/internal/adapters/registry.go +++ b/apps/node/internal/adapters/registry.go @@ -13,43 +13,83 @@ type LifecycleAdapter interface { Stop(ctx context.Context) error } -// Registry holds all registered adapters by name. +type entry struct { + typeName string + adapter runtime.Adapter +} + +// Registry holds all registered adapters keyed by instance key. type Registry struct { - adapters map[string]runtime.Adapter - order []string // insertion order for lifecycle and diagnostics + entries map[string]entry + order []string // insertion order for lifecycle and diagnostics } func NewRegistry() *Registry { - return &Registry{adapters: make(map[string]runtime.Adapter)} + return &Registry{entries: make(map[string]entry)} } -// Register adds an adapter. The first registered adapter becomes the default. +// Register adds an adapter using a.Name() as both instance key and type name. +// This preserves backward compatibility for callers that register by type. func (r *Registry) Register(a runtime.Adapter) { - name := a.Name() - if _, exists := r.adapters[name]; !exists { - r.order = append(r.order, name) - } - r.adapters[name] = a + r.RegisterKeyed(a.Name(), a.Name(), a) } -func (r *Registry) Get(name string) (runtime.Adapter, bool) { - a, ok := r.adapters[name] - return a, ok +// RegisterKeyed adds an adapter with an explicit instance key and type name. +// If key is empty, typeName is used as the key. Duplicate keys replace the +// existing entry but preserve insertion order. +func (r *Registry) RegisterKeyed(key, typeName string, a runtime.Adapter) { + if key == "" { + key = typeName + } + if _, exists := r.entries[key]; !exists { + r.order = append(r.order, key) + } + r.entries[key] = entry{typeName: typeName, adapter: a} +} + +// Get returns the adapter registered under the exact instance key. +func (r *Registry) Get(key string) (runtime.Adapter, bool) { + e, ok := r.entries[key] + return e.adapter, ok +} + +// Lookup returns an adapter by exact instance key first. If no exact match is +// found it falls back to type-name lookup: succeeds when exactly one instance +// of that type is registered, and returns an ambiguous error when multiple +// instances share the same type name. +func (r *Registry) Lookup(name string) (runtime.Adapter, error) { + if e, ok := r.entries[name]; ok { + return e.adapter, nil + } + var matchKeys []string + for _, k := range r.order { + if r.entries[k].typeName == name { + matchKeys = append(matchKeys, k) + } + } + switch len(matchKeys) { + case 0: + return nil, fmt.Errorf("adapter %q not found", name) + case 1: + return r.entries[matchKeys[0]].adapter, nil + default: + return nil, fmt.Errorf("adapter %q is ambiguous: matches instance keys %v; use an instance key", name, matchKeys) + } } // All returns all registered adapters in registration order. func (r *Registry) All() []runtime.Adapter { out := make([]runtime.Adapter, 0, len(r.order)) - for _, name := range r.order { - out = append(out, r.adapters[name]) + for _, key := range r.order { + out = append(out, r.entries[key].adapter) } return out } // MustGet returns the adapter or panics — useful during bootstrap validation. func (r *Registry) MustGet(name string) runtime.Adapter { - a, ok := r.Get(name) - if !ok { + a, err := r.Lookup(name) + if err != nil { panic(fmt.Sprintf("adapter %q not registered", name)) } return a @@ -59,20 +99,20 @@ func (r *Registry) MustGet(name string) runtime.Adapter { // On failure, already-started adapters are stopped in reverse order before returning. func (r *Registry) Start(ctx context.Context) error { started := make([]string, 0, len(r.order)) - for _, name := range r.order { - lc, ok := r.adapters[name].(LifecycleAdapter) + for _, key := range r.order { + lc, ok := r.entries[key].adapter.(LifecycleAdapter) if !ok { continue } if err := lc.Start(ctx); err != nil { for i := len(started) - 1; i >= 0; i-- { - if startedLC, ok := r.adapters[started[i]].(LifecycleAdapter); ok { + if startedLC, ok := r.entries[started[i]].adapter.(LifecycleAdapter); ok { _ = startedLC.Stop(context.Background()) } } - return fmt.Errorf("adapter %q start: %w", name, err) + return fmt.Errorf("adapter %q start: %w", key, err) } - started = append(started, name) + started = append(started, key) } return nil } @@ -82,14 +122,14 @@ func (r *Registry) Start(ctx context.Context) error { func (r *Registry) Stop(ctx context.Context) error { var firstErr error for i := len(r.order) - 1; i >= 0; i-- { - name := r.order[i] - lc, ok := r.adapters[name].(LifecycleAdapter) + key := r.order[i] + lc, ok := r.entries[key].adapter.(LifecycleAdapter) if !ok { continue } if err := lc.Stop(ctx); err != nil { if firstErr == nil { - firstErr = fmt.Errorf("adapter %q stop: %w", name, err) + firstErr = fmt.Errorf("adapter %q stop: %w", key, err) } } } diff --git a/apps/node/internal/node/node.go b/apps/node/internal/node/node.go index 1eccd13..8ec16c9 100644 --- a/apps/node/internal/node/node.go +++ b/apps/node/internal/node/node.go @@ -159,9 +159,9 @@ func (n *Node) OnCancel(_ context.Context, _ *transport.Session, req *iop.Cancel switch cancelActionFromProto(req.GetAction()) { case runtime.CancelActionTerminateSession: - adapter, ok := n.router.GetAdapter(req.GetAdapter()) - if !ok { - return fmt.Errorf("node: adapter %q not found", req.GetAdapter()) + adapter, err := n.router.LookupAdapter(req.GetAdapter()) + if err != nil { + return fmt.Errorf("node: %w", err) } terminator, ok := adapter.(runtime.SessionTerminator) if !ok { @@ -206,9 +206,9 @@ func (n *Node) OnCommandRequest(ctx context.Context, sess *transport.Session, re } func (n *Node) handleCapabilitiesCommand(ctx context.Context, req *iop.NodeCommandRequest) *iop.NodeCommandResponse { - adapter, ok := n.router.GetAdapter(req.GetAdapter()) - if !ok { - return n.commandErrorResponse(req, fmt.Sprintf("node: adapter %q not found", req.GetAdapter())) + adapter, err := n.router.LookupAdapter(req.GetAdapter()) + if err != nil { + return n.commandErrorResponse(req, fmt.Sprintf("node: %s", err.Error())) } caps, err := adapter.Capabilities(ctx) if err != nil { @@ -269,9 +269,9 @@ func (n *Node) handleTransportStatusCommand(sess *transport.Session, req *iop.No } func (n *Node) dispatchAdapterCommand(ctx context.Context, req *iop.NodeCommandRequest) *iop.NodeCommandResponse { - adapter, ok := n.router.GetAdapter(req.GetAdapter()) - if !ok { - return n.commandErrorResponse(req, fmt.Sprintf("node: adapter %q not found", req.GetAdapter())) + adapter, err := n.router.LookupAdapter(req.GetAdapter()) + if err != nil { + return n.commandErrorResponse(req, fmt.Sprintf("node: %s", err.Error())) } handler, ok := adapter.(runtime.CommandHandler) if !ok { diff --git a/apps/node/internal/node/node_test.go b/apps/node/internal/node/node_test.go index a8f1c42..4759ebe 100644 --- a/apps/node/internal/node/node_test.go +++ b/apps/node/internal/node/node_test.go @@ -118,9 +118,10 @@ func (a *commandAdapter) HandleCommand(ctx context.Context, req runtime.CommandR } type fixedRouter struct { - adapterName string - adapter runtime.Adapter - adapters map[string]runtime.Adapter + adapterName string + adapter runtime.Adapter + adapters map[string]runtime.Adapter + lookupErrors map[string]error // optional per-adapter errors for LookupAdapter } func (r *fixedRouter) Resolve(_ context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, error) { @@ -151,6 +152,19 @@ func (r *fixedRouter) ResolveAdapter(ctx context.Context, req runtime.RunRequest return spec, a, nil } +func (r *fixedRouter) LookupAdapter(adapterName string) (runtime.Adapter, error) { + if r.lookupErrors != nil { + if err, ok := r.lookupErrors[adapterName]; ok { + return nil, err + } + } + a, ok := r.adapters[adapterName] + if !ok { + return nil, fmt.Errorf("adapter %q not found", adapterName) + } + return a, nil +} + func (r *fixedRouter) GetAdapter(adapterName string) (runtime.Adapter, bool) { a, ok := r.adapters[adapterName] return a, ok @@ -166,6 +180,10 @@ func (r *errorRouter) ResolveAdapter(_ context.Context, _ runtime.RunRequest) (r return runtime.ExecutionSpec{}, nil, r.err } +func (r *errorRouter) LookupAdapter(_ string) (runtime.Adapter, error) { + return nil, r.err +} + func (r *errorRouter) GetAdapter(_ string) (runtime.Adapter, bool) { return nil, false } @@ -707,3 +725,130 @@ func TestOnCommandRequest_ErrorResponsesPreserveEnvelope(t *testing.T) { }) } } + +// --- LookupAdapter / ambiguous error propagation tests --- + +func TestOnCancel_TerminateSession_AmbiguousAdapterError(t *testing.T) { + ambigErr := fmt.Errorf("adapter \"ollama\" is ambiguous: matches instance keys [ollama@local ollama@dgx]; use an instance key") + router := &fixedRouter{ + adapterName: "ollama", + adapters: make(map[string]runtime.Adapter), + lookupErrors: map[string]error{"ollama": ambigErr}, + } + n, _ := makeNode(t, router) + + err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{ + Adapter: "ollama", + Target: "llama3", + SessionId: "sess-1", + Action: iop.CancelAction_CANCEL_ACTION_TERMINATE_SESSION, + }) + if err == nil { + t.Fatal("expected error for ambiguous adapter lookup") + } + if !strings.Contains(err.Error(), "ambiguous") { + t.Errorf("expected 'ambiguous' in error, got %v", err) + } + if !strings.Contains(err.Error(), "instance key") { + t.Errorf("expected 'instance key' guidance in error, got %v", err) + } +} + +func TestOnCancel_TerminateSession_ExactInstanceKey(t *testing.T) { + ta := &terminatingAdapter{} + router := &fixedRouter{ + adapterName: "ollama@local", + adapters: map[string]runtime.Adapter{"ollama@local": ta}, + } + n, _ := makeNode(t, router) + + err := n.OnCancel(context.Background(), &transport.Session{}, &iop.CancelRequest{ + Adapter: "ollama@local", + Target: "llama3", + SessionId: "sess-a", + Action: iop.CancelAction_CANCEL_ACTION_TERMINATE_SESSION, + }) + if err != nil { + t.Fatalf("OnCancel with exact instance key: %v", err) + } + if atomic.LoadInt32(&ta.terminateCalls) != 1 { + t.Fatal("expected TerminateSession to be called once") + } + if ta.lastTarget != "llama3" || ta.lastSessionID != "sess-a" { + t.Errorf("terminate args: target=%q session=%q", ta.lastTarget, ta.lastSessionID) + } +} + +func TestOnCommandRequest_Capabilities_AmbiguousAdapter(t *testing.T) { + ambigErr := fmt.Errorf("adapter \"cli\" is ambiguous: matches instance keys [cli@claude cli@codex]; use an instance key") + router := &fixedRouter{ + adapterName: "cli", + adapters: make(map[string]runtime.Adapter), + lookupErrors: map[string]error{"cli": ambigErr}, + } + n, _ := makeNode(t, router) + + resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{ + RequestId: "req-ambig-cap", + Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES, + Adapter: "cli", + Target: "codex", + }) + if err != nil { + t.Fatalf("OnCommandRequest: %v", err) + } + if !strings.Contains(resp.GetError(), "ambiguous") { + t.Errorf("expected 'ambiguous' in response error, got %q", resp.GetError()) + } + if !strings.Contains(resp.GetError(), "instance key") { + t.Errorf("expected 'instance key' guidance in response error, got %q", resp.GetError()) + } +} + +func TestOnCommandRequest_AdapterDispatch_AmbiguousAdapter(t *testing.T) { + ambigErr := fmt.Errorf("adapter \"cli\" is ambiguous: matches instance keys [cli@claude cli@codex]; use an instance key") + router := &fixedRouter{ + adapterName: "cli", + adapters: make(map[string]runtime.Adapter), + lookupErrors: map[string]error{"cli": ambigErr}, + } + n, _ := makeNode(t, router) + + resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{ + RequestId: "req-ambig-cmd", + Type: iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS, + Adapter: "cli", + Target: "codex", + }) + if err != nil { + t.Fatalf("OnCommandRequest: %v", err) + } + if !strings.Contains(resp.GetError(), "ambiguous") { + t.Errorf("expected 'ambiguous' in response error, got %q", resp.GetError()) + } +} + +func TestOnCommandRequest_Capabilities_ExactInstanceKey(t *testing.T) { + ca := &commandAdapter{} + router := &fixedRouter{ + adapterName: "ollama@local", + adapters: map[string]runtime.Adapter{"ollama@local": ca}, + } + n, _ := makeNode(t, router) + + resp, err := n.OnCommandRequest(context.Background(), &transport.Session{}, &iop.NodeCommandRequest{ + RequestId: "req-exact-cap", + Type: iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES, + Adapter: "ollama@local", + Target: "llama3", + }) + if err != nil { + t.Fatalf("OnCommandRequest: %v", err) + } + if resp.GetError() != "" { + t.Fatalf("expected no error for exact instance key, got %q", resp.GetError()) + } + if got := resp.GetResult()["adapter"]; got != "command" { + t.Errorf("result[adapter]: got %q want %q", got, "command") + } +} diff --git a/apps/node/internal/router/router.go b/apps/node/internal/router/router.go index 5c51a7e..0a98441 100644 --- a/apps/node/internal/router/router.go +++ b/apps/node/internal/router/router.go @@ -26,8 +26,8 @@ func (r *defaultRouter) Resolve(_ context.Context, req runtime.RunRequest) (runt return runtime.ExecutionSpec{}, fmt.Errorf("router: adapter is required") } - if _, ok := r.registry.Get(adapterName); !ok { - return runtime.ExecutionSpec{}, fmt.Errorf("router: adapter %q not found", adapterName) + if _, err := r.registry.Lookup(adapterName); err != nil { + return runtime.ExecutionSpec{}, fmt.Errorf("router: %w", err) } spec := runtime.ExecutionSpec{ @@ -57,13 +57,21 @@ func (r *defaultRouter) ResolveAdapter(ctx context.Context, req runtime.RunReque if err != nil { return runtime.ExecutionSpec{}, nil, err } - adapter, ok := r.registry.Get(spec.Adapter) - if !ok { - return runtime.ExecutionSpec{}, nil, fmt.Errorf("router: adapter %q not found", spec.Adapter) + adapter, err := r.registry.Lookup(spec.Adapter) + if err != nil { + return runtime.ExecutionSpec{}, nil, fmt.Errorf("router: %w", err) } return spec, adapter, nil } -func (r *defaultRouter) GetAdapter(adapterName string) (runtime.Adapter, bool) { - return r.registry.Get(adapterName) +func (r *defaultRouter) LookupAdapter(adapterName string) (runtime.Adapter, error) { + return r.registry.Lookup(adapterName) +} + +func (r *defaultRouter) GetAdapter(adapterName string) (runtime.Adapter, bool) { + a, err := r.registry.Lookup(adapterName) + if err != nil { + return nil, false + } + return a, true } diff --git a/apps/node/internal/router/router_test.go b/apps/node/internal/router/router_test.go index cacfe27..a1fb433 100644 --- a/apps/node/internal/router/router_test.go +++ b/apps/node/internal/router/router_test.go @@ -110,3 +110,95 @@ func TestResolveAdapter_RequiresExplicitAdapter(t *testing.T) { t.Fatalf("expected adapter required error, got %v", err) } } + +func TestResolveAdapter_InstanceKeyLookup(t *testing.T) { + reg := adapters.NewRegistry() + local := &stubAdapter{name: "ollama@local"} + dgx := &stubAdapter{name: "ollama@dgx"} + reg.RegisterKeyed("ollama@local", "ollama", local) + reg.RegisterKeyed("ollama@dgx", "ollama", dgx) + r := New(reg, zap.NewNop()) + + spec, adapter, err := r.ResolveAdapter(context.Background(), runtime.RunRequest{ + RunID: "run-1", + Adapter: "ollama@local", + Target: "llama3", + }) + if err != nil { + t.Fatalf("ResolveAdapter: %v", err) + } + if spec.Adapter != "ollama@local" { + t.Errorf("spec.Adapter: got %q want %q", spec.Adapter, "ollama@local") + } + if adapter != local { + t.Fatal("expected ollama@local adapter instance") + } + + spec2, adapter2, err := r.ResolveAdapter(context.Background(), runtime.RunRequest{ + RunID: "run-2", + Adapter: "ollama@dgx", + Target: "mixtral", + }) + if err != nil { + t.Fatalf("ResolveAdapter dgx: %v", err) + } + if spec2.Adapter != "ollama@dgx" { + t.Errorf("spec2.Adapter: got %q want %q", spec2.Adapter, "ollama@dgx") + } + if adapter2 != dgx { + t.Fatal("expected ollama@dgx adapter instance") + } +} + +func TestResolveAdapter_AmbiguousLegacyLookup(t *testing.T) { + reg := adapters.NewRegistry() + reg.RegisterKeyed("ollama@local", "ollama", &stubAdapter{name: "ollama@local"}) + reg.RegisterKeyed("ollama@dgx", "ollama", &stubAdapter{name: "ollama@dgx"}) + r := New(reg, zap.NewNop()) + + _, _, err := r.ResolveAdapter(context.Background(), runtime.RunRequest{ + RunID: "run-1", + Adapter: "ollama", + Target: "llama3", + }) + if err == nil { + t.Fatal("expected error for ambiguous adapter type-name with multiple instances") + } + if !strings.Contains(err.Error(), "ambiguous") { + t.Errorf("expected 'ambiguous' in error, got %v", err) + } +} + +func TestResolveAdapter_LegacyTypeName_SingleInstance(t *testing.T) { + reg := adapters.NewRegistry() + single := &stubAdapter{name: "ollama@prod"} + reg.RegisterKeyed("ollama@prod", "ollama", single) + r := New(reg, zap.NewNop()) + + spec, adapter, err := r.ResolveAdapter(context.Background(), runtime.RunRequest{ + RunID: "run-1", + Adapter: "ollama", + Target: "llama3", + }) + if err != nil { + t.Fatalf("ResolveAdapter legacy single: %v", err) + } + if spec.Adapter != "ollama" { + t.Errorf("spec.Adapter: got %q want %q", spec.Adapter, "ollama") + } + if adapter != single { + t.Fatal("expected the single ollama instance") + } +} + +func TestGetAdapter_AmbiguousReturnsNotFound(t *testing.T) { + reg := adapters.NewRegistry() + reg.RegisterKeyed("ollama@a", "ollama", &stubAdapter{name: "ollama@a"}) + reg.RegisterKeyed("ollama@b", "ollama", &stubAdapter{name: "ollama@b"}) + r := New(reg, zap.NewNop()) + + _, ok := r.GetAdapter("ollama") + if ok { + t.Fatal("expected GetAdapter to return false for ambiguous type name") + } +} diff --git a/apps/node/internal/runtime/types.go b/apps/node/internal/runtime/types.go index 2f07710..b6bce3b 100644 --- a/apps/node/internal/runtime/types.go +++ b/apps/node/internal/runtime/types.go @@ -161,6 +161,9 @@ type Adapter interface { type Router interface { Resolve(ctx context.Context, req RunRequest) (ExecutionSpec, error) ResolveAdapter(ctx context.Context, req RunRequest) (ExecutionSpec, Adapter, error) + // LookupAdapter returns an adapter by instance key or legacy type-name, preserving + // ambiguous-lookup errors so callers can surface the exact failure reason. + LookupAdapter(adapterName string) (Adapter, error) GetAdapter(adapterName string) (Adapter, bool) }