<!-- task=inflight-accounting-recovery/02+01_edge_openai_cancel_recovery plan=0 tag=BUG -->

# Plan - BUG: Edge OpenAI Cancel Recovery

## 이 파일을 읽는 구현 에이전트에게

구현 완료는 `CODE_REVIEW-local-G07.md` 작성 후 review-ready 보고까지다. review/finalization은 code-review 전용이다. 이 subtask는 `01_node_terminal_events/complete.log`가 만들어진 뒤 구현한다.

## 배경

Edge provider queue는 terminal `RunEvent`를 받아야 `inflightByRun`을 release한다. OpenAI-compatible HTTP 표면은 client disconnect, request context cancellation, timeout에서 `RunHandle.Close()`만 실행하고 실제 Node cancel을 보내지 않는 경로가 있다. 이 경우 Node/provider 실행은 계속 남거나 terminal event가 늦어져 provider snapshot의 `in_flight`가 stuck될 수 있다.

## 사용자 리뷰 요청 흐름

별도 bug task의 두 번째 subtask다. 현재 milestone split에 끼워 넣지 않는다. 구현 중 새 결정이 필요하면 code review stub의 `사용자 리뷰 요청` 섹션에만 기록한다.

## Roadmap Targets

- 없음. 원격 field 검증에서 발견된 독립 bug fix task다.

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/service/service_test.go`
- `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/node/internal/node/node.go`

### SDD 기준

해당 없음. 기존 SDD task에 끼워 넣지 않고, runtime accounting/cancel propagation bug fix로 별도 추적한다.

### 테스트 환경 규칙

`test_env=local`. Edge OpenAI-compatible HTTP 표면과 service queue accounting을 바꾸므로 `agent-test/local/edge-smoke.md`를 적용한다. Node cancel terminal guarantee는 `01_node_terminal_events`에서 먼저 닫고, 이 task는 Edge가 cancel을 보냈는지와 terminal event 이후 queue accounting이 회복되는지를 검증한다.

### 테스트 커버리지 공백

- `apps/edge/internal/openai/chat_handler.go:92`와 `apps/edge/internal/openai/responses_handler.go:106`의 `defer handle.Close()`는 event subscription cleanup일 뿐 Node 실행 취소가 아니다.
- `apps/edge/internal/openai/stream.go:71`은 HTTP request context가 끝나면 stream loop를 return하지만 `CancelRun`을 호출하지 않는다.
- `apps/edge/internal/openai/stream.go:185`와 `apps/edge/internal/openai/run_result.go:32` timeout 경로도 Node cancel 없이 HTTP error만 반환한다.
- `apps/edge/internal/service/run_dispatch.go:702`에는 `CancelRun`이 있으나 OpenAI server의 `runService` interface(`apps/edge/internal/openai/server.go:18`)에는 포함되어 있지 않다.
- `apps/edge/internal/service/model_queue.go:349`에서 track된 in-flight는 terminal event, send error, node disconnect 없이는 release되지 않는다.

### 심볼 참조

`openai.runService` interface에 `CancelRun`을 포함하거나, 동등한 cancel-capable helper interface를 추가한다. `RunHandle.Close()`의 의미를 암묵 cancel로 바꾸지 않는다.

### 분할 판단

이 subtask는 Edge/OpenAI 표면의 cancel propagation과 queue accounting regression test만 다룬다. Node가 terminal event를 보장하는 선행 계약은 `01_node_terminal_events`에서 구현한다.

### 범위 결정 근거

provider-specific vLLM/MLX stall detector, long-running heartbeat idle timeout 정책, Control Plane status schema 변경은 제외한다. 이 task는 HTTP surface cancellation과 Edge queue release 관찰성 보강에 집중한다.

### 빌드 등급

`local-G07`: OpenAI HTTP handler, streaming loop, Edge service cancel API, provider queue accounting test가 함께 얽힌 cross-package 변경이다.

## 의존 관계 및 구현 순서

이 subtask는 디렉터리명 `02+01_edge_openai_cancel_recovery`에 따라 같은 task group의 `01_node_terminal_events`가 `complete.log`를 만든 뒤 구현한다.

## 구현 체크리스트

- [ ] `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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [BUG-2] Edge OpenAI Cancel Propagation And Inflight Recovery

