diff --git a/agent-task/archive/2026/07/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/code_review_local_G07_0.log b/agent-task/archive/2026/07/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/code_review_local_G07_0.log new file mode 100644 index 0000000..8b9b0ac --- /dev/null +++ b/agent-task/archive/2026/07/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/code_review_local_G07_0.log @@ -0,0 +1,167 @@ + + +# 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/`로 이동한다. diff --git a/agent-task/archive/2026/07/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/complete.log b/agent-task/archive/2026/07/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/complete.log new file mode 100644 index 0000000..b983d86 --- /dev/null +++ b/agent-task/archive/2026/07/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/complete.log @@ -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 검증 단계로 남긴다. diff --git a/agent-task/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/PLAN-local-G07.md b/agent-task/archive/2026/07/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/plan_local_G07_0.log similarity index 100% rename from agent-task/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/PLAN-local-G07.md rename to agent-task/archive/2026/07/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/plan_local_G07_0.log diff --git a/agent-task/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/CODE_REVIEW-local-G07.md b/agent-task/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/CODE_REVIEW-local-G07.md deleted file mode 100644 index 974cf42..0000000 --- a/agent-task/inflight-accounting-recovery/02+01_edge_openai_cancel_recovery/CODE_REVIEW-local-G07.md +++ /dev/null @@ -1,94 +0,0 @@ - - -# 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: -``` diff --git a/agent-task/m-node-provider-first-config-surface/07_priority_routing/CODE_REVIEW-local-G06.md b/agent-task/m-node-provider-first-config-surface/07_priority_routing/CODE_REVIEW-local-G06.md new file mode 100644 index 0000000..5423b36 --- /dev/null +++ b/agent-task/m-node-provider-first-config-surface/07_priority_routing/CODE_REVIEW-local-G06.md @@ -0,0 +1,165 @@ + + +# 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 | diff --git a/agent-task/m-node-provider-first-config-surface/07_priority_routing/PLAN-local-G06.md b/agent-task/m-node-provider-first-config-surface/07_priority_routing/PLAN-local-G06.md new file mode 100644 index 0000000..f23405f --- /dev/null +++ b/agent-task/m-node-provider-first-config-surface/07_priority_routing/PLAN-local-G06.md @@ -0,0 +1,387 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index eecdc1b..4e5acc7 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -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 } diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go index 2d3151b..fd5cf57 100644 --- a/apps/edge/internal/openai/responses_handler.go +++ b/apps/edge/internal/openai/responses_handler.go @@ -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 } diff --git a/apps/edge/internal/openai/run_result.go b/apps/edge/internal/openai/run_result.go index 0cf866f..0513d83 100644 --- a/apps/edge/internal/openai/run_result.go +++ b/apps/edge/internal/openai/run_result.go @@ -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 diff --git a/apps/edge/internal/openai/server.go b/apps/edge/internal/openai/server.go index 0e1d82a..6087862 100644 --- a/apps/edge/internal/openai/server.go +++ b/apps/edge/internal/openai/server.go @@ -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 { diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index dd6e4c6..99d950e 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -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{ diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go index 84611ae..c0ae293 100644 --- a/apps/edge/internal/openai/stream.go +++ b/apps/edge/internal/openai/stream.go @@ -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 } diff --git a/apps/edge/internal/service/model_queue_test.go b/apps/edge/internal/service/model_queue_test.go index c933936..dae8b5e 100644 --- a/apps/edge/internal/service/model_queue_test.go +++ b/apps/edge/internal/service/model_queue_test.go @@ -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,