feat: edge openai handler updates and node provider first config surface
- Modify edge openai chat/stream handlers for priority routing - Update responses_handler and run_result - Add model queue test updates - Add node provider first config surface task - Archive inflight accounting recovery docs
This commit is contained in:
parent
5031bfaab3
commit
90bce8336f
13 changed files with 1028 additions and 97 deletions
|
|
@ -0,0 +1,167 @@
|
|||
<!-- task=inflight-accounting-recovery/02+01_edge_openai_cancel_recovery plan=0 tag=BUG -->
|
||||
|
||||
# Code Review Reference - BUG: Edge OpenAI Cancel Recovery
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-30
|
||||
task=inflight-accounting-recovery/02+01_edge_openai_cancel_recovery, plan=0, tag=BUG
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- 없음. 독립 bug fix task.
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [BUG-2] Edge OpenAI Cancel Propagation And Inflight Recovery | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `openai.runService` 또는 helper interface에서 `CancelRun`을 호출할 수 있게 한다.
|
||||
- [x] OpenAI chat/responses non-streaming path에서 `context.Canceled`, request timeout, run timeout 발생 시 dispatch metadata로 `CancelRun`을 보낸다.
|
||||
- [x] streaming path에서 client disconnect와 stream timeout 발생 시 `CancelRun`을 보낸다.
|
||||
- [x] 정상 `complete`, terminal `error`, terminal `cancelled`를 받은 경우에는 중복 cancel을 보내지 않는다.
|
||||
- [x] `RunHandle.Close()`는 subscription cleanup 역할로 유지하고, 실행 취소는 명시 helper에서만 수행한다.
|
||||
- [x] service queue가 `cancelled` terminal event를 받으면 provider `in_flight`가 0으로 회복되는 regression test를 추가하거나 기존 coverage를 보강한다.
|
||||
- [x] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`를 실행한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
|
||||
- [x] 판정을 append한다.
|
||||
- [x] active plan/review를 `.log`로 아카이브한다.
|
||||
- [x] PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 없음. 계획의 수정 파일 목록, 체크리스트, 테스트 후보를 그대로 구현했다. `apps/edge/internal/service/service_test.go`는 별도 변경이 필요하지 않아 손대지 않았다(`model_queue_test.go`에 `TestModelQueueCancelledTerminalReleasesProviderInflight`를 추가하는 것으로 회귀 커버리지 항목을 충족했다).
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- cancel-worthy 판별을 `apps/edge/internal/openai/run_result.go`의 `isCancelWorthyRunError(err)`로 중앙화했다. `errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errRunTimedOut)`만 true이며, `complete`/`error`/`cancelled` terminal event, `run stream closed`, `node disconnected`에서 나오는 에러는 모두 false다. 이를 통해 "정상 terminal 수신 시 중복 cancel 금지" 요구를 한 곳에서 보장한다.
|
||||
- `collectRunResult`의 timeout 분기가 만들던 `fmt.Errorf("run timed out")`를 패키지 sentinel `errRunTimedOut`으로 바꿔, non-streaming/buffered-stream 경로와 direct-stream 경로(`stream.go`의 `time.After(handle.WaitTimeout())` 분기)가 동일한 에러 값으로 cancel-worthy 여부를 판별하게 했다. 사용자에게 보이는 메시지 문자열("run timed out")은 그대로 유지된다.
|
||||
- `*Server.cancelRunOnHTTPGiveUp(dispatch, err)`를 `server.go`에 단일 helper로 두고 chat non-streaming, responses non-streaming, buffered stream, direct stream의 4개 호출부에서 재사용했다. `CancelRun` 전송 실패는 HTTP 응답 코드/바디를 바꾸지 않고 `s.logger.Warn`으로만 남긴다(계획의 "cancel send 실패는 HTTP 응답을 덮어쓰지 말고 warn log로 확인" 요구 반영). cancel 전송에는 `context.Background()`를 사용한다 — 호출 시점에 원본 요청 context가 이미 cancel/timeout된 상태이므로 그 context를 그대로 넘기면 향후 `Service.CancelRun`이 context를 실제로 사용하도록 바뀔 때 cancel 자체가 즉시 무산될 수 있다.
|
||||
- `RunHandle.Close()`는 기존 그대로 `unregisterRun`/`unregisterNode` cleanup만 수행하도록 손대지 않았다. cancel 전송은 각 handler에서 `defer handle.Close()`와 독립적으로 `cancelRunOnHTTPGiveUp`를 통해서만 일어난다.
|
||||
- direct streaming loop의 `node disconnected`, `run stream closed`, `complete`, `error`/`cancelled` 분기는 의도적으로 cancel을 보내지 않는다: node disconnect는 이미 끊어진 노드라 전송이 무의미하고(Edge는 `releaseNode`로 별도 정리), 나머지는 이미 terminal 상태에 도달했기 때문이다.
|
||||
- `TestStreamChatCompletionTimeoutSendsCancelRun`은 `edgeservice.RunResult` 인터페이스를 직접 구현하는 테스트 전용 `fakeRunResultWithTimeout`(`WaitTimeout()`을 50ms로 고정)을 추가해 실제 `DefaultTimeoutSec` 기반 대기(최소 6초)를 기다리지 않고도 timeout-cancel 경로를 빠르게 검증하도록 했다.
|
||||
- `fakeRunService.SubmitRun`이 반환하던 `RunDispatch`에 `SessionID: req.SessionID`를 추가했다(기존에는 누락되어 있었다). 프로덕션 `dispatchToEntry`/`submitRunQueued`는 항상 `SessionID`를 채우므로, cancel 회귀 테스트가 실제 `CancelRunRequest.SessionID` round-trip을 검증하려면 fake도 이를 반영해야 했다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- HTTP client cancellation과 run timeout에서 `CancelRun`이 실제 dispatch metadata로 전송되는지 확인한다.
|
||||
- `RunHandle.Close()` 의미가 cancel로 바뀌어 정상 완료 요청을 취소하지 않는지 확인한다.
|
||||
- 정상 complete/error/cancelled terminal event에서는 중복 cancel이 없는지 확인한다.
|
||||
- `cancelled` terminal event 뒤 provider snapshot `in_flight`가 0으로 회복되는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### BUG-2 중간 검증
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service
|
||||
ok iop/apps/edge/internal/openai 1.567s
|
||||
ok iop/apps/edge/internal/service 0.440s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/... ./apps/node/...
|
||||
ok iop/apps/edge/cmd/edge 0.045s
|
||||
ok iop/apps/edge/internal/bootstrap 0.290s
|
||||
ok iop/apps/edge/internal/configrefresh 0.016s
|
||||
ok iop/apps/edge/internal/controlplane 4.452s
|
||||
ok iop/apps/edge/internal/edgecmd 0.018s
|
||||
ok iop/apps/edge/internal/edgevalidate 0.004s
|
||||
ok iop/apps/edge/internal/events 0.003s
|
||||
ok iop/apps/edge/internal/input 0.006s
|
||||
ok iop/apps/edge/internal/input/a2a 0.021s
|
||||
ok iop/apps/edge/internal/node 0.005s
|
||||
ok iop/apps/edge/internal/openai 1.565s
|
||||
ok iop/apps/edge/internal/opsconsole 0.007s
|
||||
ok iop/apps/edge/internal/service 0.447s
|
||||
ok iop/apps/edge/internal/transport 2.044s
|
||||
ok iop/apps/node/cmd/node 0.010s
|
||||
ok iop/apps/node/internal/adapters 0.013s
|
||||
ok iop/apps/node/internal/adapters/cli 46.687s
|
||||
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.837s
|
||||
? iop/apps/node/internal/adapters/mock [no test files]
|
||||
ok iop/apps/node/internal/adapters/ollama 0.011s
|
||||
ok iop/apps/node/internal/adapters/openai_compat 0.008s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.009s
|
||||
ok iop/apps/node/internal/bootstrap 0.435s
|
||||
ok iop/apps/node/internal/node 0.719s
|
||||
ok iop/apps/node/internal/router 0.504s
|
||||
? iop/apps/node/internal/runtime [no test files]
|
||||
ok iop/apps/node/internal/store 0.030s
|
||||
ok iop/apps/node/internal/terminal 0.546s
|
||||
ok iop/apps/node/internal/transport 5.343s
|
||||
```
|
||||
|
||||
추가로 확인한 사항:
|
||||
|
||||
```text
|
||||
$ go build ./...
|
||||
(빈 출력 — PASS)
|
||||
|
||||
$ gofmt -l apps/edge/internal/openai/server.go apps/edge/internal/openai/chat_handler.go \
|
||||
apps/edge/internal/openai/responses_handler.go apps/edge/internal/openai/stream.go \
|
||||
apps/edge/internal/openai/run_result.go apps/edge/internal/openai/server_test.go
|
||||
(빈 출력 — PASS)
|
||||
|
||||
$ go vet ./apps/edge/...
|
||||
(빈 출력 — PASS)
|
||||
|
||||
$ git diff --check
|
||||
(빈 출력 — PASS)
|
||||
```
|
||||
|
||||
`apps/edge/internal/service/model_queue_test.go`에서 `gofmt -l`이 한 줄을 보고하지만, 이는 이번 변경과 무관한 891번째 줄 기존 주석 정렬(`git diff`에서 추가분에 포함되지 않음)이라 손대지 않았다.
|
||||
|
||||
### Field 재확인
|
||||
|
||||
```text
|
||||
dev-runtime provider cancellation 재현: 미실행.
|
||||
- provider: (해당 없음 — 이번 세션은 local-G07 구현/단위 검증까지만 수행)
|
||||
- 요청 유형: (해당 없음)
|
||||
- cancellation 방식: (해당 없음)
|
||||
- Control Plane status recovery: (해당 없음)
|
||||
```
|
||||
|
||||
local PASS 확보 후 dev-runtime에서 실제 provider request cancellation을 재현하고 Control Plane status의 해당 provider snapshot이 `in_flight=0`, `queued=0`으로 회복되는지 확인하는 절차는 plan에 명시된 대로 후속 단계로 남아 있다.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- implementation deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 리뷰 중 정리: `apps/edge/internal/service/model_queue_test.go:891`의 기존 gofmt 주석 정렬 drift를 formatting-only로 정리했고, `gofmt -l`, `git diff --check`, `go test -count=1 ./apps/edge/internal/service`를 재확인했다.
|
||||
- 다음 단계: PASS이므로 active plan/review를 `.log`로 아카이브하고 `complete.log` 작성 후 task directory를 `agent-task/archive/2026/07/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/`로 이동한다.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Complete - inflight-accounting-recovery/02+01_edge_openai_cancel_recovery
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-07-01
|
||||
|
||||
## 요약
|
||||
|
||||
Edge OpenAI-compatible cancellation propagation과 provider in-flight recovery bug fix를 1개 리뷰 루프로 완료했다. 최종 판정은 PASS이며 Roadmap Targets는 없음이다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | PASS | HTTP caller cancellation/timeout에서 `CancelRun` 전파, 정상 terminal 중복 cancel 방지, cancelled terminal provider in-flight recovery 검증이 충족됨 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- OpenAI-compatible chat/responses non-streaming, direct streaming, buffered streaming 경로에서 request cancellation 또는 run timeout 시 dispatch metadata 기반 `CancelRun`을 전송한다.
|
||||
- `RunHandle.Close()`는 subscription cleanup으로 유지하고, 실행 취소는 `cancelRunOnHTTPGiveUp` helper에서만 수행하도록 분리했다.
|
||||
- terminal `complete`, `error`, `cancelled`, stream close, node disconnect는 cancel-worthy가 아니도록 `isCancelWorthyRunError` 판별을 중앙화했다.
|
||||
- `cancelled` terminal `RunEvent`가 provider queue의 in-flight accounting을 0으로 회복하는 regression test를 추가했다.
|
||||
- 리뷰 중 `apps/edge/internal/service/model_queue_test.go`의 기존 gofmt 주석 정렬 drift를 formatting-only로 정리했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service` - PASS; `ok iop/apps/edge/internal/openai 1.571s`, `ok iop/apps/edge/internal/service 0.441s`
|
||||
- `go test -count=1 ./apps/edge/... ./apps/node/...` - PASS; Edge 전체와 Node 전체 패키지 통과
|
||||
- `go build ./...` - PASS; 빈 출력
|
||||
- `go vet ./apps/edge/...` - PASS; 빈 출력
|
||||
- `gofmt -l apps/edge/internal/openai/server.go apps/edge/internal/openai/chat_handler.go apps/edge/internal/openai/responses_handler.go apps/edge/internal/openai/stream.go apps/edge/internal/openai/run_result.go apps/edge/internal/openai/server_test.go apps/edge/internal/service/model_queue_test.go` - PASS; 빈 출력
|
||||
- `git diff --check` - PASS; 빈 출력
|
||||
- `go test -count=1 ./apps/edge/internal/service` - PASS; formatting-only 정리 후 `ok iop/apps/edge/internal/service 0.444s`
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- local PASS 이후 dev-runtime에서 실제 provider request cancellation을 재현하고 Control Plane status의 provider snapshot이 `in_flight=0`, `queued=0`으로 회복되는지 확인하는 field 재확인은 별도 runtime 검증 단계로 남긴다.
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<!-- task=inflight-accounting-recovery/02+01_edge_openai_cancel_recovery plan=0 tag=BUG -->
|
||||
|
||||
# Code Review Reference - BUG: Edge OpenAI Cancel Recovery
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-30
|
||||
task=inflight-accounting-recovery/02+01_edge_openai_cancel_recovery, plan=0, tag=BUG
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- 없음. 독립 bug fix task.
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [BUG-2] Edge OpenAI Cancel Propagation And Inflight Recovery | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `openai.runService` 또는 helper interface에서 `CancelRun`을 호출할 수 있게 한다.
|
||||
- [ ] OpenAI chat/responses non-streaming path에서 `context.Canceled`, request timeout, run timeout 발생 시 dispatch metadata로 `CancelRun`을 보낸다.
|
||||
- [ ] streaming path에서 client disconnect와 stream timeout 발생 시 `CancelRun`을 보낸다.
|
||||
- [ ] 정상 `complete`, terminal `error`, terminal `cancelled`를 받은 경우에는 중복 cancel을 보내지 않는다.
|
||||
- [ ] `RunHandle.Close()`는 subscription cleanup 역할로 유지하고, 실행 취소는 명시 helper에서만 수행한다.
|
||||
- [ ] service queue가 `cancelled` terminal event를 받으면 provider `in_flight`가 0으로 회복되는 regression test를 추가하거나 기존 coverage를 보강한다.
|
||||
- [ ] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
|
||||
- [ ] 판정을 append한다.
|
||||
- [ ] active plan/review를 `.log`로 아카이브한다.
|
||||
- [ ] PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- HTTP client cancellation과 run timeout에서 `CancelRun`이 실제 dispatch metadata로 전송되는지 확인한다.
|
||||
- `RunHandle.Close()` 의미가 cancel로 바뀌어 정상 완료 요청을 취소하지 않는지 확인한다.
|
||||
- 정상 complete/error/cancelled terminal event에서는 중복 cancel이 없는지 확인한다.
|
||||
- `cancelled` terminal event 뒤 provider snapshot `in_flight`가 0으로 회복되는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### BUG-2 중간 검증
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/... ./apps/node/...
|
||||
(output)
|
||||
```
|
||||
|
||||
### Field 재확인
|
||||
|
||||
```text
|
||||
dev-runtime provider cancellation 재현:
|
||||
- provider:
|
||||
- 요청 유형:
|
||||
- cancellation 방식:
|
||||
- Control Plane status recovery:
|
||||
```
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
<!-- task=m-node-provider-first-config-surface/07_priority_routing plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close 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 only Milestone lock decisions 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-07-01
|
||||
task=m-node-provider-first-config-surface/07_priority_routing, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
|
||||
- Task ids:
|
||||
- `priority-routing`: Provider-pool dispatch가 dispatch 가능한 후보 중 가장 낮은 `in_flight` provider를 우선하고, `in_flight`가 같은 후보에서만 낮은 숫자의 `priority`를 선택 기준으로 사용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-node-provider-first-config-surface/07_priority_routing/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-node-provider-first-config-surface`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Config Schema And Contract | [ ] |
|
||||
| [API-2] Provider Queue Priority Selection | [ ] |
|
||||
| [API-3] Refresh Classification | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `NodeProviderConf.Priority`를 config schema, contract, `configs/edge.yaml` 예시에 추가하고 기본값 0/음수 거부를 `packages/go/config` tests로 검증한다.
|
||||
- [ ] provider-pool queue candidate에 priority를 전달하고 선택 순서를 `in_flight` 오름차순 -> `priority` 오름차순 -> 기존 rotation으로 바꾸며 service/model queue tests를 갱신한다.
|
||||
- [ ] config refresh classification에서 provider priority-only 변경을 live `applied`로 분류하고 classification tests를 추가한다.
|
||||
- [ ] `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`와 `go test -count=1 ./apps/edge/... ./packages/go/...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
|
||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-node-provider-first-config-surface/07_priority_routing/`를 `agent-task/archive/YYYY/MM/m-node-provider-first-config-surface/07_priority_routing/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-node-provider-first-config-surface`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-node-provider-first-config-surface/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `priority` 기본값 0, 음수 validation error, YAML tags, config example, contract 문구가 서로 일치하는지 확인한다.
|
||||
- provider selection이 capacity ratio가 아니라 absolute `in_flight` count를 먼저 비교하는지 확인한다.
|
||||
- 같은 `in_flight`에서만 낮은 `priority`가 이기고, 같은 `in_flight`/`priority`에서는 기존 rotation이 유지되는지 확인한다.
|
||||
- priority-only config refresh가 `applied`이며 restart_required path를 만들지 않는지 확인한다.
|
||||
- 검증 출력이 `-count=1` fresh execution이며 코드 변경 후 출력인지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### API-1 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./packages/go/config
|
||||
(output)
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/internal/service
|
||||
(output)
|
||||
```
|
||||
|
||||
### API-3 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/internal/configrefresh
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh
|
||||
(output)
|
||||
```
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/... ./packages/go/...
|
||||
(output)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[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.
|
||||
|
||||
Sections and their ownership:
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
|
||||
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
|
||||
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
|
||||
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
|
||||
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only |
|
||||
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
|
||||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation; do not ask the user directly during implementation; environment/secret/service blockers, generic scope changes, and evidence gaps are not user-review requests |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
|
||||
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
|
||||
| 코드리뷰 결과 | Review agent appends | Not included in stub |
|
||||
|
|
@ -0,0 +1,387 @@
|
|||
<!-- task=m-node-provider-first-config-surface/07_priority_routing plan=0 tag=API -->
|
||||
|
||||
# Plan - API
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
구현 완료 전에는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 채운다. 구현자는 아래 검증을 실행하고 active plan/review 파일은 그대로 둔 뒤 review 준비를 보고한다. 종결 처리, log rename, `complete.log` 작성, archive 이동은 code-review-skill 전용이다.
|
||||
|
||||
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 근거, 실행한 명령, 재개 조건을 기록하고 멈춘다. 구현 중 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`, archive log, `complete.log`를 만들지 않는다. 환경/secret/service blocker, 일반 범위 조정, follow-up으로 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 Milestone의 `priority-routing` Task는 provider-pool dispatch 정책을 `in_flight` 우선, 같은 `in_flight` 안에서 `priority` 오름차순 tie-break, 완전 동률이면 기존 rotation 유지로 고정한다. 현재 config schema에는 `nodes[].providers[].priority`가 없고, queue selection은 `in_flight/capacity` 비율을 기준으로 한다. SDD S08은 config contract, validation, model queue tests, refresh classification evidence를 요구한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실구현을 차단할 때만 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 직접 사용자 prompt는 금지이며, code-review가 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
|
||||
- Task ids:
|
||||
- `priority-routing`: Provider-pool dispatch가 dispatch 가능한 후보 중 가장 낮은 `in_flight` provider를 우선하고, `in_flight`가 같은 후보에서만 낮은 숫자의 `priority`를 선택 기준으로 사용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/operational-observability-provider-management/PHASE.md`
|
||||
- `agent-roadmap/phase/operational-observability-provider-management/milestones/node-provider-first-config-surface.md`
|
||||
- `agent-roadmap/sdd/operational-observability-provider-management/node-provider-first-config-surface/SDD.md`
|
||||
- `agent-contract/index.md`
|
||||
- `agent-contract/inner/edge-config-runtime-refresh.md`
|
||||
- `agent-contract/inner/edge-node-runtime-wire.md`
|
||||
- `agent-ops/rules/project/domain/edge/rules.md`
|
||||
- `agent-ops/rules/project/domain/platform-common/rules.md`
|
||||
- `agent-ops/rules/project/domain/testing/rules.md`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/edge-smoke.md`
|
||||
- `agent-test/local/platform-common-smoke.md`
|
||||
- `packages/go/config/config.go`
|
||||
- `packages/go/config/config_test.go`
|
||||
- `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/configrefresh/classify.go`
|
||||
- `apps/edge/internal/configrefresh/classify_test.go`
|
||||
- `configs/edge.yaml`
|
||||
- `.gitignore`
|
||||
|
||||
### SDD 기준
|
||||
|
||||
- SDD: `agent-roadmap/sdd/operational-observability-provider-management/node-provider-first-config-surface/SDD.md`
|
||||
- 상태: `[승인됨]`, SDD 잠금: `해제`, SDD 사용자 리뷰: 없음.
|
||||
- Target Acceptance Scenario: S08, Milestone Task `priority-routing`.
|
||||
- Evidence Map row: S08 requires config contract update, config validation, model queue dispatch order tests, config refresh classification tests.
|
||||
- Checklist/final verification은 S08 row에서 역산했다: `priority` schema/validation, `in_flight` count 우선 queue selection, equal `in_flight` priority tie-break, equal priority rotation, priority refresh `applied` classification을 각각 구현/검증한다.
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- test_env: `local`.
|
||||
- 읽은 env/profile: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/platform-common-smoke.md`.
|
||||
- 적용 명령: local Go quick check. 구현 후 fresh execution이 필요하므로 Go test cache output은 최종 PASS evidence로 인정하지 않는다.
|
||||
- 검증 명령 layout 확인:
|
||||
- `go test ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`: PASS, cached.
|
||||
- `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`: PASS.
|
||||
- `go test -count=1 ./apps/edge/... ./packages/go/...`: PASS.
|
||||
- 테스트 환경 프리플라이트: required verification은 현재 checkout local Go tests 안에서 끝난다. 원격 runner, Docker, field/bootstrap, 외부 provider, shared runtime은 사용하지 않는다.
|
||||
- fallback source: agent-test profiles가 적용 가능하므로 별도 fallback 불필요.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- `nodes[].providers[].priority` unmarshal/default/negative validation: 현재 없음. `packages/go/config/config_test.go`에 추가한다.
|
||||
- provider-pool selection: 현재 `apps/edge/internal/service/model_queue_test.go:499-580`이 `in_flight/capacity` 비율과 비례 filling을 검증한다. S08 정책과 충돌하므로 `in_flight` count 우선과 priority tie-break 테스트로 교체/추가한다.
|
||||
- config refresh classification: capacity/max_queue/queue_timeout/enabled live apply는 있지만 priority-only diff는 없음. `apps/edge/internal/configrefresh/classify_test.go`에 추가한다.
|
||||
- 외부 full-cycle smoke: S08 Evidence Map은 deterministic config/queue/classification tests를 요구한다. 현재 public API는 request별 최종 provider id를 노출하지 않으므로 per-request priority selection은 unit tests로 검증한다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- 새 필드 추가: `NodeProviderConf.Priority`, `candidateNode.priority`.
|
||||
- 제거/교체 후보: `loadRatioLess`, `loadRatioEqual`. `rg --sort path` 결과 call site는 `apps/edge/internal/service/model_queue.go:202`, `apps/edge/internal/service/model_queue.go:206`, 함수 정의 `:228`, `:232`뿐이다.
|
||||
- 테스트 이름 교체 후보: `TestModelQueueProviderLoadRatioSelection`, `TestModelQueueProviderLoadRatioFillsProportionally`. `rg --sort path` 결과 해당 정의는 `apps/edge/internal/service/model_queue_test.go:501`, `:538`뿐이다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- Milestone-linked task group: `agent-task/m-node-provider-first-config-surface/`.
|
||||
- SDD Evidence Map이 이 work item을 `07_priority_routing`로 지정하므로 active task path는 `agent-task/m-node-provider-first-config-surface/07_priority_routing/`다.
|
||||
- `07_priority_routing`는 `+` dependency가 없는 독립 subtask다. predecessor `complete.log` 확인은 필요 없다.
|
||||
- split policy는 평가했다. config schema, service dispatch, refresh classification은 서로 다른 domain을 건드리지만 S08의 단일 runtime behavior를 이루며, 부분 완료 시 contract와 runtime이 불일치한다. 추가 분할은 incomplete evidence를 만들기 쉬워 이 subtask 안의 단일 plan으로 묶는다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- 포함: `packages/go/config`, `apps/edge/internal/service`, `apps/edge/internal/configrefresh`, `configs/edge.yaml`, `agent-contract/inner/edge-config-runtime-refresh.md`.
|
||||
- 제외: `proto/iop/runtime.proto`, `proto/gen/**`, Node adapter runtime, Control Plane/Client, OpenAI response schema. `priority`는 Edge config/dispatch policy이며 Node wire payload 변경이 필요하지 않다.
|
||||
- 제외: dev-runtime/field smoke 변경. 기존 `full-cycle-smoke` Task는 완료되어 있고, S08은 dispatch order를 deterministic tests로 검증한다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- `local-G06`: 변경은 config contract와 Edge dispatch에 걸치지만 범위가 작고, SDD가 정책을 확정했으며 local Go tests로 결정적으로 검증 가능하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `NodeProviderConf.Priority`를 config schema, contract, `configs/edge.yaml` 예시에 추가하고 기본값 0/음수 거부를 `packages/go/config` tests로 검증한다.
|
||||
- [ ] provider-pool queue candidate에 priority를 전달하고 선택 순서를 `in_flight` 오름차순 -> `priority` 오름차순 -> 기존 rotation으로 바꾸며 service/model queue tests를 갱신한다.
|
||||
- [ ] config refresh classification에서 provider priority-only 변경을 live `applied`로 분류하고 classification tests를 추가한다.
|
||||
- [ ] `go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh`와 `go test -count=1 ./apps/edge/... ./packages/go/...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [API-1] Config Schema And Contract
|
||||
|
||||
문제:
|
||||
|
||||
- `packages/go/config/config.go:146-153`에는 capacity/queue fields만 있고 `priority`가 없다.
|
||||
- `packages/go/config/config.go:192-199`는 capacity/max_queue/queue_timeout만 non-negative로 검증한다.
|
||||
- `configs/edge.yaml:96-102`와 `agent-contract/inner/edge-config-runtime-refresh.md:42-45`도 priority contract와 live apply classification을 설명하지 않는다.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// packages/go/config/config.go:146-153
|
||||
// Capacity is the provider/resource maximum concurrent execution slots.
|
||||
// Provider-pool dispatch treats 0 as not dispatchable rather than falling
|
||||
// back to node runtime concurrency metadata.
|
||||
Capacity int `mapstructure:"capacity" yaml:"capacity,omitempty"`
|
||||
// MaxQueue is the maximum queue depth.
|
||||
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue,omitempty"`
|
||||
// QueueTimeoutMS is the queue timeout in milliseconds.
|
||||
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms,omitempty"`
|
||||
```
|
||||
|
||||
```go
|
||||
// packages/go/config/config.go:192-199
|
||||
if p.Capacity < 0 {
|
||||
return fmt.Errorf("nodes[].providers[%q].capacity must be non-negative", id)
|
||||
}
|
||||
if p.MaxQueue < 0 {
|
||||
return fmt.Errorf("nodes[].providers[%q].max_queue must be non-negative", id)
|
||||
}
|
||||
if p.QueueTimeoutMS < 0 {
|
||||
return fmt.Errorf("nodes[].providers[%q].queue_timeout_ms must be non-negative", id)
|
||||
}
|
||||
```
|
||||
|
||||
해결 방법:
|
||||
|
||||
- `NodeProviderConf`에 `Priority int `mapstructure:"priority" yaml:"priority,omitempty"`를 추가한다.
|
||||
- `Validate`에서 `Priority < 0`을 `nodes[].providers[%q].priority must be non-negative`로 reject한다. zero value가 기본값 0이다.
|
||||
- `configs/edge.yaml` provider-pool comments와 sample providers에 priority 설명/예시를 추가한다.
|
||||
- `agent-contract/inner/edge-config-runtime-refresh.md` 핵심 규칙과 refresh 분류 기준에 priority를 추가한다.
|
||||
|
||||
After:
|
||||
|
||||
```go
|
||||
// packages/go/config/config.go
|
||||
Priority int `mapstructure:"priority" yaml:"priority,omitempty"`
|
||||
|
||||
if p.Priority < 0 {
|
||||
return fmt.Errorf("nodes[].providers[%q].priority must be non-negative", id)
|
||||
}
|
||||
```
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `packages/go/config/config.go`: `NodeProviderConf.Priority`와 validation 추가.
|
||||
- [ ] `packages/go/config/config_test.go`: provider-first happy path에서 priority unmarshal을 확인하고, omitted priority 기본값 0과 negative rejection test를 추가한다.
|
||||
- [ ] `configs/edge.yaml`: provider-pool comment와 example에 `priority` 의미를 추가한다.
|
||||
- [ ] `agent-contract/inner/edge-config-runtime-refresh.md`: priority rule과 live apply classification을 추가한다.
|
||||
|
||||
테스트 작성:
|
||||
|
||||
- 작성: `packages/go/config/config_test.go`
|
||||
- 대상: `TestLoadEdge_ProviderFirstNodeProvidersHappyPath`, `TestLoadEdge_NodeProviderConfNegativePriorityRejected` 또는 `TestNodeProviderConf_Validate` subcase.
|
||||
- assertion: priority omitted -> 0, explicit priority loads, negative priority rejects.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
go test -count=1 ./packages/go/config
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### [API-2] Provider Queue Priority Selection
|
||||
|
||||
문제:
|
||||
|
||||
- `apps/edge/internal/service/model_queue.go:28-39`의 `candidateNode`는 priority를 보존하지 않는다.
|
||||
- `apps/edge/internal/service/model_queue.go:182-207`은 가장 낮은 `in_flight/capacity` ratio를 고른다. SDD S08은 capacity ratio가 아니라 dispatch 가능한 후보 중 가장 낮은 absolute `in_flight`를 먼저 고르라고 정한다.
|
||||
- `apps/edge/internal/service/run_dispatch.go:610-616`은 provider config에서 candidate를 만들 때 priority를 전달하지 않는다.
|
||||
- `apps/edge/internal/service/model_queue_test.go:499-580`은 기존 ratio/proportional behavior를 고정한다.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// apps/edge/internal/service/model_queue.go:182-207
|
||||
// findAvailableNodeLocked returns the available candidate with the lowest
|
||||
// in_flight/capacity load ratio.
|
||||
...
|
||||
case len(best) == 0 || loadRatioLess(inflight, c.capacity, bestInflight, bestCapacity):
|
||||
best = []candidateNode{c}
|
||||
bestInflight = inflight
|
||||
bestCapacity = c.capacity
|
||||
case loadRatioEqual(inflight, c.capacity, bestInflight, bestCapacity):
|
||||
best = append(best, c)
|
||||
```
|
||||
|
||||
```go
|
||||
// apps/edge/internal/service/run_dispatch.go:610-616
|
||||
candidates = append(candidates, candidateNode{
|
||||
entry: entry,
|
||||
capacity: cap,
|
||||
providerID: prov.ID,
|
||||
adapter: adapterKey,
|
||||
servedTarget: servedModel,
|
||||
})
|
||||
```
|
||||
|
||||
해결 방법:
|
||||
|
||||
- `candidateNode`에 `priority int`를 추가한다.
|
||||
- `resolveProviderPoolCandidates`에서 `priority: prov.Priority`를 설정한다. legacy non-provider candidates는 zero value 0을 유지한다.
|
||||
- `findAvailableNodeLocked`를 absolute `inflight` 우선 비교로 바꾼다. 같은 `inflight` 안에서 낮은 `priority`를 고르고, `inflight`와 `priority`가 모두 같은 후보만 `best` slice에 넣어 기존 rotation을 유지한다.
|
||||
- `loadRatioLess`/`loadRatioEqual` helper는 더 이상 쓰지 않으면 제거한다.
|
||||
- model queue tests를 S08 중심으로 갱신한다.
|
||||
|
||||
After:
|
||||
|
||||
```go
|
||||
// apps/edge/internal/service/model_queue.go
|
||||
type candidateNode struct {
|
||||
entry *edgenode.NodeEntry
|
||||
capacity int
|
||||
priority int
|
||||
providerID string
|
||||
adapter string
|
||||
servedTarget string
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(best) == 0 || inflight < bestInflight:
|
||||
best = []candidateNode{c}
|
||||
bestInflight = inflight
|
||||
bestPriority = c.priority
|
||||
case inflight == bestInflight:
|
||||
if c.priority < bestPriority {
|
||||
best = []candidateNode{c}
|
||||
bestPriority = c.priority
|
||||
} else if c.priority == bestPriority {
|
||||
best = append(best, c)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `apps/edge/internal/service/model_queue.go`: `candidateNode.priority`, selector comparison, comment 갱신, unused ratio helpers 제거.
|
||||
- [ ] `apps/edge/internal/service/run_dispatch.go`: provider-pool candidate에 `prov.Priority` 전달.
|
||||
- [ ] `apps/edge/internal/service/model_queue_test.go`: ratio/proportional tests를 priority policy tests로 교체하고 existing equal-priority rotation을 보존한다.
|
||||
|
||||
테스트 작성:
|
||||
|
||||
- 작성: `apps/edge/internal/service/model_queue_test.go`
|
||||
- tests:
|
||||
- `TestModelQueueProviderInflightSelectionBeatsPriority`: priority가 낮아도 `in_flight`가 더 높은 후보는 선택되지 않는다.
|
||||
- `TestModelQueueProviderPriorityBreaksEqualInflightTie`: 같은 `in_flight`이면 낮은 priority 후보가 선택된다.
|
||||
- `TestModelQueueProviderEqualInflightPriorityRotates`: `in_flight`와 priority가 모두 같으면 기존 rotation이 유지된다.
|
||||
- candidate resolution test에서 `Priority`가 `candidateNode.priority`로 전달되는지 확인한다.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/internal/service
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### [API-3] Refresh Classification
|
||||
|
||||
문제:
|
||||
|
||||
- `apps/edge/internal/configrefresh/classify.go:81-111`의 provider diff key에는 priority가 없다.
|
||||
- `apps/edge/internal/configrefresh/classify.go:316-339`는 capacity/max_queue/queue_timeout만 `StatusApplied`로 분류한다.
|
||||
- `apps/edge/internal/configrefresh/classify_test.go:94-150`은 capacity applied만 검증하고 priority applied test가 없다.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// apps/edge/internal/configrefresh/classify.go:81-91
|
||||
type providerKey struct {
|
||||
NodeKey string
|
||||
Type string
|
||||
Category config.Category
|
||||
Adapter string
|
||||
Models []string
|
||||
Health string
|
||||
Capacity int
|
||||
MaxQueue int
|
||||
QueueTimeoutMS int
|
||||
```
|
||||
|
||||
```go
|
||||
// apps/edge/internal/configrefresh/classify.go:316-339
|
||||
if cur.Capacity != cp.Capacity { ... StatusApplied ... }
|
||||
if cur.MaxQueue != cp.MaxQueue { ... StatusApplied ... }
|
||||
if cur.QueueTimeoutMS != cp.QueueTimeoutMS { ... StatusApplied ... }
|
||||
```
|
||||
|
||||
해결 방법:
|
||||
|
||||
- `providerKey`에 `Priority int`를 추가하고 `buildProviderIndex`에서 `p.Priority`를 복사한다.
|
||||
- priority diff는 `nodes[].providers[%q].priority`, `StatusApplied`로 추가한다.
|
||||
- contract의 live apply list와 test assertion을 맞춘다.
|
||||
|
||||
After:
|
||||
|
||||
```go
|
||||
// apps/edge/internal/configrefresh/classify.go
|
||||
Priority int
|
||||
|
||||
if cur.Priority != cp.Priority {
|
||||
changes = append(changes, Change{
|
||||
Path: fmt.Sprintf("nodes[].providers[%q].priority", provID),
|
||||
Class: StatusApplied,
|
||||
Previous: fmt.Sprintf("%d", cur.Priority),
|
||||
Next: fmt.Sprintf("%d", cp.Priority),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `apps/edge/internal/configrefresh/classify.go`: priority diff를 `StatusApplied`로 분류.
|
||||
- [ ] `apps/edge/internal/configrefresh/classify_test.go`: priority-only diff가 applied이고 restart_required가 없음을 검증.
|
||||
- [ ] `agent-contract/inner/edge-config-runtime-refresh.md`: API-1 contract update와 classification wording 일치 확인.
|
||||
|
||||
테스트 작성:
|
||||
|
||||
- 작성: `apps/edge/internal/configrefresh/classify_test.go`
|
||||
- test: `TestClassifyProviderPriorityApplied`
|
||||
- assertion: path `nodes[].providers["prov-a"].priority`, class `applied`, previous/next value, no `restart_required`.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/internal/configrefresh
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `packages/go/config/config.go` | API-1 |
|
||||
| `packages/go/config/config_test.go` | API-1 |
|
||||
| `configs/edge.yaml` | API-1 |
|
||||
| `agent-contract/inner/edge-config-runtime-refresh.md` | API-1, API-3 |
|
||||
| `apps/edge/internal/service/model_queue.go` | API-2 |
|
||||
| `apps/edge/internal/service/run_dispatch.go` | API-2 |
|
||||
| `apps/edge/internal/service/model_queue_test.go` | API-2 |
|
||||
| `apps/edge/internal/configrefresh/classify.go` | API-3 |
|
||||
| `apps/edge/internal/configrefresh/classify_test.go` | API-3 |
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
1. API-1 schema/contract를 먼저 추가한다.
|
||||
2. API-2 dispatch candidate와 selection tests를 갱신한다.
|
||||
3. API-3 refresh classification을 schema 필드에 맞춰 추가한다.
|
||||
4. 최종 검증 후 review stub을 채운다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/configrefresh
|
||||
```
|
||||
|
||||
Expected: PASS. Config validation, queue dispatch order, refresh classification targeted evidence.
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/... ./packages/go/...
|
||||
```
|
||||
|
||||
Expected: PASS. Edge/platform-common regression. Go test cache output은 최종 PASS evidence로 사용하지 않는다.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -161,6 +161,7 @@ func shouldSynthesizeTextToolCalls(dispatch edgeservice.RunDispatch, textToolFal
|
|||
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
|
||||
text, reasoning, finishReason, nativeToolCalls, usage, textToolFallback, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
if err != nil {
|
||||
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err)
|
||||
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ func decodeResponsesRequest(dec *json.Decoder, req *responsesRequest) error {
|
|||
func (s *Server) completeResponse(w http.ResponseWriter, r *http.Request, req responsesRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
|
||||
text, reasoning, _, _, usage, _, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
if err != nil {
|
||||
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err)
|
||||
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package openai
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -15,6 +16,20 @@ const (
|
|||
runtimeMetadataOpenAITextToolFallback = "openai_text_tool_fallback"
|
||||
)
|
||||
|
||||
// errRunTimedOut is returned by collectRunResult and the direct streaming
|
||||
// loop when handle.WaitTimeout() elapses without a terminal run event.
|
||||
var errRunTimedOut = errors.New("run timed out")
|
||||
|
||||
// isCancelWorthyRunError reports whether err means the HTTP caller gave up
|
||||
// (context cancellation/deadline or a WaitTimeout expiry) before the run
|
||||
// reached a terminal state, so Edge should propagate CancelRun to Node.
|
||||
// Terminal run outcomes (complete/error/cancelled events, closed stream,
|
||||
// node disconnect) are not cancel-worthy: Node already reached a terminal
|
||||
// state or is unreachable, so sending CancelRun would be redundant or futile.
|
||||
func isCancelWorthyRunError(err error) bool {
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errRunTimedOut)
|
||||
}
|
||||
|
||||
func collectRunResult(ctx context.Context, stream edgeservice.RunStream, timeout time.Duration) (string, string, string, []any, *openAIUsage, bool, error) {
|
||||
if stream.Events == nil {
|
||||
return "", "", "", nil, nil, false, fmt.Errorf("run stream unavailable")
|
||||
|
|
@ -30,7 +45,7 @@ func collectRunResult(ctx context.Context, stream edgeservice.RunStream, timeout
|
|||
case <-ctx.Done():
|
||||
return "", "", "", nil, nil, false, ctx.Err()
|
||||
case <-timer.C:
|
||||
return "", "", "", nil, nil, false, fmt.Errorf("run timed out")
|
||||
return "", "", "", nil, nil, false, errRunTimedOut
|
||||
case nodeEvent, ok := <-stream.NodeEvents:
|
||||
if !ok {
|
||||
stream.NodeEvents = nil
|
||||
|
|
|
|||
|
|
@ -18,6 +18,28 @@ import (
|
|||
type runService interface {
|
||||
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (edgeservice.RunResult, error)
|
||||
OllamaAPI(context.Context, edgeservice.OllamaAPIRequest) (edgeservice.OllamaAPIView, error)
|
||||
CancelRun(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
|
||||
}
|
||||
|
||||
// cancelRunOnHTTPGiveUp sends CancelRun to Node when the HTTP caller gave up
|
||||
// (request cancellation/timeout) before the run reached a terminal state.
|
||||
// Terminal run outcomes are not cancel-worthy; see isCancelWorthyRunError.
|
||||
func (s *Server) cancelRunOnHTTPGiveUp(dispatch edgeservice.RunDispatch, err error) {
|
||||
if !isCancelWorthyRunError(err) || dispatch.RunID == "" {
|
||||
return
|
||||
}
|
||||
if _, cancelErr := s.service.CancelRun(context.Background(), edgeservice.CancelRunRequest{
|
||||
NodeRef: dispatch.NodeID,
|
||||
RunID: dispatch.RunID,
|
||||
Adapter: dispatch.Adapter,
|
||||
Target: dispatch.Target,
|
||||
SessionID: dispatch.SessionID,
|
||||
}); cancelErr != nil {
|
||||
s.logger.Warn("openai cancel run failed",
|
||||
zap.String("run_id", dispatch.RunID),
|
||||
zap.Error(cancelErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -20,6 +21,26 @@ type fakeRunService struct {
|
|||
ollamaReq edgeservice.OllamaAPIRequest
|
||||
ollamaResp edgeservice.OllamaAPIView
|
||||
events chan *iop.RunEvent
|
||||
|
||||
cancelMu sync.Mutex
|
||||
cancelCalls []edgeservice.CancelRunRequest
|
||||
cancelErr error
|
||||
}
|
||||
|
||||
func (s *fakeRunService) CancelRun(_ context.Context, req edgeservice.CancelRunRequest) (edgeservice.CommandResult, error) {
|
||||
s.cancelMu.Lock()
|
||||
s.cancelCalls = append(s.cancelCalls, req)
|
||||
s.cancelMu.Unlock()
|
||||
if s.cancelErr != nil {
|
||||
return edgeservice.CommandResult{}, s.cancelErr
|
||||
}
|
||||
return edgeservice.CommandResult{NodeID: req.NodeRef, SessionID: req.SessionID}, nil
|
||||
}
|
||||
|
||||
func (s *fakeRunService) cancelCallsSnapshot() []edgeservice.CancelRunRequest {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
return append([]edgeservice.CancelRunRequest(nil), s.cancelCalls...)
|
||||
}
|
||||
|
||||
func TestRoutesRequireBearerTokenWhenConfigured(t *testing.T) {
|
||||
|
|
@ -73,6 +94,7 @@ func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunR
|
|||
ModelGroupKey: req.ModelGroupKey,
|
||||
Adapter: req.Adapter,
|
||||
Target: req.Target,
|
||||
SessionID: req.SessionID,
|
||||
TimeoutSec: 5,
|
||||
},
|
||||
RunStream: edgeservice.RunStream{
|
||||
|
|
@ -2057,6 +2079,169 @@ func TestCollectRunResultTimesOut(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// fakeRunResultWithTimeout implements edgeservice.RunResult with a directly
|
||||
// configurable WaitTimeout, so tests can exercise the run-timeout cancel path
|
||||
// without waiting out the real DefaultTimeoutSec-derived duration.
|
||||
type fakeRunResultWithTimeout struct {
|
||||
dispatch edgeservice.RunDispatch
|
||||
stream edgeservice.RunStream
|
||||
waitTimeout time.Duration
|
||||
}
|
||||
|
||||
func (f *fakeRunResultWithTimeout) Dispatch() edgeservice.RunDispatch { return f.dispatch }
|
||||
func (f *fakeRunResultWithTimeout) Stream() edgeservice.RunStream { return f.stream }
|
||||
func (f *fakeRunResultWithTimeout) Close() {}
|
||||
func (f *fakeRunResultWithTimeout) WaitTimeout() time.Duration { return f.waitTimeout }
|
||||
|
||||
func TestChatCompletionContextCancelSendsCancelRun(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
||||
srv := NewServer(config.EdgeOpenAIConf{
|
||||
Adapter: "ollama",
|
||||
Target: "llama-fixed",
|
||||
SessionID: "cline",
|
||||
TimeoutSec: 15,
|
||||
}, fake, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"client-model",
|
||||
"messages":[{"role":"user","content":"hi"}]
|
||||
}`)).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusRequestTimeout {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
calls := fake.cancelCallsSnapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].RunID != "run-test" || calls[0].Adapter != "ollama" || calls[0].Target != "llama-fixed" || calls[0].SessionID != "cline" {
|
||||
t.Fatalf("unexpected cancel request: %+v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesContextCancelSendsCancelRun(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
||||
srv := NewServer(config.EdgeOpenAIConf{
|
||||
Adapter: "ollama",
|
||||
Target: "llama-fixed",
|
||||
SessionID: "cline",
|
||||
TimeoutSec: 15,
|
||||
}, fake, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
|
||||
"model":"client-model",
|
||||
"input":"say hello"
|
||||
}`)).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleResponses(w, req)
|
||||
|
||||
if w.Code != http.StatusRequestTimeout {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
calls := fake.cancelCallsSnapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].RunID != "run-test" || calls[0].Adapter != "ollama" || calls[0].Target != "llama-fixed" || calls[0].SessionID != "cline" {
|
||||
t.Fatalf("unexpected cancel request: %+v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamChatCompletionContextCancelSendsCancelRun(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
||||
srv := NewServer(config.EdgeOpenAIConf{
|
||||
Adapter: "ollama",
|
||||
Target: "llama-fixed",
|
||||
SessionID: "cline",
|
||||
TimeoutSec: 15,
|
||||
}, fake, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"client-model",
|
||||
"stream":true,
|
||||
"messages":[{"role":"user","content":"hi"}]
|
||||
}`)).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
||||
calls := fake.cancelCallsSnapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].RunID != "run-test" || calls[0].Adapter != "ollama" || calls[0].Target != "llama-fixed" || calls[0].SessionID != "cline" {
|
||||
t.Fatalf("unexpected cancel request: %+v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamChatCompletionTimeoutSendsCancelRun(t *testing.T) {
|
||||
fake := &fakeRunService{}
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
||||
|
||||
handle := &fakeRunResultWithTimeout{
|
||||
dispatch: edgeservice.RunDispatch{
|
||||
RunID: "run-timeout",
|
||||
NodeID: "node-1",
|
||||
Adapter: "ollama",
|
||||
Target: "llama3",
|
||||
SessionID: "cline",
|
||||
},
|
||||
stream: edgeservice.RunStream{
|
||||
Events: make(chan *iop.RunEvent),
|
||||
NodeEvents: make(chan *iop.EdgeNodeEvent),
|
||||
},
|
||||
waitTimeout: 50 * time.Millisecond,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.streamChatCompletion(w, req, chatCompletionRequest{Model: "llama3"}, handle, strictOutputPolicy{})
|
||||
|
||||
calls := fake.cancelCallsSnapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].RunID != "run-timeout" || calls[0].NodeRef != "node-1" {
|
||||
t.Fatalf("unexpected cancel request: %+v", calls[0])
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "run timed out") {
|
||||
t.Fatalf("expected timeout SSE error, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionCompleteDoesNotSendCancelRun(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama-fixed"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"client-model",
|
||||
"messages":[{"role":"user","content":"hi"}]
|
||||
}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if calls := fake.cancelCallsSnapshot(); len(calls) != 0 {
|
||||
t.Fatalf("expected no CancelRun calls for a normal completion, got %d: %+v", len(calls), calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsExposesCatalogRouteModels(t *testing.T) {
|
||||
srv := NewServer(config.EdgeOpenAIConf{
|
||||
ModelRoutes: []config.OpenAIRouteEntry{
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
|
|||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), r.Context().Err())
|
||||
return
|
||||
case nodeEvent, ok := <-stream.NodeEvents:
|
||||
if !ok {
|
||||
|
|
@ -183,7 +184,8 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
|
|||
return
|
||||
}
|
||||
case <-time.After(handle.WaitTimeout()):
|
||||
writeSSEError(w, flusher, "run timed out")
|
||||
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), errRunTimedOut)
|
||||
writeSSEError(w, flusher, errRunTimedOut.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -302,6 +304,7 @@ func writeToolCallsDeltaSSE(w http.ResponseWriter, flusher http.Flusher, id stri
|
|||
func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, handle edgeservice.RunResult, id string, created int64, model string, flusher http.Flusher, outputPolicy strictOutputPolicy, tools []any) {
|
||||
text, reasoning, finishReason, nativeToolCalls, _, textToolFallback, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
if err != nil {
|
||||
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err)
|
||||
writeSSEError(w, flusher, err.Error())
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -670,6 +670,44 @@ func TestProviderStatusInflightTracking(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestModelQueueCancelledTerminalReleasesProviderInflight verifies that a
|
||||
// cancelled terminal RunEvent releases provider in-flight accounting back to
|
||||
// zero, the same as complete/error. This guards the BUG-2 recovery contract:
|
||||
// once Edge OpenAI cancel propagation reaches Node and Node replies with a
|
||||
// cancelled terminal event, the provider snapshot's in_flight must not stay
|
||||
// stuck positive.
|
||||
func TestModelQueueCancelledTerminalReleasesProviderInflight(t *testing.T) {
|
||||
m := newModelQueueManager(nil)
|
||||
bus := edgeevents.NewBus()
|
||||
stop := m.startEventWatcher(bus)
|
||||
defer stop()
|
||||
|
||||
m.trackInflight("g-cancel", "run-cancel-1", "node-x", "prov-1")
|
||||
|
||||
m.mu.Lock()
|
||||
inf, _ := m.getStatsForProviderLocked("node-x", "prov-1")
|
||||
m.mu.Unlock()
|
||||
if inf != 1 {
|
||||
t.Fatalf("inflight before cancel: got %d, want 1", inf)
|
||||
}
|
||||
|
||||
bus.PublishRun(&iop.RunEvent{RunId: "run-cancel-1", Type: "cancelled"})
|
||||
|
||||
deadline := time.Now().Add(200 * time.Millisecond)
|
||||
for {
|
||||
m.mu.Lock()
|
||||
inf, _ = m.getStatsForProviderLocked("node-x", "prov-1")
|
||||
m.mu.Unlock()
|
||||
if inf == 0 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("inflight did not recover to 0 after cancelled terminal event, got %d", inf)
|
||||
}
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelQueueContextCancelRemovesQueuedItem verifies that cancelling the
|
||||
// context of a queued admit removes the item and returns context.Canceled.
|
||||
func TestModelQueueContextCancelRemovesQueuedItem(t *testing.T) {
|
||||
|
|
@ -853,7 +891,7 @@ func TestResolveProviderPoolCandidatesFiltersInvalidProviders(t *testing.T) {
|
|||
{
|
||||
ID: "prov-no-adapter",
|
||||
Type: "vllm",
|
||||
Adapter: "", // provider-first: adapter key derived from ID
|
||||
Adapter: "", // provider-first: adapter key derived from ID
|
||||
Models: []string{"served-qwen"},
|
||||
Health: "available",
|
||||
Capacity: 2,
|
||||
|
|
|
|||
Loading…
Reference in a new issue