feat(edge): 모델 그룹 큐 스케줄링 구현

- 모델 그룹별 큐 기반 디스패팅 로직 추가
- ModelQueue 관리자로 모델 인스턴스 큐 처리
- OpenAPI chat 및 responses 핸들러 업데이트
- edge 서비스 테스트 코드 개선
This commit is contained in:
toki 2026-06-16 10:16:05 +09:00
parent 274ed27375
commit dba289d0fa
21 changed files with 4035 additions and 58 deletions

View file

@ -0,0 +1,173 @@
<!-- task=m-edge-model-group-queue-scheduling/01_edge_queue_service plan=0 tag=REFACTOR -->
# 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; 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-16
task=m-edge-model-group-queue-scheduling/01_edge_queue_service, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `edge-admission`: Edge service가 model group별 FIFO admission queue, capacity, max_queue, queue_timeout, in-flight 상태를 소유한다.
- `node-dispatch`: Edge가 Node별 실행 가능 slot과 terminal run event를 기준으로 queue head를 다음 Node에 순차 dispatch한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-edge-model-group-queue-scheduling/01_edge_queue_service/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] Edge Queue Manager 추가 | [x] |
| [REFACTOR-2] Terminal/Disconnect Release | [x] |
| [REFACTOR-3] Multi-Node Dispatch Candidate | [x] |
## 구현 체크리스트
- [x] Edge service에 model-group queue manager를 추가하고 `SubmitRun` direct send를 queue admission/dispatch 경로로 바꾼다.
- [x] model group별 FIFO, capacity, max_queue, queue_timeout, in-flight 상태를 service-owned state로 관리한다.
- [x] run terminal event와 node disconnect event를 구독해 in-flight slot을 release하고 다음 queue head를 dispatch한다.
- [x] multi-node candidate 중 runnable slot이 있는 Node로 queue head를 순차 dispatch한다.
- [x] `go test -count=1 ./apps/edge/internal/service`가 통과한다.
- [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_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] `.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-edge-model-group-queue-scheduling/01_edge_queue_service/`를 `agent-task/archive/YYYY/MM/m-edge-model-group-queue-scheduling/01_edge_queue_service/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-model-group-queue-scheduling/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] 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로 이동한다.
## 계획 대비 변경 사항
- `status_provider.go` 수정 없음: REFACTOR-3 계획은 provider capacity 읽기 helper를 status_provider.go에서 공유하는 것을 언급했으나, 실제 구현에서는 `modelQueueManager.nodeCapacityFor(nodeID)` 가 `NodeStore.FindByID` + `Runtime.Concurrency`를 직접 참조하는 것으로 충분해 status_provider.go 변경 없이 처리했다.
- `SubscribeAllRuns` / `SubscribeAllNodes` 중앙 watcher 방식 채택: 계획의 "bounded subscription" 요건을 stop 함수 반환 방식으로 충족했다. startEventWatcher가 반환하는 func()을 내부에서 관리하되 Service 외부에 close hook을 노출하지 않았다.
- `SubmitRunRequest.NodeRef == ""` + `ModelGroupKey != ""` 시 adapter 필터링 없이 registry.All() 전체를 후보로 사용: 계획의 "Adapter/Target이 가능한 Node 후보" 필터링은 nodeStore에 adapter 타입 매칭 helper가 없어 초기 구현에서 생략했다. 현 시점에서 등록된 모든 node가 대상이며, adapter 필터링은 후속 작업에서 추가할 수 있다.
## 주요 설계 결정
- **Admit 차단 방식**: `admit()` 내부에서 buffered(1) `chan admitResult`로 대기. timer.C와 waitCh의 동시 도착 race는 "timeout 케이스에서 waitCh를 drain 후 slot 반환"으로 처리.
- **in-flight 추적 순서**: `trackInflight`를 `Send` 직전에 호출해 terminal event가 Send보다 먼저 도착해도 slot이 누수되지 않도록 했다. Send 실패 시 `releaseRun`으로 롤백.
- **node capacity 기본값**: NodeRecord.Runtime.Concurrency가 0이면 defaultNodeCapacity(1) 사용.
- **group max_queue / queue_timeout 기본값**: defaultGroupMaxQueue(16), defaultQueueTimeout(30s). 현재 단계에서는 Edge 설정에 group 단위 policy가 없으므로 하드코딩 상수로 두었다.
- **releaseNode 동작**: 노드 disconnect 시 해당 node의 모든 inflightByRun 항목 삭제 + 모든 group의 inflight[nodeID] 리셋 후 tryDispatch 재시도. 실제 연결이 끊어진 노드에 dispatch를 시도할 수 있으나, Send 실패 시 Service가 releaseSlot을 다시 호출해 다음 후보를 시도할 수 있는 구조로 되어 있다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `SubmitRun`이 `ModelGroupKey` 있는 요청에서 direct send를 우회하지 않는지 확인한다.
- queue release가 foreground/background 모두에서 terminal/disconnect event로 동작하는지 확인한다.
- multi-node dispatch가 registry iteration order에만 의존해 starvation을 만들지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
### REFACTOR-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service -run 'TestModelQueue'
ok iop/apps/edge/internal/service 0.129s
```
### REFACTOR-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service -run 'TestModelQueue(Terminal|NodeDisconnect)'
=== RUN TestModelQueueTerminalReleaseDispatchesNext
=== RUN TestModelQueueTerminalReleaseDispatchesNext/complete
=== RUN TestModelQueueTerminalReleaseDispatchesNext/error
=== RUN TestModelQueueTerminalReleaseDispatchesNext/cancelled
--- PASS: TestModelQueueTerminalReleaseDispatchesNext (0.10s)
=== RUN TestModelQueueNodeDisconnectReleasesInflight
--- PASS: TestModelQueueNodeDisconnectReleasesInflight (0.00s)
ok iop/apps/edge/internal/service 0.131s
```
### REFACTOR-3 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueue'
=== RUN TestSubmitRunModelQueueDispatchesAcrossNodes
--- PASS: TestSubmitRunModelQueueDispatchesAcrossNodes (0.00s)
ok iop/apps/edge/internal/service 0.004s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/service
ok iop/apps/edge/internal/service 0.132s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Plan deviation: Fail
- Verification trust: Pass
- 발견된 문제:
- Required: `apps/edge/internal/service/model_queue.go:117`에서 group policy가 `defaultGroupMaxQueue`와 `defaultQueueTimeout`으로 고정되고, `apps/edge/internal/service/model_queue.go:128`에서 capacity가 `NodeRecord.Runtime.Concurrency`만 사용됩니다. 계획은 NodeStore/config provider의 `capacity`, `max_queue`, `queue_timeout_ms`를 Edge scheduler 입력으로 쓰는 것이었고, config에는 provider별 `Capacity`, `MaxQueue`, `QueueTimeoutMS`가 이미 있습니다. 수정: `SubmitRunRequest.Adapter`/`Target`에 맞는 NodeRecord adapter instance를 찾아 후보별 capacity와 group policy를 계산하고, 0/미설정 값의 fallback만 기존 기본값으로 둡니다.
- Required: `apps/edge/internal/service/run_dispatch.go:187`의 `resolveQueueCandidates`가 `ModelGroupKey` 요청에서 `registry.All()` 전체를 후보로 반환합니다. 그 결과 OpenAI/A2A 요청이 해당 `Adapter`/`Target`을 제공하지 않는 Node로 dispatch될 수 있어 "Adapter/Target이 가능한 Node 후보"라는 계획 요구와 Edge service 계약을 깨뜨립니다. 수정: 명시 `NodeRef`도 capability를 검증하고, `NodeRef == ""`이면 NodeStore의 adapter 설정으로 runnable 후보만 남긴 뒤 후보가 없을 때 명확한 에러를 반환합니다.
- Required: `apps/edge/internal/service/model_queue.go:221`의 `releaseNode`는 끊어진 node의 in-flight만 0으로 만들고 queued item의 candidate slice를 갱신하지 않습니다. `tryDispatchLocked`는 기존 candidates 순서대로 다시 고르므로, disconnect된 node가 첫 후보였던 queued request가 남은 node가 있어도 stale `NodeEntry`로 깨어나고 `Send` 실패로 끝날 수 있습니다. 현재 `TestModelQueueNodeDisconnectReleasesInflight`도 반환 node가 disconnected node가 아닌지 검증하지 않아 이 결함을 놓칩니다. 수정: disconnect된 node를 queued candidates에서 제거하거나 dispatch 직전에 live registry/candidate predicate로 재검증하고, 테스트는 node-nd2 같은 남은 node로 dispatch되는지 assert합니다.
- Required: `apps/edge/internal/service/run_dispatch.go:98`에서 `SubmitRun`의 context가 버려지고, `apps/edge/internal/service/model_queue.go:150`의 `admit`도 context cancellation을 받지 않습니다. queued HTTP/OpenAI 요청이 client disconnect나 request cancellation 이후에도 queue_timeout까지 대기하거나 나중에 dispatch될 수 있습니다. 수정: `SubmitRun` context를 queued path에 전달하고, 대기 중 cancellation 시 queue item을 제거하며 dispatch를 만들지 않는 회귀 테스트를 추가합니다.
- 다음 단계: WARN/FAIL 후속 plan/review 파일을 작성한다.

View file

@ -0,0 +1,210 @@
<!-- task=m-edge-model-group-queue-scheduling/01_edge_queue_service plan=1 tag=REVIEW_REFACTOR -->
# 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.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-16
task=m-edge-model-group-queue-scheduling/01_edge_queue_service, plan=1, tag=REVIEW_REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `edge-admission`: Edge service가 model group별 FIFO admission queue, capacity, max_queue, queue_timeout, in-flight 상태를 소유한다.
- `node-dispatch`: Edge가 Node별 실행 가능 slot과 terminal run event를 기준으로 queue head를 다음 Node에 순차 dispatch한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-edge-model-group-queue-scheduling/01_edge_queue_service/`로 이동한다. 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REFACTOR-1] Provider Policy 연결 | [x] |
| [REVIEW_REFACTOR-2] Runnable Candidate 필터 | [x] |
| [REVIEW_REFACTOR-3] Disconnect 후보 재검증 | [x] |
| [REVIEW_REFACTOR-4] Queue Context Cancellation | [x] |
## 구현 체크리스트
- [x] Provider별 adapter config에서 capacity, max_queue, queue_timeout_ms를 Edge queue policy 입력으로 연결한다.
- [x] `ModelGroupKey` queued request의 후보를 `Adapter`/`Target` capable node로 필터링하고 명시 `NodeRef`도 검증한다.
- [x] node disconnect release가 stale disconnected candidate로 queued item을 깨우지 않도록 live candidate 재검증을 추가한다.
- [x] queued admission이 `SubmitRun` context cancellation을 존중하고 cancellation 시 queue item을 제거한다.
- [x] `go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`가 통과한다.
- [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_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] `.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-edge-model-group-queue-scheduling/01_edge_queue_service/`를 `agent-task/archive/YYYY/MM/m-edge-model-group-queue-scheduling/01_edge_queue_service/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-model-group-queue-scheduling/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] 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로 이동한다.
## 계획 대비 변경 사항
- **TestModelQueueNodeDisconnectReleasesInflight 구조 변경**: 단일 disconnect 이벤트로 dispatch를 assert하는 대신 nd1 disconnect + nd2 terminal 두 이벤트를 사용하도록 재설계. 이유: disconnect만으로는 nd2가 여전히 full(inflight=1)이어서 dispatch가 일어나지 않음. 두 이벤트가 필요해야 "nd2로 dispatch" 시나리오가 올바름.
- **config 패키지 import 불필요**: 헬퍼 함수들이 `*edgenode.NodeRecord` 필드를 통해 config 타입에 접근하므로 `run_dispatch.go`에 직접 import 불필요. 별도 import 없이 구현 완료.
- **`getOrCreateGroupLocked` 시그니처 변경**: policy 파라미터 추가, 기존 그룹은 policy를 업데이트하지 않음(생성 시 1회만 적용). 기존 테스트에서 policy를 수동으로 설정하는 패턴을 보존하기 위한 결정.
## 주요 설계 결정
- **candidateNode 타입 도입**: `*edgenode.NodeEntry`에 per-request capacity를 페어링. 용량은 `resolveQueueCandidates`에서 adapter config 기반으로 사전 계산, queue manager 내부에서는 단순 비교만 수행.
- **fail-open 필터링**: NodeRecord가 없거나 adapter 인스턴스가 미설정인 node는 필터에서 제외하지 않음(legacy 노드 호환). CLI: `Enabled=false && Profiles=empty`이면 fail-open. ollama/vllm/openai_compat: 인스턴스 슬라이스가 비어있으면 fail-open.
- **disconnect 후보 제거 방식**: `releaseNode`에서 모든 group의 모든 queued item의 candidates에서 disconnected node를 필터링. registry lookup 없이 manager 내부 상태만으로 처리.
- **group policy 적용 시점**: `getOrCreateGroupLocked`에서 신규 group 생성 시에만 policy 적용. 이후 동일 groupKey의 admit 호출은 기존 policy를 유지. `queueTimeout`, `maxQueue`가 0이면 default 적용.
- **context 취소 처리**: `admit`의 select에 `case <-ctx.Done()` 추가. 취소 시 item을 queue에서 제거하고, 취소 직전 dispatch race를 drain하여 slot 누수 방지.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Provider별 `Capacity`, `MaxQueue`, `QueueTimeoutMS`가 hard-coded default보다 우선 적용되는지 확인한다.
- `Adapter`/`Target`을 지원하지 않는 node가 queued dispatch 후보에서 제외되는지 확인한다.
- node disconnect 후 queued item이 stale disconnected candidate로 깨어나지 않는지 확인한다.
- queued `SubmitRun` 대기 중 context cancellation이 no-dispatch와 queue cleanup으로 끝나는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REFACTOR-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service -run 'TestModelQueueUsesProvider'
=== RUN TestModelQueueUsesProviderCapacity
--- PASS: TestModelQueueUsesProviderCapacity (0.00s)
=== RUN TestModelQueueUsesProviderQueuePolicy
=== RUN TestModelQueueUsesProviderQueuePolicy/maxQueue_enforced
=== RUN TestModelQueueUsesProviderQueuePolicy/queueTimeout_enforced
--- PASS: TestModelQueueUsesProviderQueuePolicy (0.02s)
--- PASS: TestModelQueueUsesProviderQueuePolicy/maxQueue_enforced (0.00s)
--- PASS: TestModelQueueUsesProviderQueuePolicy/queueTimeout_enforced (0.02s)
PASS
ok iop/apps/edge/internal/service 0.025s
```
### REVIEW_REFACTOR-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueue(FiltersCandidates|RejectsExplicit)'
=== RUN TestSubmitRunModelQueueFiltersCandidatesByAdapterTarget
--- PASS: TestSubmitRunModelQueueFiltersCandidatesByAdapterTarget (0.00s)
=== RUN TestSubmitRunModelQueueRejectsExplicitNodeRefWithoutTarget
--- PASS: TestSubmitRunModelQueueRejectsExplicitNodeRefWithoutTarget (0.00s)
PASS
ok iop/apps/edge/internal/service 0.008s
```
### REVIEW_REFACTOR-3 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service -run 'Test(ModelQueueNodeDisconnect|SubmitRunModelQueueDispatchesQueuedRunAfterDisconnect)'
=== RUN TestModelQueueNodeDisconnectReleasesInflight
--- PASS: TestModelQueueNodeDisconnectReleasesInflight (0.00s)
=== RUN TestSubmitRunModelQueueDispatchesQueuedRunAfterDisconnectToLiveNode
--- PASS: TestSubmitRunModelQueueDispatchesQueuedRunAfterDisconnectToLiveNode (0.03s)
PASS
ok iop/apps/edge/internal/service 0.037s
```
### REVIEW_REFACTOR-4 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueueContextCancel'
=== RUN TestSubmitRunModelQueueContextCancelRemovesQueuedItem
--- PASS: TestSubmitRunModelQueueContextCancelRemovesQueuedItem (0.03s)
PASS
ok iop/apps/edge/internal/service 0.037s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/service
ok iop/apps/edge/internal/service 0.218s
$ go test -race -count=1 ./apps/edge/internal/service
ok iop/apps/edge/internal/service 1.234s
$ go test -count=1 ./apps/edge/...
ok iop/apps/edge/cmd/edge 0.045s
ok iop/apps/edge/internal/bootstrap 0.019s
ok iop/apps/edge/internal/controlplane 4.452s
ok iop/apps/edge/internal/edgecmd 0.014s
ok iop/apps/edge/internal/events 0.006s
ok iop/apps/edge/internal/input 0.007s
ok iop/apps/edge/internal/input/a2a 0.009s
ok iop/apps/edge/internal/node 0.008s
ok iop/apps/edge/internal/openai 1.515s
ok iop/apps/edge/internal/opsconsole 0.007s
ok iop/apps/edge/internal/service 0.216s
ok iop/apps/edge/internal/transport 2.012s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Plan deviation: Fail
- Verification trust: Pass
- 발견된 문제:
- Required: `apps/edge/internal/service/run_dispatch.go:239`의 `nodeAdapterCapacity`, `apps/edge/internal/service/run_dispatch.go:283`의 `adapterPolicyFields`, `apps/edge/internal/service/run_dispatch.go:343`의 `nodeSupportsAdapter`가 provider adapter를 `"ollama"`, `"vllm"`, `"openai_compat"` type 문자열로만 해석합니다. 하지만 Node adapter registry는 exact instance key를 먼저 조회하고, type-name lookup은 같은 type 인스턴스가 여러 개면 ambiguous error를 냅니다. 그래서 `openai.model_routes[].adapter`가 `"ollama-local"` 같은 instance key이거나, 다중 Ollama node에 `"ollama"` type route가 들어오면 Edge queue는 해당 Node를 runnable 후보로 잘못 포함하고 provider `Capacity`/`MaxQueue`/`QueueTimeoutMS`도 default/runtime fallback으로 계산합니다. 수정: NodeRecord의 provider instance `Name`을 request adapter와 exact-match로 우선 해석하고, type-name은 enabled instance가 정확히 1개일 때만 runnable로 본 뒤 그 instance의 capacity/policy를 사용하세요. 다중 instance type route는 service 단계에서 명확한 error 또는 후보 제외로 Node router의 ambiguity 계약과 맞추고, instance-key route 및 ambiguous type route 회귀 테스트를 추가하세요.
- 다음 단계: WARN/FAIL 후속 plan/review 파일을 작성한다.

