<!-- 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/`로 이동한다.