#### 문제

OpenAI-compatible handler는 foreground RunResult를 받은 뒤 HTTP context cancellation이나 timeout을 만나도 `CancelRun`을 보내지 않는다. `RunHandle.Close()`는 `unregisterRun`/`unregisterNode` cleanup만 수행하므로 Node/provider 실행이 계속될 수 있고, Edge queue manager는 terminal event를 받지 못하면 `inflightByRun`을 유지한다. 이 때문에 observed provider status가 음수가 되지는 않지만, 양수 stuck 상태가 될 수 있다.

#### 해결 방법

`RunDispatch` metadata(`RunID`, `NodeID`, `Adapter`, `Target`, `SessionID`)로 `edgeservice.CancelRunRequest`를 구성하는 OpenAI server helper를 추가한다. non-streaming `collectRunResult` error와 streaming loop exit reason 중 client cancellation/timeout에 대해서만 cancel을 전송한다. cancel send 실패는 HTTP 응답을 덮어쓰지 말고 warn log와 test-observable fake field로 확인한다. Node가 `cancelled` terminal event를 보내면 기존 queue watcher가 release한다.

#### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/server.go`: cancel-capable service interface/helper 추가.
- [ ] `apps/edge/internal/openai/chat_handler.go`: complete path cancel-on-context/timeout 처리.
- [ ] `apps/edge/internal/openai/responses_handler.go`: responses path cancel-on-context/timeout 처리.
- [ ] `apps/edge/internal/openai/stream.go`: direct stream과 buffered stream cancel-on-context/timeout 처리.
- [ ] `apps/edge/internal/openai/run_result.go`: cancel-worthy error 판별 helper를 둘 위치 검토.
- [ ] `apps/edge/internal/openai/server_test.go`: fake service에 `CancelRun` 기록 필드 추가, chat/responses/stream cancellation regression tests 추가.
- [ ] `apps/edge/internal/service/model_queue_test.go` 또는 `service_test.go`: `cancelled` terminal event 후 provider snapshot `in_flight=0` 회복 보강.

#### 테스트 작성

작성한다. 최소 테스트 후보:

- `TestChatCompletionContextCancelSendsCancelRun`
- `TestResponsesContextCancelSendsCancelRun`
- `TestStreamChatCompletionContextCancelSendsCancelRun`
- `TestStreamChatCompletionTimeoutSendsCancelRun`
- `TestChatCompletionCompleteDoesNotSendCancelRun`
- `TestModelQueueCancelledTerminalReleasesProviderInflight`

#### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service
```

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/server.go` | BUG-2 |
| `apps/edge/internal/openai/chat_handler.go` | BUG-2 |
| `apps/edge/internal/openai/responses_handler.go` | BUG-2 |
| `apps/edge/internal/openai/stream.go` | BUG-2 |
| `apps/edge/internal/openai/run_result.go` | BUG-2 |
| `apps/edge/internal/openai/server_test.go` | BUG-2 |
| `apps/edge/internal/service/model_queue_test.go` | BUG-2 |
| `apps/edge/internal/service/service_test.go` | BUG-2 |

## 최종 검증

```bash
go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service
```

```bash
go test -count=1 ./apps/edge/... ./apps/node/...
```

field 재확인은 local PASS 이후 dev-runtime에서 provider request cancellation을 재현하고 Control Plane status의 해당 provider snapshot이 `in_flight=0`, `queued=0`으로 회복되는지 확인한다. token/secret 원문은 tracked 파일에 남기지 않는다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