View file

@ -0,0 +1,150 @@
<!-- task=m-edge-model-group-queue-scheduling/01_edge_queue_service plan=2 tag=REVIEW_REVIEW_REFACTOR -->
# Code Review Reference - REVIEW_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.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-16
task=m-edge-model-group-queue-scheduling/01_edge_queue_service, plan=2, tag=REVIEW_REVIEW_REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `edge-admission`: Edge service가 model group별 FIFO admission queue, capacity, max_queue, queue_timeout, in-flight 상태를 소유한다.
- `node-dispatch`: Edge가 Node별 실행 가능 slot과 terminal run event를 기준으로 queue head를 다음 Node에 순차 dispatch한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-edge-model-group-queue-scheduling/01_edge_queue_service/`로 이동한다. 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_REFACTOR-1] Provider Instance Routing | [x] |
## 구현 체크리스트
- [x] Provider adapter helper가 request adapter를 exact instance key로 먼저 매칭하고 해당 instance capacity/max_queue/queue_timeout_ms를 사용한다.
- [x] Provider type-name route는 enabled instance가 정확히 1개일 때만 runnable로 인정하고, 다중 instance type route는 후보 제외 또는 명확한 ambiguity error로 Node router 계약과 맞춘다.
- [x] instance-key route와 ambiguous type route에 대한 service-level 회귀 테스트를 추가한다.
- [x] `go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`가 통과한다.
- [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_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_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-edge-model-group-queue-scheduling/01_edge_queue_service/`를 `agent-task/archive/YYYY/MM/m-edge-model-group-queue-scheduling/01_edge_queue_service/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-model-group-queue-scheduling/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
- 계획된 요구사항에 따라 `service_test.go`에 `TestSubmitRunModelQueueUsesProviderInstancePolicy` 및 `TestSubmitRunModelQueueRejectsAmbiguousProviderType` 회귀 테스트를 신규로 추가하여, exact instance key를 우선 매칭하고 ambiguous provider type을 reject하는 에러 핸들링이 service layer에서 정확히 이루어지는지 유닛 테스트 검증을 완료하였습니다. 그 외 구현 상의 변동 사항은 없습니다.
## 주요 설계 결정
- `resolveAdapterForNode` 함수가 `req.Adapter`와 provider instance `Name`이 exact match되는 경우, concurrency fallback 대신 개별 인스턴스에 구성된 `capacity`, `maxQueue`, `queueTimeoutMS` 정책을 최우선적으로 적용하도록 구현하여 Node router의 lookup 우선순위와 동치로 만들었습니다.
- provider type string으로 요청이 들어오는 경우에는, 해당 노드의 enabled 인스턴스 개수가 정확히 1개인 경우에만 승인하고, 다중 enabled 인스턴스가 존재할 경우 `ambiguous` 플래그를 추가로 리턴해 routing 후보군 계산 시 제외되거나 explicit ambiguity 에러를 뱉도록 설계 결정하였습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- provider adapter instance key route가 matching instance의 capacity/policy만 쓰는지 확인한다.
- provider type-name route가 다중 enabled instance에서 Node router ambiguity와 같은 의미로 reject되는지 확인한다.
- 기존 single-instance provider route와 CLI profile route가 regression 없이 유지되는지 확인한다.
## 검증 결과
### REVIEW_REVIEW_REFACTOR-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueue(UsesProviderInstancePolicy|RejectsAmbiguousProviderType)'
ok iop/apps/edge/internal/service 0.066s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/service
ok iop/apps/edge/internal/service 0.252s
$ go test -race -count=1 ./apps/edge/internal/service
ok iop/apps/edge/internal/service 1.298s
$ go test -count=1 ./apps/edge/...
ok iop/apps/edge/cmd/edge 0.087s
ok iop/apps/edge/internal/bootstrap 0.046s
ok iop/apps/edge/internal/controlplane 4.480s
ok iop/apps/edge/internal/edgecmd 0.031s
ok iop/apps/edge/internal/events 0.006s
ok iop/apps/edge/internal/input 0.013s
ok iop/apps/edge/internal/input/a2a 0.017s
ok iop/apps/edge/internal/node 0.017s
ok iop/apps/edge/internal/openai 1.514s
ok iop/apps/edge/internal/opsconsole 0.011s
ok iop/apps/edge/internal/service 0.271s
ok iop/apps/edge/internal/transport 2.020s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS이므로 `complete.log` 작성 후 active task directory를 archive로 이동한다.

View file

@ -0,0 +1,49 @@
# Complete - m-edge-model-group-queue-scheduling/01_edge_queue_service
## 완료 일시
2026-06-16
## 요약
Edge queue service provider instance routing follow-up completed after 3 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | Provider policy, runnable candidate filtering, disconnect stale candidate, context cancellation gaps found. |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | Provider instance-key routing and ambiguous provider type contract gap found. |
| `plan_cloud_G07_2.log` | `code_review_cloud_G07_2.log` | PASS | Exact provider instance policy and ambiguous type route behavior verified. |
## 구현/정리 내용
- Edge queued SubmitRun path now resolves provider adapter requests by exact instance key first and applies that instance's capacity, max_queue, and queue_timeout_ms.
- Provider type-name route now remains runnable only for exactly one enabled instance, matching the Node router ambiguity contract for multiple instances.
- Added service-level regressions for exact provider instance policy and ambiguous provider type rejection.
- Review cleanup applied `gofmt` to `apps/edge/internal/service/run_dispatch.go` and `apps/edge/internal/service/service_test.go`.
## 최종 검증
- `go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueue(UsesProviderInstancePolicy|RejectsAmbiguousProviderType)'` - PASS; `ok iop/apps/edge/internal/service 0.056s`.
- `go test -count=1 ./apps/edge/internal/service` - PASS; `ok iop/apps/edge/internal/service 0.269s`.
- `go test -race -count=1 ./apps/edge/internal/service` - PASS; `ok iop/apps/edge/internal/service 1.281s`.
- `go test -count=1 ./apps/edge/...` - PASS; all edge packages passed, including `internal/openai` and `internal/service`.
- `gofmt -l apps/edge/internal/service/run_dispatch.go apps/edge/internal/service/service.go apps/edge/internal/service/service_test.go apps/edge/internal/openai/chat_handler.go apps/edge/internal/openai/responses_handler.go apps/edge/internal/openai/server_test.go` - PASS; no output.
- `git diff --check` - PASS; no output.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Completed task ids:
- `edge-admission`: PASS; evidence=`agent-task/archive/2026/06/m-edge-model-group-queue-scheduling/01_edge_queue_service/plan_cloud_G07_2.log`, `agent-task/archive/2026/06/m-edge-model-group-queue-scheduling/01_edge_queue_service/code_review_cloud_G07_2.log`; verification=`go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`
- `node-dispatch`: PASS; evidence=`agent-task/archive/2026/06/m-edge-model-group-queue-scheduling/01_edge_queue_service/plan_cloud_G07_2.log`, `agent-task/archive/2026/06/m-edge-model-group-queue-scheduling/01_edge_queue_service/code_review_cloud_G07_2.log`; verification=`go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,233 @@
<!-- task=m-edge-model-group-queue-scheduling/01_edge_queue_service plan=0 tag=REFACTOR -->
# Plan - REFACTOR Edge Queue Service
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증 명령을 실행하고 실제 출력, 구현 메모, 계획 대비 변경 사항을 채운 뒤 active 파일을 남긴 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 범위 변경, 외부 환경/secret, 또는 scope conflict가 생기면 chat에서 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 남긴다. 후속 에이전트가 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다.
## 배경
현재 Edge service는 `SubmitRun`에서 node를 resolve한 뒤 곧바로 `RunRequest`를 전송한다. 이 구조에서는 같은 OpenAI `model`에 대한 요청을 Edge-owned FIFO queue 하나로 세우거나, Node terminal/disconnect event를 기준으로 다음 Node에 dispatch할 수 없다. 앞선 작은 변경으로 `SubmitRunRequest.ModelGroupKey`는 생겼으므로, 이 plan은 그 키를 실제 Edge queue owner의 입력으로 연결한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하지 않는다. code-review가 해당 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `edge-admission`: Edge service가 model group별 FIFO admission queue, capacity, max_queue, queue_timeout, in-flight 상태를 소유한다.
- `node-dispatch`: Edge가 Node별 실행 가능 slot과 terminal run event를 기준으로 queue head를 다음 Node에 순차 dispatch한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/inference-provider-extension/PHASE.md`
- `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/status_provider.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/service/run_dispatch_internal_test.go`
- `apps/edge/internal/events/bus.go`
- `apps/edge/internal/node/registry.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/server_test.go`
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md` 존재 및 정독 완료.
- 적용 profile: `agent-test/local/edge-smoke.md`, `agent-test/local/node-smoke.md`, `agent-test/local/platform-common-smoke.md`.
- 이 subtask의 필수 명령은 대상 Edge service package 기준 `go test -count=1 ./apps/edge/internal/service`.
- Edge service/transport 실행 경로에 닿으므로 후속 통합 subtask와 최종 regression subtask에서 `go test ./apps/edge/...`, `go test ./apps/node/...`, repo 내부 edge-node smoke/full-cycle 기준을 이어서 수행한다.
### 테스트 커버리지 공백
- 현재 `apps/edge/internal/service` 테스트는 direct dispatch metadata와 node command만 검증하고, model-group FIFO admission은 없다.
- 현재 run terminal release, node disconnect release, multi-node candidate dispatch 테스트가 없다.
- 이 plan에서 service-level fake node client와 event bus를 이용해 FIFO, overflow, timeout, terminal release, disconnect release, multi-node dispatch 테스트를 새로 작성해야 한다.
### 심볼 참조
- 새 public-ish service DTO 필드 `SubmitRunRequest.ModelGroupKey`, `RunDispatch.ModelGroupKey`는 이미 small task에서 추가됐다.
- 제거/rename 대상 없음.
- `SubmitRunRequest{}` call site는 `rg --sort path "SubmitRunRequest\\{" apps/edge/internal` 기준 OpenAI, A2A, opsconsole, service/openai/a2a 테스트에 있다. 이 plan은 service 구현과 service tests 중심으로 처리하고, surface별 의미 검증은 `02+01_surface_snapshot_contract`가 맡는다.
### 분할 판단
- split decision policy 평가 완료. 단일 plan은 부적합하다.
- 공유 task group: `m-edge-model-group-queue-scheduling`.
- `01_edge_queue_service`: Edge queue core와 service dispatch integration. 선행 없음.
- `02+01_surface_snapshot_contract`: surface/snapshot 계약. `01` 완료 필요.
- `03+01_node_queue_simplify`: Node-local queue 축소. `01` 완료 필요.
- `04+01,02,03_regression_evidence`: 통합 회귀/evidence. `01`, `02`, `03` 완료 필요.
- 이 디렉터리는 독립 시작점이므로 predecessor 확인 대상 없음.
### 범위 결정 근거
- OpenAI/A2A/console의 세부 요청 검증은 `02+01_surface_snapshot_contract`로 제외한다.
- Node-local FIFO 제거와 node tests 전환은 `03+01_node_queue_simplify`로 제외한다.
- config/proto 변경은 현재 필수로 보지 않는다. 기존 NodeStore/config provider capacity/max_queue/queue_timeout 값을 Edge scheduler 입력으로 우선 사용한다.
### 빌드 등급
- `cloud-G07`: Edge-owned concurrency/queue scheduling, event-driven release, multi-node dispatch가 결합된 변경이며 race/ordering failure mode가 hard-to-review라 cloud lane이 맞다.
## 구현 체크리스트
- [ ] Edge service에 model-group queue manager를 추가하고 `SubmitRun` direct send를 queue admission/dispatch 경로로 바꾼다.
- [ ] model group별 FIFO, capacity, max_queue, queue_timeout, in-flight 상태를 service-owned state로 관리한다.
- [ ] run terminal event와 node disconnect event를 구독해 in-flight slot을 release하고 다음 queue head를 dispatch한다.
- [ ] multi-node candidate 중 runnable slot이 있는 Node로 queue head를 순차 dispatch한다.
- [ ] `go test -count=1 ./apps/edge/internal/service`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REFACTOR-1] Edge Queue Manager 추가
#### 문제
`SubmitRun`은 node resolve 이후 즉시 proto를 전송한다.
```go
// apps/edge/internal/service/run_dispatch.go:97
97 func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (RunResult, error) {
98 entry, err := s.ResolveNode(req.NodeRef)
103 runReq, runID, err := BuildRunRequest(req)
120 if err := entry.Client.Send(runReq); err != nil {
```
이 구조에는 model group별 queue state가 없다.
#### 해결 방법
`apps/edge/internal/service/model_queue.go`를 추가한다. 내부 타입은 package-private로 두고 `ModelGroupKey`, candidate node, capacity policy, `RunRequest`를 받아 FIFO admission을 수행한다. `ModelGroupKey == ""` 요청은 기존 direct path를 유지하거나 explicit fallback group 없이 dispatch한다.
```go
type modelQueueManager struct {
mu sync.Mutex
groups map[string]*modelQueueGroup
}
```
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/model_queue.go`: queue manager, group state, admission result, release API 추가.
- [ ] `apps/edge/internal/service/service.go`: `Service`에 queue manager field 초기화.
- [ ] `apps/edge/internal/service/run_dispatch.go`: `SubmitRun`이 `ModelGroupKey`가 있을 때 queue manager를 통과하도록 수정.
#### 테스트 작성
- 작성: `apps/edge/internal/service/model_queue_test.go`.
- 테스트명: `TestModelQueueFIFOOrdering`, `TestModelQueueOverflow`, `TestModelQueueTimeout`.
- 목표: 같은 key 안에서 FIFO, max_queue overflow, queue_timeout rejection 검증.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service -run 'TestModelQueue'
```
### [REFACTOR-2] Terminal/Disconnect Release
#### 문제
service는 foreground 응답용으로만 run/node event를 subscribe한다. queue owner가 slot release를 관찰하는 구독이 없다.
```go
// apps/edge/internal/service/run_dispatch.go:112
112 if !runReq.GetBackground() {
116 runEvents, unregisterRun = s.events.SubscribeRun(runID, 4096)
117 nodeEvents, unregisterNode = s.events.SubscribeNode(entry.NodeID, 16)
```
#### 해결 방법
Queue manager가 dispatch 성공 시 `(runID,nodeID,groupKey)` in-flight record를 만들고, `Service.SubmitRun`이 background/foreground와 무관하게 terminal run event 또는 node disconnect event를 release trigger로 연결한다. `events.Bus.SubscribeAllRuns`와 `SubscribeAllNodes`를 쓰되, goroutine lifecycle은 `Service` 내부 close hook 없이 bounded subscription으로 유지할 수 있게 테스트 가능한 helper로 둔다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/model_queue.go`: `releaseRun(runID, reason)`와 `releaseNode(nodeID, reason)` 구현.
- [ ] `apps/edge/internal/service/run_dispatch.go`: dispatch 후 release watcher 등록 또는 queue manager watcher 호출 연결.
- [ ] `apps/edge/internal/service/model_queue_test.go`: complete/error/cancelled와 disconnect release 테스트.
#### 테스트 작성
- 작성: `TestModelQueueTerminalReleaseDispatchesNext`, `TestModelQueueNodeDisconnectReleasesInflight`.
- terminal event 타입: `complete`, `error`, `cancelled`.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service -run 'TestModelQueue(Terminal|NodeDisconnect)'
```
### [REFACTOR-3] Multi-Node Dispatch Candidate
#### 문제
`ResolveNode("")`는 node가 여러 개면 에러를 반환한다. Edge queue는 같은 model group의 여러 Node 후보 중 runnable slot으로 dispatch해야 한다.
```go
// apps/edge/internal/node/registry.go:119
119 if len(r.byID) == 1 {
124 if len(r.byID) == 0 {
127 return nil, fmt.Errorf("multiple nodes connected; select one with /node <id|alias>")
```
#### 해결 방법
`SubmitRunRequest.NodeRef`가 명시되면 후보를 단일 Node로 제한한다. 비어 있고 `ModelGroupKey`가 있으면 `registry.All()`과 `nodeStore`의 config payload를 이용해 `Adapter`/`Target`이 가능한 Node 후보를 만든다. 후보가 없으면 기존 resolve error를 반환한다. candidate별 in-flight를 보고 queue head를 runnable candidate에 dispatch한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/model_queue.go`: candidate set과 runnable slot 계산 추가.
- [ ] `apps/edge/internal/service/status_provider.go`: 필요 시 provider capacity 읽기 helper를 service package 안에서 공유.
- [ ] `apps/edge/internal/service/service_test.go`: multi-node candidate dispatch 테스트 추가.
#### 테스트 작성
- 작성: `TestSubmitRunModelQueueDispatchesAcrossNodes`.
- 목표: 같은 group key의 두 요청이 capacity 1인 두 Node에 각각 dispatch되는지 검증.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueue'
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/model_queue.go` | REFACTOR-1, REFACTOR-2, REFACTOR-3 |
| `apps/edge/internal/service/service.go` | REFACTOR-1 |
| `apps/edge/internal/service/run_dispatch.go` | REFACTOR-1, REFACTOR-2, REFACTOR-3 |
| `apps/edge/internal/service/status_provider.go` | REFACTOR-3 |
| `apps/edge/internal/service/model_queue_test.go` | REFACTOR-1, REFACTOR-2, REFACTOR-3 |
| `apps/edge/internal/service/service_test.go` | REFACTOR-3 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/service
```
기대 결과: 통과. Go test cache는 이 plan에서 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,287 @@
<!-- task=m-edge-model-group-queue-scheduling/01_edge_queue_service plan=1 tag=REVIEW_REFACTOR -->
# Plan - REVIEW_REFACTOR Edge Queue Service Follow-up
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증 명령을 실행하고 실제 출력, 구현 메모, 계획 대비 변경 사항을 채운 뒤 active 파일을 남긴 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 범위 변경, 외부 환경/secret, 또는 scope conflict가 생기면 chat에서 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 남긴다. 후속 에이전트가 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다.
## 배경
첫 번째 구현은 Edge-owned model group queue의 기본 골격과 서비스 테스트를 추가했지만, scheduler 입력과 live candidate 계약을 충분히 지키지 못했다. 특히 provider별 `capacity`, `max_queue`, `queue_timeout_ms`가 무시되고, `ModelGroupKey` 요청이 모든 connected node를 후보로 삼으며, node disconnect 후 stale candidate로 dispatch될 수 있다. queued `SubmitRun`이 request context cancellation을 무시하는 문제도 함께 고친다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하지 않는다. code-review가 해당 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `edge-admission`: Edge service가 model group별 FIFO admission queue, capacity, max_queue, queue_timeout, in-flight 상태를 소유한다.
- `node-dispatch`: Edge가 Node별 실행 가능 slot과 terminal run event를 기준으로 queue head를 다음 Node에 순차 dispatch한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/plan_cloud_G07_0.log`
- `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/code_review_cloud_G07_0.log`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/events/bus.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/transport/server.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/run_result.go`
- `apps/edge/internal/openai/stream.go`
- `packages/go/config/config.go`
- `packages/go/events/events.go`
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md` 존재 및 정독 완료.
- 적용 profile: `agent-test/local/edge-smoke.md`.
- 이 follow-up의 필수 명령은 deterministic local quick check로 `go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`를 사용한다.
- edge-node 실제 왕복과 OpenAI-compatible smoke는 sibling `04+01,02,03_regression_evidence`에서 전체 변경 세트 기준으로 수행한다. 이 plan은 service queue 계약 회귀를 먼저 고친다.
### 테스트 커버리지 공백
- Provider별 `capacity`, `max_queue`, `queue_timeout_ms`가 Edge queue policy로 반영되는 테스트가 없다.
- `NodeRef == ""`인 queued request가 `Adapter`/`Target` capable node만 후보로 삼는 테스트가 없다.
- node disconnect 후 queued item이 disconnected node가 아니라 남은 live node로 dispatch되는지 검증하지 않는다.
- queued `SubmitRun` 대기 중 context cancellation이 queue item 제거와 no-dispatch로 끝나는 테스트가 없다.
### 심볼 참조
- rename/remove 없음.
- `SubmitRunRequest{}` call site는 이번 follow-up에서 구조 변경 없이 기존 필드 의미를 보강한다.
### 분할 판단
- 기존 shared task group은 `m-edge-model-group-queue-scheduling`.
- 현재 subtask는 `01_edge_queue_service`의 first review follow-up이며, active sibling `02+01_surface_snapshot_contract`, `03+01_node_queue_simplify`, `04+01,02,03_regression_evidence`와 같은 parent를 유지한다.
- 네 Required 이슈는 모두 `apps/edge/internal/service` queue admission/dispatch 계약에 묶여 있어 같은 follow-up 안에서 처리한다.
- predecessor 추가 확인은 필요 없다. 이 subtask 자체가 선행 없는 `01_` 계열이다.
### 범위 결정 근거
- OpenAI/A2A surface snapshot 계약 보강은 sibling `02+01_surface_snapshot_contract` 범위로 남긴다.
- Node-local queue 축소는 sibling `03+01_node_queue_simplify` 범위로 남긴다.
- full-cycle edge-node smoke evidence는 sibling `04+01,02,03_regression_evidence` 범위로 남긴다.
- 이 follow-up은 Edge service queue manager, candidate resolution, related service tests에 한정한다.
### 빌드 등급
- `cloud-G07`: concurrency/event-driven queue scheduling의 Required correctness follow-up이며, config policy, candidate liveness, context cancellation을 함께 판단해야 한다.
## 구현 체크리스트
- [ ] Provider별 adapter config에서 capacity, max_queue, queue_timeout_ms를 Edge queue policy 입력으로 연결한다.
- [ ] `ModelGroupKey` queued request의 후보를 `Adapter`/`Target` capable node로 필터링하고 명시 `NodeRef`도 검증한다.
- [ ] node disconnect release가 stale disconnected candidate로 queued item을 깨우지 않도록 live candidate 재검증을 추가한다.
- [ ] queued admission이 `SubmitRun` context cancellation을 존중하고 cancellation 시 queue item을 제거한다.
- [ ] `go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_REFACTOR-1] Provider Policy 연결
#### 문제
`modelQueueGroup`은 생성 시 hard-coded default만 사용한다.
```go
// apps/edge/internal/service/model_queue.go:117
117 policy: groupPolicy{
118 maxQueue: defaultGroupMaxQueue,
119 queueTimeout: defaultQueueTimeout,
120 },
```
capacity도 node runtime concurrency만 사용한다.
```go
// apps/edge/internal/service/model_queue.go:128
128 func (m *modelQueueManager) nodeCapacityFor(nodeID string) int {
129 if m.store != nil {
130 if rec, ok := m.store.FindByID(nodeID); ok && rec.Runtime.Concurrency > 0 {
```
`packages/go/config/config.go:205` 이후에는 provider별 `Capacity`, `MaxQueue`, `QueueTimeoutMS`가 이미 존재한다.
#### 해결 방법
Queue admission 입력에 candidate별 scheduler policy를 싣는다. `SubmitRunRequest.Adapter`/`Target`에 맞는 NodeRecord adapter instance를 찾아 capacity를 후보별로 계산하고, group policy는 matching provider config의 `MaxQueue`/`QueueTimeoutMS`를 사용한다. 값이 0이면 기존 default fallback을 유지한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/model_queue.go`: candidate entry에 capacity를 포함하고 group policy override를 admission에 전달한다.
- [ ] `apps/edge/internal/service/run_dispatch.go`: NodeStore adapter config에서 policy/capacity를 추출해 queue manager에 전달한다.
- [ ] `apps/edge/internal/service/model_queue_test.go`: policy fallback과 configured capacity/max_queue/timeout 테스트를 추가한다.
#### 테스트 작성
- 작성: `TestModelQueueUsesProviderCapacity`.
- 작성: `TestModelQueueUsesProviderQueuePolicy`.
- 목표: capacity=2면 같은 node에 두 요청이 즉시 admission되고, max_queue/queue_timeout_ms가 hard-coded default가 아니라 config 값을 따른다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service -run 'TestModelQueueUsesProvider'
```
### [REVIEW_REFACTOR-2] Runnable Candidate 필터
#### 문제
`resolveQueueCandidates`는 node ref가 없으면 모든 connected node를 반환한다.
```go
// apps/edge/internal/service/run_dispatch.go:185
185 // resolveQueueCandidates returns the candidate nodes for a queued run.
186 // If NodeRef is set, returns only that node. Otherwise returns all connected nodes.
187 func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]*edgenode.NodeEntry, error) {
195 all := s.registry.All()
199 return all, nil
```
이러면 해당 adapter/target을 제공하지 않는 node에도 queued OpenAI/A2A request가 dispatch될 수 있다.
#### 해결 방법
NodeStore record의 normalized adapter config를 기준으로 `req.Adapter`와 `req.Target`을 지원하는 node만 후보로 남긴다. 명시 `NodeRef`가 있을 때도 해당 node가 runnable하지 않으면 Send까지 가지 말고 service error를 반환한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/run_dispatch.go`: `resolveQueueCandidates`를 capability-aware helper로 바꾼다.
- [ ] `apps/edge/internal/service/model_queue.go`: queue manager가 filtered candidate만 받도록 타입을 맞춘다.
- [ ] `apps/edge/internal/service/service_test.go`: runnable candidate 필터 테스트를 추가한다.
#### 테스트 작성
- 작성: `TestSubmitRunModelQueueFiltersCandidatesByAdapterTarget`.
- 작성: `TestSubmitRunModelQueueRejectsExplicitNodeRefWithoutTarget`.
- 목표: 두 node 중 target을 제공하는 node로만 dispatch되고, 명시 node가 target을 제공하지 않으면 명확한 error를 반환한다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueue(FiltersCandidates|RejectsExplicit)'
```
### [REVIEW_REFACTOR-3] Disconnect 후보 재검증
#### 문제
`releaseNode`는 끊어진 node의 in-flight count만 0으로 만들고 queued item의 candidates는 그대로 둔다.
```go
// apps/edge/internal/service/model_queue.go:221
221 func (m *modelQueueManager) releaseNode(nodeID, reason string) {
231 for _, group := range m.groups {
232 if count := group.inflight[nodeID]; count > 0 {
233 group.inflight[nodeID] = 0
234 m.tryDispatchLocked(group)
```
`tryDispatchLocked`는 stale candidates에서 첫 available node를 고르므로 disconnect된 node로 queued item을 깨울 수 있다.
#### 해결 방법
disconnect event를 받은 node는 queued candidate 목록에서 제거하거나, dispatch 직전에 service가 제공한 live-candidate predicate로 재검증한다. 후보가 모두 사라진 queued item은 명확한 error를 전달하거나 새 live candidate가 생길 때까지 기다리는 정책 중 하나를 코드에 명시한다. 현재 목표는 "남은 live candidate가 있으면 그 node로 dispatch"다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/model_queue.go`: disconnected node를 stale candidate로 선택하지 않게 한다.
- [ ] `apps/edge/internal/service/model_queue_test.go`: disconnect 후 dispatch node id를 검증하도록 기존 테스트를 강화한다.
- [ ] `apps/edge/internal/service/service_test.go`: 실제 `SubmitRun` 흐름에서 disconnected candidate send failure 없이 남은 node로 dispatch되는 테스트를 추가한다.
#### 테스트 작성
- 수정: `TestModelQueueNodeDisconnectReleasesInflight`가 `node-nd2`로 dispatch되는지 assert한다.
- 작성: `TestSubmitRunModelQueueDispatchesQueuedRunAfterDisconnectToLiveNode`.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service -run 'Test(ModelQueueNodeDisconnect|SubmitRunModelQueueDispatchesQueuedRunAfterDisconnect)'
```
### [REVIEW_REFACTOR-4] Queue Context Cancellation
#### 문제
`SubmitRun`은 context를 버리고 queue admission도 cancellation을 받지 않는다.
```go
// apps/edge/internal/service/run_dispatch.go:98
98 func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (RunResult, error) {
```
```go
// apps/edge/internal/service/model_queue.go:150
150 func (m *modelQueueManager) admit(groupKey string, candidates []*edgenode.NodeEntry) (*edgenode.NodeEntry, error) {
```
queued HTTP/OpenAI request가 client disconnect 후에도 대기하거나 dispatch될 수 있다.
#### 해결 방법
`SubmitRun(ctx, req)`의 ctx를 queued admission까지 전달한다. `admit` select에 `ctx.Done()`을 추가하고 cancellation 시 queue item 제거, reserved slot cleanup, no-dispatch를 보장한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/run_dispatch.go`: context를 queued path로 전달한다.
- [ ] `apps/edge/internal/service/model_queue.go`: `admit`이 ctx cancellation을 처리한다.
- [ ] `apps/edge/internal/service/model_queue_test.go` 또는 `service_test.go`: cancellation 회귀 테스트를 추가한다.
#### 테스트 작성
- 작성: `TestSubmitRunModelQueueContextCancelRemovesQueuedItem`.
- 목표: capacity가 찬 상태에서 두 번째 request가 queue에 들어간 뒤 context cancel 시 error를 반환하고, 이후 slot release가 canceled request를 dispatch하지 않는다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueueContextCancel'
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/model_queue.go` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-3, REVIEW_REFACTOR-4 |
| `apps/edge/internal/service/run_dispatch.go` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-2, REVIEW_REFACTOR-4 |
| `apps/edge/internal/service/model_queue_test.go` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-3, REVIEW_REFACTOR-4 |
| `apps/edge/internal/service/service_test.go` | REVIEW_REFACTOR-2, REVIEW_REFACTOR-3, REVIEW_REFACTOR-4 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/service
go test -race -count=1 ./apps/edge/internal/service
go test -count=1 ./apps/edge/...
```
기대 결과: 모두 통과. Go test cache는 이 plan에서 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,152 @@
<!-- task=m-edge-model-group-queue-scheduling/01_edge_queue_service plan=2 tag=REVIEW_REVIEW_REFACTOR -->
# Plan - REVIEW_REVIEW_REFACTOR Edge Queue Service Instance Routing
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증 명령을 실행하고 실제 출력, 구현 메모, 계획 대비 변경 사항을 채운 뒤 active 파일을 남긴 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 범위 변경, 외부 환경/secret, 또는 scope conflict가 생기면 chat에서 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 남긴다. 후속 에이전트가 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다.
## 배경
두 번째 구현은 provider policy, candidate filtering, disconnect stale candidate, context cancellation을 대부분 보완했다. 남은 결함은 provider adapter를 type 문자열로만 해석해서 Node router의 instance-key/ambiguous-type 계약과 Edge queue 후보 계산이 어긋나는 점이다. 이 follow-up은 provider instance name 기반 routing과 policy 계산만 좁게 고친다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하지 않는다. code-review가 해당 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `edge-admission`: Edge service가 model group별 FIFO admission queue, capacity, max_queue, queue_timeout, in-flight 상태를 소유한다.
- `node-dispatch`: Edge가 Node별 실행 가능 slot과 terminal run event를 기준으로 queue head를 다음 Node에 순차 dispatch한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/plan_cloud_G07_1.log`
- `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/code_review_cloud_G07_1.log`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/service/service_test.go`
- `apps/node/internal/router/router.go`
- `apps/node/internal/adapters/registry.go`
- `packages/go/config/config.go`
- `configs/edge.yaml`
### 테스트 환경 규칙
- test_env: `local`.
- 이전 review turn에서 `agent-test/local/rules.md`와 `agent-test/local/edge-smoke.md`를 적용했다.
- 이 follow-up의 필수 명령은 `go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`다.
- focus 검증은 provider instance route와 ambiguous type route 테스트만 먼저 실행한다.
### 테스트 커버리지 공백
- `openai.model_routes[].adapter` 또는 service request adapter가 provider instance key(`ollama-local` 등)일 때 NodeRecord의 matching instance policy/capacity를 쓰는 테스트가 없다.
- provider type route(`ollama`)가 같은 node의 다중 enabled instances와 충돌할 때 Edge가 runnable 후보로 잘못 포함하지 않는 테스트가 없다.
### 심볼 참조
- rename/remove 없음.
- `nodeAdapterCapacity`, `adapterPolicyFields`, `nodeSupportsAdapter`, `resolveQueueCandidates` 내부 의미 변경만 필요하다.
### 분할 판단
- 기존 shared task group은 `m-edge-model-group-queue-scheduling`.
- 현재 subtask는 `01_edge_queue_service`의 second review follow-up이다.
- 남은 issue는 Edge service queue candidate/policy helper 하나의 계약 수정으로 충분하므로 추가 split 없이 plan 2로 이어간다.
### 범위 결정 근거
- OpenAI surface snapshot 문구나 docs 변경은 sibling task 범위로 남긴다.
- Node router/adapters 구현 변경은 하지 않는다. Edge service가 Node router의 현재 exact-instance/type-ambiguity 계약을 반영한다.
- 이 follow-up은 `apps/edge/internal/service` 구현과 tests에 한정한다.
### 빌드 등급
- `cloud-G07`: provider instance routing과 queue policy 계산이 Edge/Node adapter registry 계약을 가로지르는 correctness follow-up이다.
## 구현 체크리스트
- [ ] Provider adapter helper가 request adapter를 exact instance key로 먼저 매칭하고 해당 instance capacity/max_queue/queue_timeout_ms를 사용한다.
- [ ] Provider type-name route는 enabled instance가 정확히 1개일 때만 runnable로 인정하고, 다중 instance type route는 후보 제외 또는 명확한 ambiguity error로 Node router 계약과 맞춘다.
- [ ] instance-key route와 ambiguous type route에 대한 service-level 회귀 테스트를 추가한다.
- [ ] `go test -count=1 ./apps/edge/internal/service`, `go test -race -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_REVIEW_REFACTOR-1] Provider Instance Routing
#### 문제
Edge queue helper는 provider adapter를 type 문자열로만 해석한다.
```go
// apps/edge/internal/service/run_dispatch.go:239
239 func nodeAdapterCapacity(rec *edgenode.NodeRecord, adapterType string) int {
243 switch adapterType {
244 case "ollama":
```
```go
// apps/edge/internal/service/run_dispatch.go:343
343 func nodeSupportsAdapter(rec *edgenode.NodeRecord, adapterType, target string) bool {
347 switch adapterType {
348 case "cli":
```
Node router는 exact instance key를 먼저 조회하고, type-name lookup은 같은 type 인스턴스가 여러 개면 ambiguous error를 낸다.
```go
// apps/node/internal/adapters/registry.go:60
60 func (r *Registry) Lookup(name string) (runtime.Adapter, error) {
61 if e, ok := r.entries[name]; ok {
64 var matchKeys []string
76 return nil, fmt.Errorf("adapter %q is ambiguous: matches instance keys %v; use an instance key", name, matchKeys)
```
#### 해결 방법
Provider config resolver를 하나로 모은다. `req.Adapter`가 provider instance `Name`과 exact-match하면 그 instance만 사용한다. `req.Adapter`가 provider type(`ollama`, `vllm`, `openai_compat`)이면 enabled instance가 정확히 하나일 때만 그 instance를 사용하고, 여러 개면 runnable 후보가 아니거나 explicit ambiguity error를 반환한다. resolved instance의 `Capacity`, `MaxQueue`, `QueueTimeoutMS`만 candidate capacity/group policy에 반영한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/run_dispatch.go`: provider instance resolver helper 추가 또는 기존 helper 통합.
- [ ] `apps/edge/internal/service/run_dispatch.go`: `nodeAdapterCapacity`, `adapterPolicyFields`, `nodeSupportsAdapter`가 resolved provider instance를 공유하도록 변경.
- [ ] `apps/edge/internal/service/service_test.go`: instance-key route와 ambiguous type route 테스트 추가.
#### 테스트 작성
- 작성: `TestSubmitRunModelQueueUsesProviderInstancePolicy`.
- 목표: `Adapter: "ollama-local"` 요청이 `ollama-local` instance의 capacity/policy를 쓰고 다른 provider instance policy를 섞지 않는다.
- 작성: `TestSubmitRunModelQueueRejectsAmbiguousProviderType`.
- 목표: 한 node에 enabled `ollama_instances`가 2개 있을 때 `Adapter: "ollama"` queued request는 Node router ambiguous failure로 내려가지 않고 service 단계에서 reject된다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service -run 'TestSubmitRunModelQueue(UsesProviderInstancePolicy|RejectsAmbiguousProviderType)'
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/run_dispatch.go` | REVIEW_REVIEW_REFACTOR-1 |
| `apps/edge/internal/service/service_test.go` | REVIEW_REVIEW_REFACTOR-1 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/service
go test -race -count=1 ./apps/edge/internal/service
go test -count=1 ./apps/edge/...
```
기대 결과: 모두 통과. Go test cache는 이 plan에서 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,117 @@
<!-- task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract plan=0 tag=REFACTOR -->
# 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; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
## 개요
date=2026-06-16
task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `snapshot-contract`: Edge-visible capacity/in-flight/queued snapshot이 Edge-owned model group 상태를 기준으로 표현되고 Node capability snapshot과 충돌하지 않는다.
- `surface-coverage`: OpenAI Chat Completions/Responses 실행 경로가 `model` 값 기반 Edge-owned queue를 우회하지 않고, A2A/console 경로는 별도 model group key 없이 같은 Edge dispatch primitive와 충돌하지 않는다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] OpenAI Queue Coverage | [ ] |
| [REFACTOR-2] A2A/Console Zero-Key Contract | [ ] |
| [REFACTOR-3] Edge-Owned Snapshot | [ ] |
## 구현 체크리스트
- [ ] OpenAI Chat/Responses 테스트가 request `model` 기반 `ModelGroupKey`와 queue primitive 사용을 검증하도록 보강한다.
- [ ] A2A/console 테스트가 `ModelGroupKey == ""`와 같은 `SubmitRun` primitive 사용을 명시적으로 검증하도록 보강한다.
- [ ] `ListNodeSnapshots`/Control Plane status snapshot이 Edge-owned queue state의 capacity/in-flight/queued를 표현하도록 service boundary를 조정한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`가 통과한다.
- [ ] 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을 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS split 작업이면 parent directory 유지/정리를 확인한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 경로를 처리한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- OpenAI는 request `model`을 queue group key로 유지한다.
- A2A/console은 임의 model group key를 만들지 않는다.
- Control Plane status가 Edge-owned queue snapshot을 relay한다.
## 검증 결과
### REFACTOR-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/openai
(output)
```
### REFACTOR-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole
(output)
```
### REFACTOR-3 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/controlplane
(output)
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**

View file

@ -0,0 +1,220 @@
<!-- task=m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract plan=0 tag=REFACTOR -->
# Plan - REFACTOR Surface Snapshot Contract
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증 명령을 실행하고 실제 출력, 구현 메모, 계획 대비 변경 사항을 채운 뒤 active 파일을 남긴 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 범위 변경, 외부 환경/secret, 또는 scope conflict가 생기면 chat에서 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 남긴다.
## 배경
`01_edge_queue_service`가 Edge-owned queue core를 만들면 각 입력 표면이 그 primitive를 우회하지 않는지 확인해야 한다. OpenAI Chat/Responses는 request `model``ModelGroupKey`로 전달하고, A2A/console은 별도 model group key를 만들지 않으면서 같은 `SubmitRun` primitive를 사용해야 한다. Control Plane과 ops surface가 보는 capacity/in-flight/queued snapshot도 Node-local 값과 충돌하지 않게 Edge-owned state를 기준으로 표현해야 한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하지 않는다. code-review가 해당 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `snapshot-contract`: Edge-visible capacity/in-flight/queued snapshot이 Edge-owned model group 상태를 기준으로 표현되고 Node capability snapshot과 충돌하지 않는다.
- `surface-coverage`: OpenAI Chat Completions/Responses 실행 경로가 `model` 값 기반 Edge-owned queue를 우회하지 않고, A2A/console 경로는 별도 model group key 없이 같은 Edge dispatch primitive와 충돌하지 않는다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/status_provider.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/input/a2a/server.go`
- `apps/edge/internal/input/a2a/server_test.go`
- `apps/edge/internal/opsconsole/console.go`
- `apps/edge/internal/opsconsole/console_test.go`
- `apps/edge/internal/controlplane/connector.go`
- `apps/edge/internal/controlplane/connector_test.go`
### 테스트 환경 규칙
- test_env: `local`.
- 적용 profile: `edge-smoke`, `node-smoke`, `platform-common-smoke`.
- 필수 명령: `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`.
- OpenAI-compatible 경계를 바꾸므로 최종 regression subtask에서 `/v1/models`, `/v1/responses`, chat completions smoke 기준을 이어서 확인한다.
### 테스트 커버리지 공백
- OpenAI tests는 small task로 `ModelGroupKey` 보존을 일부 확인하지만, 실제 queue core 우회 여부는 `01` 이후 fake queue/service로 추가 검증이 필요하다.
- A2A/console tests는 `SubmitRunRequest.Metadata["source"]` 중심이며 `ModelGroupKey == ""` 계약을 명시하지 않는다.
- `ListNodeSnapshots``ProviderSnapshots`를 비워 둔다. Control Plane status는 provider snapshot을 그대로 relay하지만 Edge-owned queue state가 들어오는지 검증하지 않는다.
### 심볼 참조
- rename/remove 없음.
- `SubmitRunRequest{}` call site 중 `apps/edge/internal/input/a2a/server.go:168``apps/edge/internal/opsconsole/console.go:230``ModelGroupKey`를 명시하지 않아 zero value다. 이 zero value가 "별도 model group key 없음" 계약이다.
### 분할 판단
- shared task group: `m-edge-model-group-queue-scheduling`.
- 현재 subtask: `02+01_surface_snapshot_contract`.
- predecessor `01`: active `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/complete.log` 없음, archive 후보도 확인 전이다. 구현 시작 전 `01_edge_queue_service` PASS complete.log가 필요하다.
- 이 plan은 surface/snapshot 경계만 다루며 Node-local queue 제거는 `03+01_node_queue_simplify`로 분리한다.
### 범위 결정 근거
- Edge queue core 자료구조와 dispatch algorithm은 `01` 범위라 여기서 새로 설계하지 않는다.
- Node adapter capability의 provider availability/probe 자체는 바꾸지 않는다. 이 subtask는 Edge-visible snapshot의 capacity/in-flight/queued 소유권 표현만 조정한다.
- proto 계약 변경은 필요할 때만 수행한다. 기존 `ProviderSnapshot` 필드로 표현 가능하면 proto는 제외한다.
### 빌드 등급
- `cloud-G06`: 여러 입력 표면과 Control Plane status 계약을 한 번에 맞추는 cross-boundary 변경이지만, `01`의 queue core 위에서 검증 가능한 범위다.
## 구현 체크리스트
- [ ] OpenAI Chat/Responses 테스트가 request `model` 기반 `ModelGroupKey`와 queue primitive 사용을 검증하도록 보강한다.
- [ ] A2A/console 테스트가 `ModelGroupKey == ""`와 같은 `SubmitRun` primitive 사용을 명시적으로 검증하도록 보강한다.
- [ ] `ListNodeSnapshots`/Control Plane status snapshot이 Edge-owned queue state의 capacity/in-flight/queued를 표현하도록 service boundary를 조정한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REFACTOR-1] OpenAI Queue Coverage
#### 문제
OpenAI handlers는 `ModelGroupKey`를 채우지만, queue core 통합 후 queue primitive를 실제로 통과하는지 surface-level 검증이 필요하다.
```go
// apps/edge/internal/openai/chat_handler.go:73
73 handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
75 ModelGroupKey: strings.TrimSpace(req.Model),
// apps/edge/internal/openai/responses_handler.go:81
81 handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
83 ModelGroupKey: strings.TrimSpace(req.Model),
```
#### 해결 방법
`fakeRunService`에 queue-observed assertions 또는 `RunDispatch.ModelGroupKey` checks를 추가해 target override와 관계없이 request `model`이 queue group key로 유지되는지 검증한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/openai/server_test.go`: route catalog, configured target, metadata target 케이스에서 `ModelGroupKey` 검증 추가/정리.
#### 테스트 작성
- 작성: `TestChatCompletionsRouteCatalogDispatchesModelB`, `TestResponsesRouteCatalogDispatchesRoute` 보강 또는 전용 `TestOpenAIUsesRequestModelAsQueueGroupKey`.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/openai
```
### [REFACTOR-2] A2A/Console Zero-Key Contract
#### 문제
A2A와 console은 `SubmitRun`을 공유하지만 model group key를 만들지 않는다.
```go
// apps/edge/internal/input/a2a/server.go:168
168 handle, err := s.svc.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
169 NodeRef: s.cfg.NodeRef,
// apps/edge/internal/opsconsole/console.go:230
230 handle, err := edgeSvc.SubmitRun(ctx, edgeservice.SubmitRunRequest{
231 NodeRef: target.NodeRef,
```
#### 해결 방법
A2A fake service와 console metadata test를 확장해 `ModelGroupKey == ""`를 명시한다. 구현이 필요하면 console `SendRun` test double을 작게 추가하되, console 사용자 출력 형식은 바꾸지 않는다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/input/a2a/server_test.go`: `TestHandleMessageSendDispatchesRun`에서 zero key 확인.
- [ ] `apps/edge/internal/opsconsole/console_test.go`: console SubmitRunRequest contract에 zero key 확인 추가.
#### 테스트 작성
- 작성: 기존 테스트 보강.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole
```
### [REFACTOR-3] Edge-Owned Snapshot
#### 문제
`NodeSnapshot.ProviderSnapshots`는 현재 `ListNodeSnapshots`에서 채워지지 않고, Control Plane은 받은 값을 그대로 relay한다.
```go
// apps/edge/internal/service/status_provider.go:12
12 type NodeSnapshot struct {
19 ProviderSnapshots []*iop.ProviderSnapshot
// apps/edge/internal/controlplane/connector.go:367
367 nodes = append(nodes, &iop.EdgeNodeSnapshot{
373 ProviderSnapshots: s.ProviderSnapshots,
```
#### 해결 방법
`Service.ListNodeSnapshots()`가 queue manager의 Edge-owned runtime snapshot을 합성하도록 조정한다. Node command capability snapshot에서 받은 queued/in-flight와 충돌하지 않도록 Edge snapshot을 우선하고, Node provider status/availability만 보조 정보로 유지한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/status_provider.go`: queue manager snapshot merge.
- [ ] `apps/edge/internal/service/service_test.go`: Edge-owned snapshot capacity/in-flight/queued 검증.
- [ ] `apps/edge/internal/controlplane/connector_test.go`: status response에 Edge-owned snapshot이 relay되는지 검증.
#### 테스트 작성
- 작성: `TestListNodeSnapshotsUsesEdgeQueueState`, `TestConnectorRespondsToStatusRequestFromProvider` 보강.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/controlplane
```
## 의존 관계 및 구현 순서
- `01_edge_queue_service` complete.log가 먼저 필요하다.
- 구현 전 확인 후보:
- active: `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/complete.log`
- archive: `agent-task/archive/*/*/m-edge-model-group-queue-scheduling/01_edge_queue_service/complete.log`
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/server_test.go` | REFACTOR-1 |
| `apps/edge/internal/input/a2a/server_test.go` | REFACTOR-2 |
| `apps/edge/internal/opsconsole/console_test.go` | REFACTOR-2 |
| `apps/edge/internal/service/status_provider.go` | REFACTOR-3 |
| `apps/edge/internal/service/service_test.go` | REFACTOR-3 |
| `apps/edge/internal/controlplane/connector_test.go` | REFACTOR-3 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane ./apps/edge/internal/service
```
기대 결과: 통과. Go test cache는 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,116 @@
<!-- task=m-edge-model-group-queue-scheduling/03+01_node_queue_simplify plan=0 tag=REFACTOR -->
# 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; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
## 개요
date=2026-06-16
task=m-edge-model-group-queue-scheduling/03+01_node_queue_simplify, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `node-simplify`: Node-local provider FIFO queue가 제거되거나 Edge dispatch 이후의 실행 안전장치로 축소되어, 각 Node가 queue owner가 되지 않는다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] OnRunRequest Queue Ownership 제거 | [ ] |
| [REFACTOR-2] Capability Snapshot 정리 | [ ] |
| [REFACTOR-3] Admission Queue Test 전환 | [ ] |
## 구현 체크리스트
- [ ] Node `OnRunRequest`에서 FIFO queue ownership을 제거하고 dispatch된 run을 즉시 실행 또는 safety reject로 처리한다.
- [ ] Node capability snapshot의 queued/in-flight 의미를 Edge-owned queue와 충돌하지 않게 정리한다.
- [ ] Node admission queue tests를 삭제/전환하고 over-dispatch safety, cancel, background, store lifecycle 테스트를 남긴다.
- [ ] `go test -count=1 ./apps/node/internal/node`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G07_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block을 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS split 작업이면 parent directory 유지/정리를 확인한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 경로를 처리한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Node가 FIFO queue owner로 남지 않았는지 확인한다.
- Node queued snapshot이 Edge-owned queued 의미와 충돌하지 않는지 확인한다.
- cancel/background/store lifecycle 회귀가 남아 있는지 확인한다.
## 검증 결과
### REFACTOR-1 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node -run 'TestOnRunRequest'
(output)
```
### REFACTOR-2 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node -run 'TestOnCommandRequest_Capabilities'
(output)
```
### REFACTOR-3 중간 검증
```text
$ go test -count=1 ./apps/node/internal/node -run 'Test(AdmissionQueue|ConcurrencyLimit|QueueOverflow|OnRunRequest|OnCommandRequest_Capabilities)'
(output)
```
### 최종 검증
```text
$ go test -count=1 ./apps/node/internal/node
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**

View file

@ -0,0 +1,210 @@
<!-- task=m-edge-model-group-queue-scheduling/03+01_node_queue_simplify plan=0 tag=REFACTOR -->
# Plan - REFACTOR Node Queue Simplify
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증 명령을 실행하고 실제 출력, 구현 메모, 계획 대비 변경 사항을 채운 뒤 active 파일을 남긴 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 범위 변경, 외부 환경/secret, 또는 scope conflict가 생기면 chat에서 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 남긴다.
## 배경
현재 Node는 provider-scoped FIFO admission queue와 node-wide global gate를 소유한다. 현재 마일스톤의 목표는 queue owner를 Edge로 옮기는 것이므로, Node는 dispatch된 실행을 수행하고 상태를 보고하는 역할로 단순화되어야 한다. Edge queue core가 먼저 들어간 뒤 Node-local FIFO는 제거하거나 over-dispatch 안전장치 수준으로 축소한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하지 않는다. code-review가 해당 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `node-simplify`: Node-local provider FIFO queue가 제거되거나 Edge dispatch 이후의 실행 안전장치로 축소되어, 각 Node가 queue owner가 되지 않는다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/node/node_concurrency_integration_test.go`
- `apps/node/internal/runtime/types.go`
- `apps/node/internal/router/router.go`
### 테스트 환경 규칙
- test_env: `local`.
- 적용 profile: `node-smoke`, `edge-smoke`.
- 필수 명령: `go test -count=1 ./apps/node/internal/node`.
- Node 실행 요청/stream/cancel/status 경로에 닿으므로 최종 regression subtask에서 repo 내부 edge-node 진단과 full-cycle 기준을 이어서 수행한다.
### 테스트 커버리지 공백
- 현재 Node tests는 Node-owned FIFO queue를 성공 조건으로 삼는 테스트가 많다.
- Edge-owned queue 전환 뒤에는 Node가 queued status를 만들지 않아야 하므로 기존 `TestAdmissionQueue_*` 계열은 삭제, 이동, 또는 "over-dispatch immediate rejection/safety" 기준으로 전환해야 한다.
- Node capabilities의 queued/in_flight 값은 Edge-owned snapshot과 충돌하지 않아야 한다. queued는 0 또는 safety-only 값으로 정리해야 한다.
### 심볼 참조
- 제거/축소 후보: `fifoGate`, `admissionManager`, `admissionTicket`, `ErrConcurrencyLimitExceeded` 주변 queue_full/queue_timeout helper.
- call site:
- `apps/node/internal/node/node.go:35` `globalGate`
- `apps/node/internal/node/node.go:36-37` `adapterGates`
- `apps/node/internal/node/node.go:98-213` admission enqueue/wait/launch flow
- `apps/node/internal/node/node.go:216-242` admission helper
- `apps/node/internal/node/node.go:332-344` capabilities in_flight/queued snapshot
- `apps/node/internal/node/node_test.go:1438+` admission queue tests
- `apps/node/internal/node/node_concurrency_integration_test.go:91+` queue overflow integration test
### 분할 판단
- shared task group: `m-edge-model-group-queue-scheduling`.
- 현재 subtask: `03+01_node_queue_simplify`.
- predecessor `01`: active `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/complete.log` 없음. 구현 시작 전 `01_edge_queue_service` PASS complete.log가 필요하다.
- snapshot/surface 조정은 `02+01_surface_snapshot_contract`, final regression은 `04+01,02,03_regression_evidence`로 분리한다.
### 범위 결정 근거
- Node adapter provider probe, runtime adapter contracts, router lookup semantics는 바꾸지 않는다.
- Edge scheduler 구현을 이 subtask에서 재설계하지 않는다.
- 외부 OpenAI/A2A/console surface는 Node simplification 검증 범위가 아니므로 제외한다.
### 빌드 등급
- `cloud-G07`: 기존 Node admission queue는 cancellation, background run, timeout, store status, provider snapshot에 걸쳐 있어 대량 테스트 전환과 concurrency risk가 크다.
## 구현 체크리스트
- [ ] Node `OnRunRequest`에서 FIFO queue ownership을 제거하고 dispatch된 run을 즉시 실행 또는 safety reject로 처리한다.
- [ ] Node capability snapshot의 queued/in-flight 의미를 Edge-owned queue와 충돌하지 않게 정리한다.
- [ ] Node admission queue tests를 삭제/전환하고 over-dispatch safety, cancel, background, store lifecycle 테스트를 남긴다.
- [ ] `go test -count=1 ./apps/node/internal/node`가 통과한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REFACTOR-1] OnRunRequest Queue Ownership 제거
#### 문제
Node가 provider FIFO queue owner다.
```go
// apps/node/internal/node/node.go:98
98 // Admission gate (foreground and background use the same path).
110 admission := n.admissionFor(spec.Adapter, caps)
128 ticket, err := admission.enqueue()
167 if err := ticket.wait(execCtx); err != nil {
177 defer ticket.release()
```
#### 해결 방법
`OnRunRequest`는 router resolve, store insert, runManager register, adapter execute lifecycle만 맡긴다. capacity guard가 필요하면 queue 없는 non-blocking safety gate로 두고, full이면 즉시 error event와 rejected store status를 남긴다. queued store status는 Edge queue가 소유하므로 Node-local 대기 상태로 쓰지 않는다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node.go`: admission enqueue/wait 흐름 제거 또는 safety guard로 축소.
- [ ] `apps/node/internal/node/run_manager.go`: FIFO queue helper 제거 또는 safety-only helper로 축소.
#### 테스트 작성
- 작성: `TestOnRunRequest_DispatchedRunRunsImmediately`, `TestOnRunRequest_OverDispatchSafetyRejectsWithoutQueue` 또는 기존 테스트 전환.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node -run 'TestOnRunRequest'
```
### [REFACTOR-2] Capability Snapshot 정리
#### 문제
Node command capabilities가 Node-local gate의 in-flight/queued를 보고한다.
```go
// apps/node/internal/node/node.go:332
332 inFlight := 0
333 queued := 0
334 if ok {
335 inFlight = gate.activeCount()
336 queued = gate.queuedCount()
337 }
```
Edge-owned snapshot과 섞이면 queued 의미가 충돌한다.
#### 해결 방법
Node는 provider availability/capacity와 safety in-flight만 보고하고 queued는 0으로 고정하거나 safety queue가 없음을 명시한다. Edge-owned queued는 `02`의 service snapshot이 표현한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node.go`: capabilities result/snapshot queued semantics 정리.
- [ ] `apps/node/internal/node/node_test.go`: capabilities queued 테스트 전환.
#### 테스트 작성
- 작성/전환: `TestOnCommandRequest_Capabilities`, `TestOnCommandRequest_Capabilities_InFlight`, 기존 `TestOnCommandRequest_Capabilities_Queued` 삭제 또는 safety-only 이름으로 변경.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node -run 'TestOnCommandRequest_Capabilities'
```
### [REFACTOR-3] Admission Queue Test 전환
#### 문제
`apps/node/internal/node/node_test.go:1438+` 이후는 Node-owned FIFO를 전제로 한다. Edge queue 전환 뒤 이 테스트들이 그대로 남으면 목표와 반대 동작을 고정한다.
#### 해결 방법
FIFO ordering, max_queue, queue_timeout, release promotion 테스트는 제거하거나 Edge service queue tests로 이동된 것을 확인한다. Node에는 cancel/background/store/error lifecycle과 over-dispatch immediate safety만 남긴다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node_test.go`: `TestAdmissionQueue_*` 계열 정리.
- [ ] `apps/node/internal/node/node_concurrency_integration_test.go`: queue overflow integration test를 safety rejection 또는 제거로 전환.
#### 테스트 작성
- 작성: over-dispatch safety event가 Edge로 도착하는 통합 테스트가 필요하면 기존 `TestQueueOverflow_RejectEventObservedByEdge`를 재명명해 즉시 rejection 기준으로 유지한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/node -run 'Test(AdmissionQueue|ConcurrencyLimit|QueueOverflow|OnRunRequest|OnCommandRequest_Capabilities)'
```
## 의존 관계 및 구현 순서
- `01_edge_queue_service` complete.log가 먼저 필요하다.
- 구현 전 확인 후보:
- active: `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/complete.log`
- archive: `agent-task/archive/*/*/m-edge-model-group-queue-scheduling/01_edge_queue_service/complete.log`
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/node/node.go` | REFACTOR-1, REFACTOR-2 |
| `apps/node/internal/node/run_manager.go` | REFACTOR-1 |
| `apps/node/internal/node/node_test.go` | REFACTOR-1, REFACTOR-2, REFACTOR-3 |
| `apps/node/internal/node/node_concurrency_integration_test.go` | REFACTOR-3 |
## 최종 검증
```bash
go test -count=1 ./apps/node/internal/node
```
기대 결과: 통과. Go test cache는 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,132 @@
<!-- task=m-edge-model-group-queue-scheduling/04+01,02,03_regression_evidence plan=0 tag=TEST -->
# Code Review Reference - TEST
> **[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`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
## 개요
date=2026-06-16
task=m-edge-model-group-queue-scheduling/04+01,02,03_regression_evidence, plan=0, tag=TEST
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `verification`: Edge-owned queue의 FIFO 순서, queue overflow/timeout, run terminal release, node disconnect release, multi-node dispatch가 테스트로 검증되어 있다. 검증: 대상 Go 패키지 테스트와 queue 관련 regression test가 통과한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [TEST-1] Predecessor Evidence 확인 | [ ] |
| [TEST-2] Package Regression | [ ] |
| [TEST-3] Runtime Smoke Evidence | [ ] |
## 구현 체크리스트
- [ ] predecessor `01`, `02`, `03` complete.log를 확인하고 Roadmap Targets가 기대 Task를 가리키는지 검토한다.
- [ ] Edge service queue regression tests가 FIFO, overflow, timeout, terminal release, disconnect release, multi-node dispatch를 모두 커버하는지 확인한다.
- [ ] 대상 package tests와 Edge/Node package regression을 실행하고 실제 stdout/stderr를 기록한다.
- [ ] 보조 smoke `./scripts/e2e-smoke.sh`와 OpenAI-compatible smoke `./scripts/e2e-openai-ollama.sh` 실행 또는 blocker를 기록한다.
- [ ] full-cycle 실제 구동 수행 여부를 명시하고, 못 하면 local 규칙 기준 blocker/남은 위험을 기록한다.
- [ ] 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을 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS split 작업이면 parent directory 유지/정리를 확인한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 경로를 처리한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- predecessor complete.log가 모두 존재하고 PASS 경로인지 확인한다.
- queue regression coverage가 roadmap `verification` 문구와 직접 대응하는지 확인한다.
- smoke 결과와 full-cycle 수행 여부/미수행 사유가 명확한지 확인한다.
## 검증 결과
### TEST-1 중간 검증
```text
$ find agent-task -path 'agent-task/archive' -prune -o -path 'agent-task/m-edge-model-group-queue-scheduling/*/complete.log' -print | sort
(output)
```
### TEST-2 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/service
(output)
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane
(output)
$ go test -count=1 ./apps/node/internal/node
(output)
```
### TEST-3 중간 검증
```text
$ ./scripts/e2e-smoke.sh
(output)
$ ./scripts/e2e-openai-ollama.sh
(output)
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/... ./apps/node/...
(output)
$ ./scripts/e2e-smoke.sh
(output)
$ ./scripts/e2e-openai-ollama.sh
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**

View file

@ -0,0 +1,198 @@
<!-- task=m-edge-model-group-queue-scheduling/04+01,02,03_regression_evidence plan=0 tag=TEST -->
# Plan - TEST Edge Queue Regression Evidence
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증 명령을 실행하고 실제 출력, 구현 메모, 계획 대비 변경 사항을 채운 뒤 active 파일을 남긴 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 범위 변경, 외부 환경/secret, 또는 scope conflict가 생기면 chat에서 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 남긴다.
## 배경
Edge queue core, surface/snapshot, Node simplification이 각각 PASS된 뒤에는 Epic의 최종 `verification` Task가 요구하는 FIFO, overflow/timeout, terminal release, disconnect release, multi-node dispatch 회귀 증거를 한곳에서 확인해야 한다. 보조 smoke는 completion을 대체하지 않으므로, package tests와 runtime smoke/full-cycle 여부를 명확히 분리해 기록한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하지 않는다. code-review가 해당 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/edge-model-group-queue-scheduling.md`
- Task ids:
- `verification`: Edge-owned queue의 FIFO 순서, queue overflow/timeout, run terminal release, node disconnect release, multi-node dispatch가 테스트로 검증되어 있다. 검증: 대상 Go 패키지 테스트와 queue 관련 regression test가 통과한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `Makefile`
- `scripts/e2e-smoke.sh`
- `scripts/e2e-openai-ollama.sh`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/node/node_concurrency_integration_test.go`
### 테스트 환경 규칙
- test_env: `local`.
- 적용 profile: `edge-smoke`, `node-smoke`, `platform-common-smoke`.
- 대상 package tests:
- `go test -count=1 ./apps/edge/internal/service`
- `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane`
- `go test -count=1 ./apps/node/internal/node`
- 전체 Go regression: `go test -count=1 ./apps/edge/... ./apps/node/...`.
- 보조 smoke: `./scripts/e2e-smoke.sh`, `./scripts/e2e-openai-ollama.sh`.
- `scripts/e2e-smoke.sh` 자체가 "auxiliary smoke only; completion requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification"을 출력한다. 이 plan에서도 보조 smoke만으로 완료 처리하지 않는다.
### 테스트 커버리지 공백
- 앞선 subtasks가 PASS되기 전에는 이 plan을 구현하지 않는다.
- real external CLI profile 또는 field host 검증은 local container에서 불가능할 수 있다. 불가하면 profile별 blocker로 기록하고, package/regression tests와 구분한다.
- Docker compose 검증은 현재 작업 컨테이너에서 수행하지 않는다.
### 심볼 참조
- 이 plan 자체는 rename/remove를 의도하지 않는다.
- queue 관련 regression test anchor:
- `TestModelQueue*` 또는 `TestSubmitRunModelQueue*` in `apps/edge/internal/service`.
- OpenAI request model group key tests in `apps/edge/internal/openai`.
- Node safety/capability tests in `apps/node/internal/node`.
### 분할 판단
- shared task group: `m-edge-model-group-queue-scheduling`.
- 현재 subtask: `04+01,02,03_regression_evidence`.
- predecessor `01`, `02`, `03`: active complete.log 없음. 구현/검증 시작 전 세 predecessor의 PASS complete.log가 필요하다.
- 이 plan은 최종 evidence 수집 전용이며 새로운 product behavior를 추가하지 않는다.
### 범위 결정 근거
- 구현 보완이 발견되면 이 plan에서 임의로 광범위 수정하지 않는다. FAIL/WARN 후 follow-up plan으로 분리한다.
- remote field runner, external CLI auth, secret 준비는 사용자/환경 blocker로 기록한다.
- `go test ./...`가 너무 넓어 unrelated failure가 나오면, 대상 package command 결과와 전체 command failure를 모두 기록하고 원인 분류를 review에 남긴다.
### 빌드 등급
- `cloud-G06`: 검증 evidence 신뢰성이 중요하고, runtime smoke 출력 판독과 predecessor 결과 확인이 필요하다.
## 구현 체크리스트
- [ ] predecessor `01`, `02`, `03` complete.log를 확인하고 Roadmap Targets가 기대 Task를 가리키는지 검토한다.
- [ ] Edge service queue regression tests가 FIFO, overflow, timeout, terminal release, disconnect release, multi-node dispatch를 모두 커버하는지 확인한다.
- [ ] 대상 package tests와 Edge/Node package regression을 실행하고 실제 stdout/stderr를 기록한다.
- [ ] 보조 smoke `./scripts/e2e-smoke.sh`와 OpenAI-compatible smoke `./scripts/e2e-openai-ollama.sh` 실행 또는 blocker를 기록한다.
- [ ] full-cycle 실제 구동 수행 여부를 명시하고, 못 하면 local 규칙 기준 blocker/남은 위험을 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [TEST-1] Predecessor Evidence 확인
#### 문제
이 verification Task는 구현 subtask 완료 없이 실행하면 의미가 없다.
#### 해결 방법
active/archive 후보에서 `complete.log`를 확인하고 `Roadmap Completion` 섹션이 `edge-admission`, `node-dispatch`, `snapshot-contract`, `surface-coverage`, `node-simplify`를 포함하는지 확인한다.
#### 수정 파일 및 체크리스트
- [ ] 코드 수정 없음.
- [ ] `CODE_REVIEW-cloud-G06.md`에 predecessor complete.log 경로를 기록.
#### 테스트 작성
- 스킵: evidence collection task다. 새 동작 테스트가 빠져 있으면 predecessor follow-up으로 분리한다.
#### 중간 검증
```bash
find agent-task -path 'agent-task/archive' -prune -o -path 'agent-task/m-edge-model-group-queue-scheduling/*/complete.log' -print | sort
```
### [TEST-2] Package Regression
#### 문제
queue 전환은 Edge service, OpenAI/A2A/console/control-plane status, Node execution lifecycle을 모두 건드린다.
#### 해결 방법
대상 package tests를 fresh run으로 실행한다.
#### 수정 파일 및 체크리스트
- [ ] 코드 수정 없음.
- [ ] 실패 시 실패 package와 test name을 review에 기록하고 follow-up plan이 필요한지 분류.
#### 테스트 작성
- 스킵: 이 task는 테스트 실행/evidence 수집 전용이다.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service
go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/input/a2a ./apps/edge/internal/opsconsole ./apps/edge/internal/controlplane
go test -count=1 ./apps/node/internal/node
```
### [TEST-3] Runtime Smoke Evidence
#### 문제
local rules는 사용자 실행 파이프라인 변경을 unit tests만으로 완료 처리하지 않는다.
#### 해결 방법
보조 smoke를 실행하고, repo 내부 edge-node full-cycle 실제 구동 수행 여부를 기록한다. `scripts/e2e-smoke.sh`는 메시지 2회, background run, `/capabilities`, `/transport`, `/sessions`, persistent `/terminate-session`까지 확인한다. OpenAI smoke는 `/v1/models`, chat completions, `/v1/responses`, streaming chat, `iop-edge smoke openai`를 확인한다.
#### 수정 파일 및 체크리스트
- [ ] 코드 수정 없음.
- [ ] `./scripts/e2e-smoke.sh` 결과 기록.
- [ ] `./scripts/e2e-openai-ollama.sh` 결과 기록.
- [ ] full-cycle 실제 구동 미수행 시 사유와 위험 기록.
#### 테스트 작성
- 스킵: 실행 evidence 수집 전용이다.
#### 중간 검증
```bash
./scripts/e2e-smoke.sh
./scripts/e2e-openai-ollama.sh
```
## 의존 관계 및 구현 순서
- `01_edge_queue_service`, `02+01_surface_snapshot_contract`, `03+01_node_queue_simplify` complete.log가 먼저 필요하다.
- 구현 전 확인 후보:
- active: `agent-task/m-edge-model-group-queue-scheduling/01_edge_queue_service/complete.log`
- active: `agent-task/m-edge-model-group-queue-scheduling/02+01_surface_snapshot_contract/complete.log`
- active: `agent-task/m-edge-model-group-queue-scheduling/03+01_node_queue_simplify/complete.log`
- archive: `agent-task/archive/*/*/m-edge-model-group-queue-scheduling/<subtask>/complete.log`
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `agent-task/m-edge-model-group-queue-scheduling/04+01,02,03_regression_evidence/CODE_REVIEW-cloud-G06.md` | TEST-1, TEST-2, TEST-3 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/... ./apps/node/...
./scripts/e2e-smoke.sh
./scripts/e2e-openai-ollama.sh
```
기대 결과: 모두 통과. Go test cache는 허용하지 않는다. smoke가 환경 문제로 막히면 실제 stdout/stderr와 blocker를 기록한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -71,15 +71,16 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
)
handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
NodeRef: dispatch.NodeRef,
Adapter: dispatch.Adapter,
Target: dispatch.Target,
SessionID: dispatch.SessionID,
Workspace: workspace,
Prompt: prompt,
Input: input,
TimeoutSec: dispatch.TimeoutSec,
Metadata: chatRunMetadata(runMeta, req, outputPolicy),
NodeRef: dispatch.NodeRef,
ModelGroupKey: strings.TrimSpace(req.Model),
Adapter: dispatch.Adapter,
Target: dispatch.Target,
SessionID: dispatch.SessionID,
Workspace: workspace,
Prompt: prompt,
Input: input,
TimeoutSec: dispatch.TimeoutSec,
Metadata: chatRunMetadata(runMeta, req, outputPolicy),
})
if err != nil {
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())

View file

@ -79,15 +79,16 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
)
handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
NodeRef: dispatch.NodeRef,
Adapter: dispatch.Adapter,
Target: dispatch.Target,
SessionID: dispatch.SessionID,
Workspace: workspace,
Prompt: prompt,
Input: input,
TimeoutSec: dispatch.TimeoutSec,
Metadata: runMeta,
NodeRef: dispatch.NodeRef,
ModelGroupKey: strings.TrimSpace(req.Model),
Adapter: dispatch.Adapter,
Target: dispatch.Target,
SessionID: dispatch.SessionID,
Workspace: workspace,
Prompt: prompt,
Input: input,
TimeoutSec: dispatch.TimeoutSec,
Metadata: runMeta,
})
if err != nil {
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())

View file

@ -25,9 +25,10 @@ func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunR
s.req = req
return &edgeservice.RunHandle{
RunDispatch: edgeservice.RunDispatch{
RunID: "run-test",
Target: req.Target,
TimeoutSec: 5,
RunID: "run-test",
ModelGroupKey: req.ModelGroupKey,
Target: req.Target,
TimeoutSec: 5,
},
RunStream: edgeservice.RunStream{
Events: s.events,
@ -73,6 +74,9 @@ func TestChatCompletionsDispatchesConfiguredOllamaTarget(t *testing.T) {
if fake.req.SessionID != "cline" || fake.req.TimeoutSec != 15 {
t.Fatalf("execution config mismatch: %+v", fake.req)
}
if fake.req.ModelGroupKey != "client-model" {
t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
}
if !strings.Contains(fake.req.Prompt, "system: brief") || !strings.Contains(fake.req.Prompt, "user: say hello") {
t.Fatalf("prompt did not include messages: %q", fake.req.Prompt)
}
@ -564,6 +568,9 @@ func TestResponsesMetadataContractAndTargetOverride(t *testing.T) {
if fake.req.Metadata["openai_model"] != "client-model" {
t.Fatalf("openai_model: got %q", fake.req.Metadata["openai_model"])
}
if fake.req.ModelGroupKey != "client-model" {
t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
}
if fake.req.Metadata["openai_stream"] != "false" {
t.Fatalf("openai_stream: got %q", fake.req.Metadata["openai_stream"])
}
@ -615,6 +622,9 @@ func TestChatCompletionsMetadataContractAndWorkspace(t *testing.T) {
if fake.req.Metadata["source"] != "openai" {
t.Fatalf("source: got %q", fake.req.Metadata["source"])
}
if fake.req.ModelGroupKey != "client-model" {
t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
}
if _, ok := fake.req.Metadata["workspace"]; ok {
t.Fatal("workspace should not be copied into run metadata")
}
@ -1028,6 +1038,9 @@ func TestChatCompletionsRouteCatalogDispatchesModelB(t *testing.T) {
if fake.req.Adapter != "vllm" || fake.req.Target != "qwen" {
t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target)
}
if fake.req.ModelGroupKey != "model-b" {
t.Fatalf("model group key: got %q, want model-b", fake.req.ModelGroupKey)
}
}
func TestChatCompletionsCatalogMissFallsToLegacyTarget(t *testing.T) {

View file

@ -0,0 +1,333 @@
package service
import (
"context"
"fmt"
"sync"
"time"
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
eventpkg "iop/packages/go/events"
iop "iop/proto/gen/iop"
)
const (
defaultNodeCapacity = 1
defaultGroupMaxQueue = 16
defaultQueueTimeout = 30 * time.Second
)
var (
errQueueFull = fmt.Errorf("queue is full")
errQueueTimeout = fmt.Errorf("queue timeout")
)
// candidateNode pairs a registry entry with the per-request capacity derived
// from the node's adapter config (or Runtime.Concurrency as fallback).
type candidateNode struct {
entry *edgenode.NodeEntry
capacity int
}
type groupPolicy struct {
maxQueue int
queueTimeout time.Duration
}
type inflightRec struct {
groupKey string
nodeID string
}
type admitResult struct {
node *edgenode.NodeEntry
err error
}
type queueItem struct {
candidates []candidateNode
waitCh chan admitResult
deadline time.Time
}
type modelQueueGroup struct {
key string
policy groupPolicy
queue []*queueItem
inflight map[string]int // nodeID → current in-flight count
}
type modelQueueManager struct {
mu sync.Mutex
groups map[string]*modelQueueGroup
inflightByRun map[string]inflightRec // runID → {groupKey, nodeID}
store *edgenode.NodeStore
}
func newModelQueueManager(store *edgenode.NodeStore) *modelQueueManager {
return &modelQueueManager{
groups: make(map[string]*modelQueueGroup),
inflightByRun: make(map[string]inflightRec),
store: store,
}
}
func (m *modelQueueManager) setStore(store *edgenode.NodeStore) {
m.mu.Lock()
m.store = store
m.mu.Unlock()
}
// startEventWatcher subscribes to all run and node events, releasing in-flight
// slots when runs terminate or nodes disconnect. Returns a stop function.
func (m *modelQueueManager) startEventWatcher(bus *edgeevents.Bus) func() {
runCh, unsubRun := bus.SubscribeAllRuns(256)
nodeCh, unsubNode := bus.SubscribeAllNodes(64)
done := make(chan struct{})
go func() {
defer unsubRun()
defer unsubNode()
for {
select {
case e, ok := <-runCh:
if !ok {
return
}
if isTerminalRunEvent(e) {
m.releaseRun(e.GetRunId(), e.GetType())
}
case e, ok := <-nodeCh:
if !ok {
return
}
if e.GetType() == eventpkg.TypeNodeDisconnected {
m.releaseNode(e.GetNodeId(), "disconnected")
}
case <-done:
return
}
}
}()
return func() { close(done) }
}
func isTerminalRunEvent(e *iop.RunEvent) bool {
t := e.GetType()
return t == "complete" || t == "error" || t == "cancelled"
}
// getOrCreateGroupLocked returns an existing group or creates one with the
// given policy (applying defaults for zero fields). Policy is only applied
// at creation; existing groups retain their current policy.
func (m *modelQueueManager) getOrCreateGroupLocked(key string, policy groupPolicy) *modelQueueGroup {
g, ok := m.groups[key]
if !ok {
if policy.maxQueue <= 0 {
policy.maxQueue = defaultGroupMaxQueue
}
if policy.queueTimeout <= 0 {
policy.queueTimeout = defaultQueueTimeout
}
g = &modelQueueGroup{
key: key,
policy: policy,
inflight: make(map[string]int),
}
m.groups[key] = g
}
return g
}
func (m *modelQueueManager) findAvailableNodeLocked(group *modelQueueGroup, candidates []candidateNode) *candidateNode {
for i := range candidates {
c := &candidates[i]
if group.inflight[c.entry.NodeID] < c.capacity {
return c
}
}
return nil
}
// admit selects an available node for the given model group, or queues the
// request until a slot opens. Blocks until a node is assigned, the queue
// timeout expires, or ctx is cancelled.
func (m *modelQueueManager) admit(ctx context.Context, groupKey string, candidates []candidateNode, policy groupPolicy) (*edgenode.NodeEntry, error) {
m.mu.Lock()
group := m.getOrCreateGroupLocked(groupKey, policy)
candidate := m.findAvailableNodeLocked(group, candidates)
if candidate != nil {
group.inflight[candidate.entry.NodeID]++
m.mu.Unlock()
return candidate.entry, nil
}
if len(group.queue) >= group.policy.maxQueue {
m.mu.Unlock()
return nil, fmt.Errorf("model group %q: %w", groupKey, errQueueFull)
}
item := &queueItem{
candidates: candidates,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(group.policy.queueTimeout),
}
group.queue = append(group.queue, item)
timeout := group.policy.queueTimeout
m.mu.Unlock()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case res := <-item.waitCh:
return res.node, res.err
case <-timer.C:
m.mu.Lock()
m.removeItemLocked(groupKey, item)
m.mu.Unlock()
// Handle race: dispatch may have sent to waitCh just before timeout fired.
select {
case res := <-item.waitCh:
if res.node != nil {
m.releaseSlot(groupKey, res.node.NodeID)
}
default:
}
return nil, fmt.Errorf("model group %q: %w", groupKey, errQueueTimeout)
case <-ctx.Done():
m.mu.Lock()
m.removeItemLocked(groupKey, item)
m.mu.Unlock()
// Handle race: dispatch may have sent to waitCh just before cancellation.
select {
case res := <-item.waitCh:
if res.node != nil {
m.releaseSlot(groupKey, res.node.NodeID)
}
default:
}
return nil, ctx.Err()
}
}
// trackInflight records a dispatched run so release events can find it.
func (m *modelQueueManager) trackInflight(groupKey, runID, nodeID string) {
m.mu.Lock()
m.inflightByRun[runID] = inflightRec{groupKey: groupKey, nodeID: nodeID}
m.mu.Unlock()
}
// releaseRun releases the in-flight slot for a terminated run and dispatches
// the next queued item if one is waiting.
func (m *modelQueueManager) releaseRun(runID, reason string) {
m.mu.Lock()
rec, ok := m.inflightByRun[runID]
if !ok {
m.mu.Unlock()
return
}
delete(m.inflightByRun, runID)
m.releaseSlotLocked(rec.groupKey, rec.nodeID)
m.mu.Unlock()
}
// releaseNode resets all in-flight slots on a disconnected node, removes it
// from all queued items' candidate lists, and tries to re-dispatch to any
// remaining live candidates.
func (m *modelQueueManager) releaseNode(nodeID, reason string) {
m.mu.Lock()
defer m.mu.Unlock()
for runID, rec := range m.inflightByRun {
if rec.nodeID == nodeID {
delete(m.inflightByRun, runID)
}
}
for _, group := range m.groups {
// Remove the disconnected node from all queued items so a future
// tryDispatch cannot pick it as a stale available candidate.
for _, item := range group.queue {
filtered := make([]candidateNode, 0, len(item.candidates))
for _, c := range item.candidates {
if c.entry.NodeID != nodeID {
filtered = append(filtered, c)
}
}
item.candidates = filtered
}
if group.inflight[nodeID] > 0 {
group.inflight[nodeID] = 0
m.tryDispatchLocked(group)
}
}
}
// releaseSlot decrements the in-flight count and tries to dispatch the next
// queued item. Used when admit-path I/O fails after the slot was reserved.
func (m *modelQueueManager) releaseSlot(groupKey, nodeID string) {
m.mu.Lock()
m.releaseSlotLocked(groupKey, nodeID)
m.mu.Unlock()
}
func (m *modelQueueManager) releaseSlotLocked(groupKey, nodeID string) {
group, ok := m.groups[groupKey]
if !ok {
return
}
if group.inflight[nodeID] > 0 {
group.inflight[nodeID]--
}
m.tryDispatchLocked(group)
}
// tryDispatchLocked attempts to dispatch the head of the queue to an available
// node. Must be called with m.mu held.
func (m *modelQueueManager) tryDispatchLocked(group *modelQueueGroup) {
for len(group.queue) > 0 {
head := group.queue[0]
if time.Now().After(head.deadline) {
group.queue = group.queue[1:]
select {
case head.waitCh <- admitResult{err: fmt.Errorf("model group %q: %w", group.key, errQueueTimeout)}:
default:
}
continue
}
candidate := m.findAvailableNodeLocked(group, head.candidates)
if candidate == nil {
break
}
group.queue = group.queue[1:]
group.inflight[candidate.entry.NodeID]++
select {
case head.waitCh <- admitResult{node: candidate.entry}:
return
default:
// Caller already timed out or cancelled; release the reserved slot and try next.
group.inflight[candidate.entry.NodeID]--
}
}
}
func (m *modelQueueManager) removeItemLocked(groupKey string, item *queueItem) {
group, ok := m.groups[groupKey]
if !ok {
return
}
for i, it := range group.queue {
if it == item {
group.queue = append(group.queue[:i], group.queue[i+1:]...)
return
}
}
}

View file

@ -0,0 +1,490 @@
package service
import (
"context"
"errors"
"testing"
"time"
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
// waitForQueueLen polls until the group queue reaches wantLen or times out.
func waitForQueueLen(t *testing.T, m *modelQueueManager, groupKey string, wantLen int) {
t.Helper()
deadline := time.Now().Add(200 * time.Millisecond)
for time.Now().Before(deadline) {
m.mu.Lock()
g, ok := m.groups[groupKey]
got := 0
if ok {
got = len(g.queue)
}
m.mu.Unlock()
if got >= wantLen {
return
}
time.Sleep(1 * time.Millisecond)
}
t.Fatalf("timeout: queue for %q did not reach length %d", groupKey, wantLen)
}
// TestModelQueueFIFOOrdering verifies that queued items are dispatched in FIFO
// order when a slot becomes available.
func TestModelQueueFIFOOrdering(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-q1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry := &edgenode.NodeEntry{NodeID: "node-q1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
defPolicy := groupPolicy{}
m := newModelQueueManager(store)
// Fill the only capacity slot.
first, err := m.admit(context.Background(), "g-fifo", cands, defPolicy)
if err != nil || first == nil {
t.Fatalf("initial admit: %v", err)
}
// Manually insert two items in known order so we can verify FIFO.
item1 := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
item2 := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-fifo", groupPolicy{})
g.queue = append(g.queue, item1, item2)
m.mu.Unlock()
// Release one slot — item1 (head) must be dispatched first.
m.mu.Lock()
m.releaseSlotLocked("g-fifo", "node-q1")
m.mu.Unlock()
select {
case res := <-item1.waitCh:
if res.err != nil {
t.Fatalf("item1 expected dispatch, got error: %v", res.err)
}
if res.node == nil || res.node.NodeID != "node-q1" {
t.Fatalf("item1: unexpected node %v", res.node)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout: item1 was not dispatched after slot release")
}
// item2 must still be waiting (no slot available yet).
select {
case <-item2.waitCh:
t.Fatal("item2 should not have been dispatched yet")
default:
}
// Release the slot item1 holds so item2 gets dispatched.
m.mu.Lock()
m.releaseSlotLocked("g-fifo", "node-q1")
m.mu.Unlock()
select {
case res := <-item2.waitCh:
if res.err != nil {
t.Fatalf("item2 expected dispatch, got error: %v", res.err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout: item2 was not dispatched after second slot release")
}
}
// TestModelQueueOverflow verifies that admit rejects requests when the queue
// is at max_queue capacity.
func TestModelQueueOverflow(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-ov1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry := &edgenode.NodeEntry{NodeID: "node-ov1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
m := newModelQueueManager(store)
// Fill the node's capacity and set max_queue=1.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-overflow", groupPolicy{})
g.policy.maxQueue = 1
g.inflight["node-ov1"] = 1
m.mu.Unlock()
// Queue one item — should succeed.
item := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
m.mu.Lock()
m.groups["g-overflow"].queue = append(m.groups["g-overflow"].queue, item)
m.mu.Unlock()
// Second admit should fail immediately with errQueueFull.
_, err := m.admit(context.Background(), "g-overflow", cands, groupPolicy{})
if !errors.Is(err, errQueueFull) {
t.Fatalf("expected errQueueFull, got: %v", err)
}
}
// TestModelQueueTimeout verifies that queued items receive a timeout error
// when no slot becomes available before the deadline.
func TestModelQueueTimeout(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-to1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry := &edgenode.NodeEntry{NodeID: "node-to1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
m := newModelQueueManager(store)
// Fill capacity and set a very short queue timeout.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-timeout", groupPolicy{})
g.policy.queueTimeout = 20 * time.Millisecond
g.inflight["node-to1"] = 1
m.mu.Unlock()
start := time.Now()
_, err := m.admit(context.Background(), "g-timeout", cands, groupPolicy{})
elapsed := time.Since(start)
if !errors.Is(err, errQueueTimeout) {
t.Fatalf("expected errQueueTimeout, got: %v", err)
}
if elapsed < 15*time.Millisecond {
t.Fatalf("timed out too fast: %v", elapsed)
}
}
// TestModelQueueTerminalReleaseDispatchesNext verifies that a terminal run event
// releases the in-flight slot and dispatches the next queued item.
func TestModelQueueTerminalReleaseDispatchesNext(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-tr1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry := &edgenode.NodeEntry{NodeID: "node-tr1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
defPolicy := groupPolicy{}
bus := edgeevents.NewBus()
m := newModelQueueManager(store)
stop := m.startEventWatcher(bus)
defer stop()
// Fill capacity and record inflight.
node, err := m.admit(context.Background(), "g-tr", cands, defPolicy)
if err != nil {
t.Fatalf("admit: %v", err)
}
m.trackInflight("g-tr", "run-tr-001", node.NodeID)
// Queue a second item in a goroutine.
resultCh := make(chan admitResult, 1)
go func() {
n, e := m.admit(context.Background(), "g-tr", cands, defPolicy)
resultCh <- admitResult{node: n, err: e}
}()
waitForQueueLen(t, m, "g-tr", 1)
// Terminal event for the first run — should unblock the queued admit.
for _, termType := range []string{"complete", "error", "cancelled"} {
t.Run(termType, func(t *testing.T) {
// Reset state for each sub-test.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-tr-"+termType, groupPolicy{})
g.inflight["node-tr1"] = 1
item := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
g.queue = []*queueItem{item}
runID := "run-tr-" + termType
m.inflightByRun[runID] = inflightRec{groupKey: "g-tr-" + termType, nodeID: "node-tr1"}
m.mu.Unlock()
bus.PublishRun(&iop.RunEvent{RunId: runID, Type: termType})
select {
case res := <-item.waitCh:
if res.err != nil {
t.Fatalf("expected dispatch, got error: %v", res.err)
}
if res.node == nil {
t.Fatal("expected non-nil node")
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("timeout: queued item not dispatched after %q terminal event", termType)
}
})
}
// Cleanup the goroutine from the outer admit.
m.mu.Lock()
if g, ok := m.groups["g-tr"]; ok && len(g.queue) > 0 {
g.inflight["node-tr1"] = 1
m.tryDispatchLocked(g)
}
m.mu.Unlock()
select {
case <-resultCh:
case <-time.After(100 * time.Millisecond):
}
}
// TestModelQueueNodeDisconnectReleasesInflight verifies that a node disconnect
// event removes the node from queued item candidate lists, and that subsequent
// dispatch goes to a remaining live candidate (not the disconnected node).
func TestModelQueueNodeDisconnectReleasesInflight(t *testing.T) {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-nd1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
store.Add(&edgenode.NodeRecord{
ID: "node-nd2",
Runtime: config.RuntimeConf{Concurrency: 1},
})
entry1 := &edgenode.NodeEntry{NodeID: "node-nd1"}
entry2 := &edgenode.NodeEntry{NodeID: "node-nd2"}
// Queued item that can use either node (both at capacity 1).
bothCands := []candidateNode{
{entry: entry1, capacity: 1},
{entry: entry2, capacity: 1},
}
bus := edgeevents.NewBus()
m := newModelQueueManager(store)
stop := m.startEventWatcher(bus)
defer stop()
// Fill node-nd1 and node-nd2 capacities and record inflight.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-nd", groupPolicy{})
g.inflight["node-nd1"] = 1
g.inflight["node-nd2"] = 1
m.inflightByRun["run-nd-x"] = inflightRec{groupKey: "g-nd", nodeID: "node-nd1"}
m.inflightByRun["run-nd-y"] = inflightRec{groupKey: "g-nd", nodeID: "node-nd2"}
// Queue an item that can use either node.
item := &queueItem{
candidates: bothCands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
g.queue = []*queueItem{item}
m.mu.Unlock()
// node-nd1 disconnects: its inflight is freed and it is removed from candidates.
// node-nd2 is still full, so no dispatch yet.
bus.PublishNode(&iop.EdgeNodeEvent{
NodeId: "node-nd1",
Type: "node.disconnected",
})
// node-nd2's run terminates: now nd2 has capacity, dispatch goes to nd2.
bus.PublishRun(&iop.RunEvent{RunId: "run-nd-y", Type: "complete"})
select {
case res := <-item.waitCh:
if res.err != nil {
t.Fatalf("expected dispatch after disconnect+terminal, got error: %v", res.err)
}
if res.node == nil {
t.Fatal("expected non-nil node after disconnect release")
}
// Must be nd2 — nd1 was removed from candidates on disconnect.
if res.node.NodeID != "node-nd2" {
t.Errorf("expected dispatch to node-nd2, got %q (nd1 was disconnected)", res.node.NodeID)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("timeout: queued item not dispatched after node disconnect and terminal event")
}
// The inflight record for run-nd-x should be gone (removed at disconnect).
m.mu.Lock()
_, stillInFlight := m.inflightByRun["run-nd-x"]
m.mu.Unlock()
if stillInFlight {
t.Error("run-nd-x should be removed from inflightByRun after node disconnect")
}
}
// TestModelQueueUsesProviderCapacity verifies that the capacity supplied in the
// candidateNode (derived from adapter config) overrides the default of 1.
func TestModelQueueUsesProviderCapacity(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-pc1"}
// Provider capacity = 2 (two concurrent slots on this node).
cands := []candidateNode{{entry: entry, capacity: 2}}
defPolicy := groupPolicy{}
m := newModelQueueManager(nil)
// First admit: inflight=0 < cap=2 → dispatched immediately.
n1, err := m.admit(context.Background(), "g-pc", cands, defPolicy)
if err != nil || n1 == nil {
t.Fatalf("first admit: %v", err)
}
// Second admit: inflight=1 < cap=2 → still dispatched (not queued).
n2, err := m.admit(context.Background(), "g-pc", cands, defPolicy)
if err != nil || n2 == nil {
t.Fatalf("second admit (capacity=2 should allow): %v", err)
}
// Third request must queue because inflight=2 == cap=2.
item := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(200 * time.Millisecond),
}
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-pc", groupPolicy{})
g.queue = append(g.queue, item)
m.mu.Unlock()
// Releasing one slot should dispatch the queued item.
m.releaseSlot("g-pc", "node-pc1")
select {
case res := <-item.waitCh:
if res.err != nil {
t.Fatalf("expected dispatch after slot release, got: %v", res.err)
}
if res.node == nil || res.node.NodeID != "node-pc1" {
t.Fatalf("unexpected node: %v", res.node)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("timeout: item not dispatched after slot release")
}
}
// TestModelQueueUsesProviderQueuePolicy verifies that max_queue and
// queue_timeout from the policy parameter are respected.
func TestModelQueueUsesProviderQueuePolicy(t *testing.T) {
t.Run("maxQueue enforced", func(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-pq1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
// Provider policy: maxQueue=1.
policy := groupPolicy{maxQueue: 1, queueTimeout: 5 * time.Second}
m := newModelQueueManager(nil)
// Fill capacity and queue one item manually.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-pq-max", policy)
g.inflight["node-pq1"] = 1
g.queue = []*queueItem{{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(5 * time.Second),
}}
m.mu.Unlock()
// Second admit should fail: queue already at maxQueue=1.
_, err := m.admit(context.Background(), "g-pq-max", cands, policy)
if !errors.Is(err, errQueueFull) {
t.Fatalf("expected errQueueFull, got: %v", err)
}
})
t.Run("queueTimeout enforced", func(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-pq2"}
cands := []candidateNode{{entry: entry, capacity: 1}}
// Provider policy: very short timeout.
policy := groupPolicy{maxQueue: 16, queueTimeout: 20 * time.Millisecond}
m := newModelQueueManager(nil)
// Fill capacity.
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-pq-to", policy)
g.inflight["node-pq2"] = 1
m.mu.Unlock()
start := time.Now()
_, err := m.admit(context.Background(), "g-pq-to", cands, policy)
elapsed := time.Since(start)
if !errors.Is(err, errQueueTimeout) {
t.Fatalf("expected errQueueTimeout, got: %v", err)
}
if elapsed < 15*time.Millisecond {
t.Fatalf("timed out too fast: %v", elapsed)
}
})
}
// TestModelQueueContextCancelRemovesQueuedItem verifies that cancelling the
// context of a queued admit removes the item and returns context.Canceled.
func TestModelQueueContextCancelRemovesQueuedItem(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-cc1"}
cands := []candidateNode{{entry: entry, capacity: 1}}
defPolicy := groupPolicy{}
m := newModelQueueManager(nil)
// Fill the only slot.
n, err := m.admit(context.Background(), "g-cc", cands, defPolicy)
if err != nil || n == nil {
t.Fatalf("initial admit: %v", err)
}
// Queue a second admit with a cancellable context.
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
_, err := m.admit(ctx, "g-cc", cands, defPolicy)
resultCh <- err
}()
waitForQueueLen(t, m, "g-cc", 1)
cancel()
select {
case err := <-resultCh:
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got: %v", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("timeout: context cancellation not handled")
}
// Queue must be empty after cancellation.
m.mu.Lock()
qLen := 0
if g, ok := m.groups["g-cc"]; ok {
qLen = len(g.queue)
}
m.mu.Unlock()
if qLen != 0 {
t.Errorf("queue should be empty after cancel, got %d items", qLen)
}
}

View file

@ -8,35 +8,38 @@ import (
"google.golang.org/protobuf/types/known/structpb"
edgenode "iop/apps/edge/internal/node"
eventpkg "iop/packages/go/events"
iop "iop/proto/gen/iop"
)
type SubmitRunRequest struct {
NodeRef string
RunID string
Adapter string
Target string
SessionID string
Workspace string
Prompt string
Input map[string]any
Background bool
TimeoutSec int
Metadata map[string]string
NodeRef string
RunID string
ModelGroupKey string
Adapter string
Target string
SessionID string
Workspace string
Prompt string
Input map[string]any
Background bool
TimeoutSec int
Metadata map[string]string
}
// RunDispatch describes a dispatched run in surface-neutral terms. It is the
// metadata any caller (console, HTTP, future RPC) needs after submission.
type RunDispatch struct {
RunID string
NodeID string
NodeLabel string
Adapter string
Target string
SessionID string
Background bool
TimeoutSec int
RunID string
NodeID string
NodeLabel string
ModelGroupKey string
Adapter string
Target string
SessionID string
Background bool
TimeoutSec int
}
// RunStream carries asynchronous events for a dispatched foreground run.
@ -92,12 +95,331 @@ func (h *RunHandle) Stream() RunStream {
return h.RunStream
}
func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (RunResult, error) {
func (s *Service) SubmitRun(ctx context.Context, req SubmitRunRequest) (RunResult, error) {
if req.ModelGroupKey != "" && s.queue != nil {
return s.submitRunQueued(ctx, req)
}
return s.submitRunDirect(req)
}
func (s *Service) submitRunDirect(req SubmitRunRequest) (RunResult, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return nil, err
}
return s.dispatchToEntry(entry, req)
}
func (s *Service) submitRunQueued(ctx context.Context, req SubmitRunRequest) (RunResult, error) {
candidates, policy, err := s.resolveQueueCandidates(req)
if err != nil {
return nil, err
}
entry, err := s.queue.admit(ctx, req.ModelGroupKey, candidates, policy)
if err != nil {
return nil, err
}
runReq, runID, err := BuildRunRequest(req)
if err != nil {
s.queue.releaseSlot(req.ModelGroupKey, entry.NodeID)
return nil, err
}
// Track inflight before send so the event watcher can release the slot
// even if a terminal event arrives before the Send call completes.
s.queue.trackInflight(req.ModelGroupKey, runID, entry.NodeID)
var runEvents <-chan *iop.RunEvent
var unregisterRun func()
var nodeEvents <-chan *iop.EdgeNodeEvent
var unregisterNode func()
if !runReq.GetBackground() {
if s.events == nil {
s.queue.releaseRun(runID, "no-event-bus")
return nil, fmt.Errorf("event bus is not configured")
}
runEvents, unregisterRun = s.events.SubscribeRun(runID, 4096)
nodeEvents, unregisterNode = s.events.SubscribeNode(entry.NodeID, 16)
}
if err := entry.Client.Send(runReq); err != nil {
s.queue.releaseRun(runID, "send-error")
if unregisterRun != nil {
unregisterRun()
}
if unregisterNode != nil {
unregisterNode()
}
return nil, err
}
return &RunHandle{
RunDispatch: RunDispatch{
RunID: runID,
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
ModelGroupKey: req.ModelGroupKey,
Adapter: runReq.GetAdapter(),
Target: runReq.GetTarget(),
SessionID: runReq.GetSessionId(),
Background: runReq.GetBackground(),
TimeoutSec: int(runReq.GetTimeoutSec()),
},
RunStream: RunStream{
Events: runEvents,
NodeEvents: nodeEvents,
},
close: func() {
if unregisterRun != nil {
unregisterRun()
}
if unregisterNode != nil {
unregisterNode()
}
},
}, nil
}
// resolveQueueCandidates returns candidate nodes filtered by adapter/target capability,
// each paired with per-node capacity, plus the group policy derived from adapter config.
func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode, groupPolicy, error) {
if req.NodeRef != "" {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return nil, groupPolicy{}, err
}
cap := defaultNodeCapacity
if s.nodeStore != nil {
if rec, ok := s.nodeStore.FindByID(entry.NodeID); ok {
res := resolveAdapterForNode(rec, req.Adapter, req.Target)
if !res.supported {
msg := fmt.Sprintf("node %q does not support adapter %q target %q", entry.NodeID, req.Adapter, req.Target)
if res.ambiguous {
msg = fmt.Sprintf("node %q: adapter %q is ambiguous (multiple enabled instances); use an instance key", entry.NodeID, req.Adapter)
}
return nil, groupPolicy{}, fmt.Errorf("%s", msg)
}
cap = res.capacity
}
}
policy := groupPolicyFromStore(s.nodeStore, []*edgenode.NodeEntry{entry}, req.Adapter, req.Target)
return []candidateNode{{entry: entry, capacity: cap}}, policy, nil
}
all := s.registry.All()
if len(all) == 0 {
return nil, groupPolicy{}, fmt.Errorf("no nodes connected")
}
var candidates []candidateNode
for _, entry := range all {
cap := defaultNodeCapacity
if s.nodeStore != nil {
rec, ok := s.nodeStore.FindByID(entry.NodeID)
if ok {
res := resolveAdapterForNode(rec, req.Adapter, req.Target)
if !res.supported {
continue
}
cap = res.capacity
}
}
candidates = append(candidates, candidateNode{entry: entry, capacity: cap})
}
if len(candidates) == 0 {
return nil, groupPolicy{}, fmt.Errorf("no nodes support adapter %q target %q", req.Adapter, req.Target)
}
entries := make([]*edgenode.NodeEntry, len(candidates))
for i, c := range candidates {
entries[i] = c.entry
}
policy := groupPolicyFromStore(s.nodeStore, entries, req.Adapter, req.Target)
return candidates, policy, nil
}
// adapterResolution holds the resolved capacity and queue policy for a single
// node/adapter combination. ambiguous is true when a type-name lookup (e.g.,
// "ollama") matches 2+ enabled instances on that node, mirroring the Node
// router's exact-instance-key/ambiguity contract.
type adapterResolution struct {
supported bool
ambiguous bool
capacity int
maxQueue int
queueTimeoutMS int
}
func positiveOr(v, fallback int) int {
if v > 0 {
return v
}
return fallback
}
// resolveAdapterForNode determines whether a node can handle adapterType/target
// and computes the per-node capacity and queue policy fields.
//
// Resolution order:
// 1. Exact instance Name match across OllamaInstances / VllmInstances /
// OpenAICompatInstances (instance-key route, matching Node router priority).
// 2. Type-name route (e.g. "ollama"): supported only when exactly 1 enabled
// instance of that type exists. 2+ enabled instances → ambiguous (fail,
// same semantics as Node router ambiguity error). 0 instances → fail-open
// for legacy/unconfigured nodes.
// 3. "cli": capability gated by CLI.Enabled and profile name in target.
// 4. Default (unknown adapter type): fail-open.
func resolveAdapterForNode(rec *edgenode.NodeRecord, adapterType, target string) adapterResolution {
if rec == nil {
return adapterResolution{supported: true, capacity: defaultNodeCapacity}
}
concurrencyFallback := positiveOr(rec.Runtime.Concurrency, defaultNodeCapacity)
// Exact instance Name match (highest priority).
for _, inst := range rec.Adapters.OllamaInstances {
if inst.Name == adapterType {
return adapterResolution{
supported: inst.Enabled,
capacity: positiveOr(inst.Capacity, concurrencyFallback),
maxQueue: inst.MaxQueue,
queueTimeoutMS: inst.QueueTimeoutMS,
}
}
}
for _, inst := range rec.Adapters.VllmInstances {
if inst.Name == adapterType {
return adapterResolution{
supported: inst.Enabled,
capacity: positiveOr(inst.Capacity, concurrencyFallback),
maxQueue: inst.MaxQueue,
queueTimeoutMS: inst.QueueTimeoutMS,
}
}
}
for _, inst := range rec.Adapters.OpenAICompatInstances {
if inst.Name == adapterType {
return adapterResolution{
supported: inst.Enabled,
capacity: positiveOr(inst.Capacity, concurrencyFallback),
maxQueue: inst.MaxQueue,
queueTimeoutMS: inst.QueueTimeoutMS,
}
}
}
// Type-name route.
switch adapterType {
case "ollama":
return resolveTypeRoute(rec, ollamaEnabledInstances(rec), concurrencyFallback)
case "vllm":
return resolveTypeRoute(rec, vllmEnabledInstances(rec), concurrencyFallback)
case "openai_compat":
return resolveTypeRoute(rec, openAICompatEnabledInstances(rec), concurrencyFallback)
case "cli":
// CLI has no named multi-instance model; capability is gated by profile.
if !rec.Adapters.CLI.Enabled && len(rec.Adapters.CLI.Profiles) == 0 {
// No CLI config at all → fail-open for legacy/unconfigured nodes.
return adapterResolution{supported: true, capacity: concurrencyFallback}
}
if !rec.Adapters.CLI.Enabled {
return adapterResolution{supported: false}
}
if target == "" {
return adapterResolution{supported: len(rec.Adapters.CLI.Profiles) > 0, capacity: concurrencyFallback}
}
_, ok := rec.Adapters.CLI.Profiles[target]
return adapterResolution{supported: ok, capacity: concurrencyFallback}
default:
return adapterResolution{supported: true, capacity: concurrencyFallback}
}
}
// instanceFields holds the capacity/policy fields extracted from a single adapter instance.
type instanceFields struct {
capacity, maxQueue, queueTimeoutMS int
}
func ollamaEnabledInstances(rec *edgenode.NodeRecord) []instanceFields {
var out []instanceFields
for _, inst := range rec.Adapters.OllamaInstances {
if inst.Enabled {
out = append(out, instanceFields{inst.Capacity, inst.MaxQueue, inst.QueueTimeoutMS})
}
}
return out
}
func vllmEnabledInstances(rec *edgenode.NodeRecord) []instanceFields {
var out []instanceFields
for _, inst := range rec.Adapters.VllmInstances {
if inst.Enabled {
out = append(out, instanceFields{inst.Capacity, inst.MaxQueue, inst.QueueTimeoutMS})
}
}
return out
}
func openAICompatEnabledInstances(rec *edgenode.NodeRecord) []instanceFields {
var out []instanceFields
for _, inst := range rec.Adapters.OpenAICompatInstances {
if inst.Enabled {
out = append(out, instanceFields{inst.Capacity, inst.MaxQueue, inst.QueueTimeoutMS})
}
}
return out
}
// resolveTypeRoute applies Node-router-compatible type-name resolution:
// - 0 enabled instances → fail-open (legacy / unconfigured)
// - 1 enabled instance → use its capacity and policy
// - 2+ enabled instances → ambiguous (reject)
func resolveTypeRoute(rec *edgenode.NodeRecord, enabled []instanceFields, concurrencyFallback int) adapterResolution {
switch len(enabled) {
case 0:
return adapterResolution{supported: true, capacity: concurrencyFallback}
case 1:
f := enabled[0]
return adapterResolution{
supported: true,
capacity: positiveOr(f.capacity, concurrencyFallback),
maxQueue: f.maxQueue,
queueTimeoutMS: f.queueTimeoutMS,
}
default:
return adapterResolution{supported: false, ambiguous: true}
}
}
// groupPolicyFromStore derives queue policy from the first resolved candidate node.
func groupPolicyFromStore(store *edgenode.NodeStore, entries []*edgenode.NodeEntry, adapterType, target string) groupPolicy {
if store != nil {
for _, e := range entries {
rec, ok := store.FindByID(e.NodeID)
if !ok {
continue
}
res := resolveAdapterForNode(rec, adapterType, target)
if !res.supported {
continue
}
if res.maxQueue > 0 || res.queueTimeoutMS > 0 {
p := groupPolicy{
maxQueue: positiveOr(res.maxQueue, defaultGroupMaxQueue),
queueTimeout: time.Duration(positiveOr(res.queueTimeoutMS, 0)) * time.Millisecond,
}
if p.queueTimeout <= 0 {
p.queueTimeout = defaultQueueTimeout
}
return p
}
}
}
return groupPolicy{maxQueue: defaultGroupMaxQueue, queueTimeout: defaultQueueTimeout}
}
func (s *Service) dispatchToEntry(entry *edgenode.NodeEntry, req SubmitRunRequest) (RunResult, error) {
runReq, runID, err := BuildRunRequest(req)
if err != nil {
return nil, err
@ -127,14 +449,15 @@ func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (RunResult,
return &RunHandle{
RunDispatch: RunDispatch{
RunID: runID,
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
Adapter: runReq.GetAdapter(),
Target: runReq.GetTarget(),
SessionID: runReq.GetSessionId(),
Background: runReq.GetBackground(),
TimeoutSec: int(runReq.GetTimeoutSec()),
RunID: runID,
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
ModelGroupKey: req.ModelGroupKey,
Adapter: runReq.GetAdapter(),
Target: runReq.GetTarget(),
SessionID: runReq.GetSessionId(),
Background: runReq.GetBackground(),
TimeoutSec: int(runReq.GetTimeoutSec()),
},
RunStream: RunStream{
Events: runEvents,

View file

@ -20,14 +20,24 @@ type Service struct {
registry *edgenode.Registry
events *edgeevents.Bus
nodeStore *edgenode.NodeStore
queue *modelQueueManager
}
func New(registry *edgenode.Registry, events *edgeevents.Bus) *Service {
return &Service{registry: registry, events: events}
s := &Service{registry: registry, events: events}
if events != nil {
q := newModelQueueManager(nil)
q.startEventWatcher(events)
s.queue = q
}
return s
}
func (s *Service) SetNodeStore(store *edgenode.NodeStore) {
s.nodeStore = store
if s.queue != nil {
s.queue.setStore(store)
}
}
func (s *Service) ListNodes() []*edgenode.NodeEntry {

View file

@ -2,15 +2,18 @@ package service_test
import (
"context"
"errors"
"fmt"
"net"
"strings"
"sync"
"testing"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"google.golang.org/protobuf/proto"
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
@ -240,19 +243,23 @@ func TestResolveNodeSnapshotReturnsDTO(t *testing.T) {
func TestSubmitRunReturnsDispatchMetadata(t *testing.T) {
dispatch := edgeservice.RunDispatch{
RunID: "run-x",
NodeID: "node-1",
NodeLabel: "alpha",
Adapter: "cli",
Target: "codex",
SessionID: "session-a",
Background: true,
TimeoutSec: 30,
RunID: "run-x",
NodeID: "node-1",
NodeLabel: "alpha",
ModelGroupKey: "codex-model",
Adapter: "cli",
Target: "codex",
SessionID: "session-a",
Background: true,
TimeoutSec: 30,
}
handle := &edgeservice.RunHandle{RunDispatch: dispatch}
if handle.RunID != dispatch.RunID || handle.NodeLabel != dispatch.NodeLabel {
t.Fatalf("RunHandle does not expose embedded RunDispatch fields: %+v", handle)
}
if handle.ModelGroupKey != "codex-model" {
t.Errorf("RunHandle ModelGroupKey: got %q want codex-model", handle.ModelGroupKey)
}
if handle.Background != true || handle.TimeoutSec != 30 {
t.Errorf("RunHandle dispatch fields wrong: %+v", handle)
}
@ -940,3 +947,555 @@ func TestServiceCapabilitiesPreservesProviderSnapshots(t *testing.T) {
t.Fatalf("unexpected snapshot contents: %+v", snap)
}
}
// TestSubmitRunModelQueueFiltersCandidatesByAdapterTarget verifies that only
// nodes whose adapter config supports the requested adapter/target are used
// as candidates for queued dispatch.
func TestSubmitRunModelQueueFiltersCandidatesByAdapterTarget(t *testing.T) {
parserMap := toki.ParserMap{
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RunRequest{}
return m, proto.Unmarshal(b, m)
},
}
edgeConn1, nodeConn1 := net.Pipe()
edgeConn2, nodeConn2 := net.Pipe()
defer edgeConn1.Close()
defer nodeConn1.Close()
defer edgeConn2.Close()
defer nodeConn2.Close()
edgeClient1 := toki.NewTcpClient(edgeConn1, 0, 0, parserMap)
edgeClient2 := toki.NewTcpClient(edgeConn2, 0, 0, parserMap)
nodeClient1 := toki.NewTcpClient(nodeConn1, 0, 0, parserMap)
nodeClient2 := toki.NewTcpClient(nodeConn2, 0, 0, parserMap)
toki.AddListenerTyped[*iop.RunRequest](&nodeClient1.Communicator, func(*iop.RunRequest) {})
toki.AddListenerTyped[*iop.RunRequest](&nodeClient2.Communicator, func(*iop.RunRequest) {})
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "filt-node-1", Client: edgeClient1})
reg.Register(&edgenode.NodeEntry{NodeID: "filt-node-2", Client: edgeClient2})
store := edgenode.NewNodeStore()
// filt-node-1: supports cli/codex (capacity=2 via Concurrency).
store.Add(&edgenode.NodeRecord{
ID: "filt-node-1",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{"codex": {Command: "codex"}},
},
},
Runtime: config.RuntimeConf{Concurrency: 2},
})
// filt-node-2: supports cli/claude but NOT cli/codex.
store.Add(&edgenode.NodeRecord{
ID: "filt-node-2",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{"claude": {Command: "claude"}},
},
},
Runtime: config.RuntimeConf{Concurrency: 2},
})
bus := edgeevents.NewBus()
svc := edgeservice.New(reg, bus)
svc.SetNodeStore(store)
// Submit 2 runs for cli/codex — both should go to filt-node-1 (cap=2).
var (
mu sync.Mutex
nodeIDs []string
errs []error
)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
res, err := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "filt-group",
Adapter: "cli",
Target: "codex",
Background: true,
})
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, err)
return
}
nodeIDs = append(nodeIDs, res.Dispatch().NodeID)
res.Close()
}()
}
wg.Wait()
for _, err := range errs {
t.Fatalf("SubmitRun error: %v", err)
}
if len(nodeIDs) != 2 {
t.Fatalf("expected 2 dispatches, got %d", len(nodeIDs))
}
for _, id := range nodeIDs {
if id != "filt-node-1" {
t.Errorf("dispatch to %q: filt-node-2 does not support cli/codex", id)
}
}
}
// TestSubmitRunModelQueueRejectsExplicitNodeRefWithoutTarget verifies that when
// an explicit NodeRef is given, the node must support the requested adapter/target.
func TestSubmitRunModelQueueRejectsExplicitNodeRefWithoutTarget(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "excl-node-1"})
store := edgenode.NewNodeStore()
// excl-node-1: supports cli/claude but NOT cli/codex.
store.Add(&edgenode.NodeRecord{
ID: "excl-node-1",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{"claude": {Command: "claude"}},
},
},
})
bus := edgeevents.NewBus()
svc := edgeservice.New(reg, bus)
svc.SetNodeStore(store)
_, err := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
NodeRef: "excl-node-1",
ModelGroupKey: "excl-group",
Adapter: "cli",
Target: "codex",
Background: true,
})
if err == nil {
t.Fatal("expected error: node does not support cli/codex")
}
if !strings.Contains(err.Error(), "does not support") {
t.Errorf("expected 'does not support' in error, got: %v", err)
}
}
// TestSubmitRunModelQueueDispatchesQueuedRunAfterDisconnectToLiveNode verifies
// that after a node disconnect, a queued run is dispatched to a remaining live
// node once that node's slot becomes available.
func TestSubmitRunModelQueueDispatchesQueuedRunAfterDisconnectToLiveNode(t *testing.T) {
parserMap := toki.ParserMap{
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RunRequest{}
return m, proto.Unmarshal(b, m)
},
}
edgeConn1, nodeConn1 := net.Pipe()
edgeConn2, nodeConn2 := net.Pipe()
defer edgeConn1.Close()
defer nodeConn1.Close()
defer edgeConn2.Close()
defer nodeConn2.Close()
edgeClient1 := toki.NewTcpClient(edgeConn1, 0, 0, parserMap)
edgeClient2 := toki.NewTcpClient(edgeConn2, 0, 0, parserMap)
nodeClient1 := toki.NewTcpClient(nodeConn1, 0, 0, parserMap)
nodeClient2 := toki.NewTcpClient(nodeConn2, 0, 0, parserMap)
toki.AddListenerTyped[*iop.RunRequest](&nodeClient1.Communicator, func(*iop.RunRequest) {})
toki.AddListenerTyped[*iop.RunRequest](&nodeClient2.Communicator, func(*iop.RunRequest) {})
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "dc-node-1", Client: edgeClient1})
reg.Register(&edgenode.NodeEntry{NodeID: "dc-node-2", Client: edgeClient2})
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "dc-node-1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
store.Add(&edgenode.NodeRecord{
ID: "dc-node-2",
Runtime: config.RuntimeConf{Concurrency: 1},
})
bus := edgeevents.NewBus()
svc := edgeservice.New(reg, bus)
svc.SetNodeStore(store)
// Submit two background runs to fill both nodes.
res1, err := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "dc-group",
Adapter: "mock",
Background: true,
})
if err != nil {
t.Fatalf("run1: %v", err)
}
defer res1.Close()
res2, err := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "dc-group",
Adapter: "mock",
Background: true,
})
if err != nil {
t.Fatalf("run2: %v", err)
}
defer res2.Close()
dispatch1 := res1.Dispatch()
dispatch2 := res2.Dispatch()
if dispatch1.NodeID == "" || dispatch2.NodeID == "" || dispatch1.NodeID == dispatch2.NodeID {
t.Fatalf("expected two different nodes; got %q and %q", dispatch1.NodeID, dispatch2.NodeID)
}
// Start run3 in a goroutine — will queue because both nodes are full.
var (
res3 edgeservice.RunResult
err3 error
wg sync.WaitGroup
)
wg.Add(1)
go func() {
defer wg.Done()
res3, err3 = svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "dc-group",
Adapter: "mock",
Background: true,
})
}()
// Allow run3 to enter the queue.
time.Sleep(30 * time.Millisecond)
// Identify which node is nd1 (to disconnect) and nd2 (to receive run3).
nd1NodeID := dispatch1.NodeID
nd2RunID := dispatch2.RunID
nd2NodeID := dispatch2.NodeID
// nd1 disconnects — its candidate is removed from run3's queue item.
bus.PublishNode(&iop.EdgeNodeEvent{NodeId: nd1NodeID, Type: "node.disconnected"})
// nd2's run terminates — now nd2 has capacity, run3 dispatches to nd2.
bus.PublishRun(&iop.RunEvent{RunId: nd2RunID, Type: "complete"})
wg.Wait()
if err3 != nil {
t.Fatalf("run3 error: %v", err3)
}
if res3 == nil {
t.Fatal("run3: expected non-nil result")
}
defer res3.Close()
if got := res3.Dispatch().NodeID; got != nd2NodeID {
t.Errorf("run3 dispatched to %q, want %q (the live node; nd1=%q disconnected)", got, nd2NodeID, nd1NodeID)
}
}
// TestSubmitRunModelQueueContextCancelRemovesQueuedItem verifies that cancelling
// the SubmitRun context removes the item from the queue and returns context.Canceled.
func TestSubmitRunModelQueueContextCancelRemovesQueuedItem(t *testing.T) {
parserMap := toki.ParserMap{
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RunRequest{}
return m, proto.Unmarshal(b, m)
},
}
edgeConn, nodeConn := net.Pipe()
defer edgeConn.Close()
defer nodeConn.Close()
edgeClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap)
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap)
toki.AddListenerTyped[*iop.RunRequest](&nodeClient.Communicator, func(*iop.RunRequest) {})
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "ctx-node-1", Client: edgeClient})
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "ctx-node-1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
bus := edgeevents.NewBus()
svc := edgeservice.New(reg, bus)
svc.SetNodeStore(store)
// Fill the node capacity with run1.
res1, err := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "ctx-group",
Adapter: "mock",
Background: true,
})
if err != nil {
t.Fatalf("run1: %v", err)
}
defer res1.Close()
// Start run2 with a cancellable context (will queue).
ctx, cancel := context.WithCancel(context.Background())
var run2Err error
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
_, run2Err = svc.SubmitRun(ctx, edgeservice.SubmitRunRequest{
ModelGroupKey: "ctx-group",
Adapter: "mock",
Background: true,
})
}()
// Give run2 time to enter the queue.
time.Sleep(30 * time.Millisecond)
cancel()
wg.Wait()
if run2Err == nil {
t.Fatal("expected error from cancelled context, got nil")
}
if !errors.Is(run2Err, context.Canceled) {
t.Errorf("expected context.Canceled, got: %v", run2Err)
}
}
// TestSubmitRunModelQueueDispatchesAcrossNodes verifies that concurrent
// SubmitRun calls with the same ModelGroupKey are dispatched across multiple
// nodes when each node has capacity 1.
func TestSubmitRunModelQueueDispatchesAcrossNodes(t *testing.T) {
parserMap := toki.ParserMap{
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RunRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeCommandRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeCommandRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.NodeCommandResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.NodeCommandResponse{}
return m, proto.Unmarshal(b, m)
},
}
edgeConn1, nodeConn1 := net.Pipe()
edgeConn2, nodeConn2 := net.Pipe()
defer edgeConn1.Close()
defer nodeConn1.Close()
defer edgeConn2.Close()
defer nodeConn2.Close()
edgeClient1 := toki.NewTcpClient(edgeConn1, 0, 0, parserMap)
edgeClient2 := toki.NewTcpClient(edgeConn2, 0, 0, parserMap)
// Node-side clients consume RunRequest messages without responding.
nodeClient1 := toki.NewTcpClient(nodeConn1, 0, 0, parserMap)
nodeClient2 := toki.NewTcpClient(nodeConn2, 0, 0, parserMap)
toki.AddListenerTyped[*iop.RunRequest](&nodeClient1.Communicator, func(*iop.RunRequest) {})
toki.AddListenerTyped[*iop.RunRequest](&nodeClient2.Communicator, func(*iop.RunRequest) {})
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "mq-node-1", Client: edgeClient1})
reg.Register(&edgenode.NodeEntry{NodeID: "mq-node-2", Client: edgeClient2})
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "mq-node-1",
Token: "tok-1",
Runtime: config.RuntimeConf{Concurrency: 1},
})
store.Add(&edgenode.NodeRecord{
ID: "mq-node-2",
Token: "tok-2",
Runtime: config.RuntimeConf{Concurrency: 1},
})
bus := edgeevents.NewBus()
svc := edgeservice.New(reg, bus)
svc.SetNodeStore(store)
var (
mu sync.Mutex
nodeIDs []string
errs []error
)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
res, err := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "test-group",
Adapter: "cli",
Target: "codex",
Background: true,
})
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, err)
return
}
nodeIDs = append(nodeIDs, res.Dispatch().NodeID)
res.Close()
}()
}
wg.Wait()
for _, err := range errs {
t.Fatalf("SubmitRun error: %v", err)
}
if len(nodeIDs) != 2 {
t.Fatalf("expected 2 dispatches, got %d", len(nodeIDs))
}
usedNodes := map[string]int{}
for _, id := range nodeIDs {
usedNodes[id]++
}
if usedNodes["mq-node-1"] != 1 || usedNodes["mq-node-2"] != 1 {
t.Errorf("expected each node used exactly once: %v", usedNodes)
}
}
func TestSubmitRunModelQueueUsesProviderInstancePolicy(t *testing.T) {
parserMap := toki.ParserMap{
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RunRequest{}
return m, proto.Unmarshal(b, m)
},
}
edgeConn, nodeConn := net.Pipe()
defer edgeConn.Close()
defer nodeConn.Close()
edgeClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap)
nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap)
toki.AddListenerTyped[*iop.RunRequest](&nodeClient.Communicator, func(*iop.RunRequest) {})
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "prov-node-1", Client: edgeClient})
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "prov-node-1",
Adapters: config.AdaptersConf{
OllamaInstances: []config.OllamaInstanceConf{
{
Name: "ollama-local",
Enabled: true,
Capacity: 2,
MaxQueue: 3,
},
{
Name: "ollama-remote",
Enabled: true,
Capacity: 5,
MaxQueue: 10,
},
},
},
Runtime: config.RuntimeConf{Concurrency: 1},
})
bus := edgeevents.NewBus()
svc := edgeservice.New(reg, bus)
svc.SetNodeStore(store)
res1, err := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "local-group",
Adapter: "ollama-local",
Background: true,
})
if err != nil {
t.Fatalf("run1 error: %v", err)
}
defer res1.Close()
res2, err := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "local-group",
Adapter: "ollama-local",
Background: true,
})
if err != nil {
t.Fatalf("run2 error: %v", err)
}
defer res2.Close()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err3 := svc.SubmitRun(ctx, edgeservice.SubmitRunRequest{
ModelGroupKey: "local-group",
Adapter: "ollama-local",
Background: true,
})
if err3 == nil {
t.Fatal("expected third run to block and timeout (capacity=2 exceeded)")
}
if !errors.Is(err3, context.DeadlineExceeded) {
t.Errorf("expected deadline exceeded, got: %v", err3)
}
}
func TestSubmitRunModelQueueRejectsAmbiguousProviderType(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "ambig-node-1"})
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "ambig-node-1",
Adapters: config.AdaptersConf{
OllamaInstances: []config.OllamaInstanceConf{
{
Name: "ollama-local",
Enabled: true,
},
{
Name: "ollama-remote",
Enabled: true,
},
},
},
})
bus := edgeevents.NewBus()
svc := edgeservice.New(reg, bus)
svc.SetNodeStore(store)
_, err1 := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
NodeRef: "ambig-node-1",
ModelGroupKey: "ambig-group",
Adapter: "ollama",
Background: true,
})
if err1 == nil {
t.Fatal("expected error for ambiguous adapter type")
}
if !strings.Contains(err1.Error(), "ambiguous") {
t.Errorf("expected ambiguous error, got: %v", err1)
}
_, err2 := svc.SubmitRun(context.Background(), edgeservice.SubmitRunRequest{
ModelGroupKey: "ambig-group",
Adapter: "ollama",
Background: true,
})
if err2 == nil {
t.Fatal("expected error when no candidates support ambiguous adapter type")
}
if !strings.Contains(err2.Error(), "no nodes support") {
t.Errorf("expected no support error, got: %v", err2)
}
}