<!-- task=inflight-accounting-recovery/01_node_terminal_events plan=0 tag=G06 -->

# Plan - G06: Node Terminal Run Event Guarantee

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

구현 완료는 `CODE_REVIEW-local-G06.md` 작성 후 review-ready 보고까지다. review/finalization은 code-review 전용이다.

## 배경

dev-runtime Mac MLX provider 확인 중 Control Plane status에서 `mac-mlx-vllm` provider의 `in_flight=1`, `queued=0`이 요청 중단 이후에도 남는 현상이 관찰됐다. Edge queue accounting은 terminal `RunEvent`를 받아야 provider slot을 해제한다. 현재 Node는 일부 에러/종료 경로에서 store status만 갱신하고 terminal `RunEvent`를 보장하지 않아 Edge의 positive in-flight leak를 만들 수 있다.

## 사용자 리뷰 요청 흐름

별도 bug task로 처리한다. 현재 `m-node-provider-first-config-surface` 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/node/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/sink_test.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/node/node_concurrency_integration_test.go`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`
- `apps/node/internal/adapters/openai_compat/openai_compat_test.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/run_dispatch.go`

### SDD 기준

해당 없음. 기존 SDD task에 끼워 넣지 않고, 런타임 accounting bug fix로 별도 추적한다.

### 테스트 환경 규칙

`test_env=local`. Node 실행 파이프라인과 adapter terminal event 계약을 바꾸므로 `agent-test/local/node-smoke.md` 기준을 적용한다. 기본 검증은 targeted unit/integration test이고, 최종 보강 검증은 `go test -count=1 ./apps/node/...`다.

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

- `apps/node/internal/node/node.go:107`의 `ResolveAdapter` 실패는 `RunEvent` 없이 error return만 한다.
- `apps/node/internal/node/node.go:189` 이후 adapter가 terminal event를 내지 않고 반환해도 `completeRun`은 store status만 갱신한다.
- `apps/node/internal/node/node.go:268`의 `completeRun`은 Edge로 terminal event를 보내지 않는다.
- `apps/node/internal/node/node.go:679`의 `terminalDeferringSink`는 terminal event 지연만 담당하고, terminal event가 없을 때 합성하는 보장은 없다.
- 기존 test는 reject/admission error event와 store status 중심이며, "adapter가 terminal event 없이 성공/실패/취소 반환"하는 Edge-observable terminal event 보장은 부족하다.

### 심볼 참조

rename/remove 없음. Node 내부 helper 추가와 test double 보강만 사용한다.

### 분할 판단

이 task는 Node가 RunRequest lifecycle의 terminal event를 보장하는 계약만 다룬다. Edge/OpenAI request cancellation 전파는 `02+01_edge_openai_cancel_recovery`에서 별도로 구현한다.

### 범위 결정 근거

OpenAI-compatible adapter의 streaming parser나 provider-specific timeout 정책은 바꾸지 않는다. 이 plan은 Node가 어떤 adapter를 실행하든 RunRequest당 terminal event가 최대 1회, 누락 없이 관측되도록 하는 범위로 제한한다.

### 빌드 등급

`local-G06`: Node execution lifecycle과 terminal event contract를 변경한다. transport integration test까지 필요하지만 wire schema 변경은 없다.

## 의존 관계 및 구현 순서

선행 task 없음. 이 task가 PASS 되어 `complete.log`가 만들어진 뒤 `02+01_edge_openai_cancel_recovery`를 구현한다.

## 구현 체크리스트

- [x] Node run execution 경로에서 terminal runtime event 발생 여부를 추적하는 helper를 추가한다.
- [x] adapter가 terminal event 없이 `nil`을 반환하면 `complete` event를 합성한다.
- [x] adapter가 terminal event 없이 error를 반환하면 `error` 또는 `cancelled` event를 합성한다.
- [x] `ResolveAdapter` 실패처럼 adapter 실행 전 실패하는 foreground RunRequest에도 `RunEvent{type:"error"}`를 보낸다.
- [x] adapter가 이미 `complete`, `error`, `cancelled`를 emitted한 경우 중복 terminal event가 발생하지 않도록 테스트한다.
- [x] terminal event flush 순서는 기존 over-dispatch safety 의도대로 local ticket release 이후가 되도록 유지한다.
- [x] `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [BUG-1] Node Terminal Run Event Guarantee

#### 문제

Edge queue는 `complete`, `error`, `cancelled` RunEvent를 terminal로 보고 slot을 해제한다(`apps/edge/internal/service/model_queue.go:137`). 그러나 Node는 adapter 실행 결과를 `completeRun`으로 store에만 반영하고 terminal event를 직접 보장하지 않는다(`apps/node/internal/node/node.go:268`). adapter가 provider stream stall, context cancellation, parser return 등으로 terminal event 없이 반환하면 Edge는 `inflightByRun`을 계속 보유할 수 있다.

#### 해결 방법

`terminalDeferringSink` 또는 그 주변에 terminal observation helper를 두어 adapter가 이미 terminal event를 emitted했는지 기록한다. `adapter.Execute`가 반환된 뒤 local ticket을 release하고, terminal event가 없으면 실행 결과에 맞는 terminal runtime event를 합성해 deferring sink에 넣은 뒤 `Flush(context.Background())`로 Edge에 보낸다. 실행 전 resolve 실패는 `req.RunId` 기준 error `RunEvent`를 직접 전송한다.

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

- [x] `apps/node/internal/node/node.go`: terminal observation/synthesis helper와 pre-execute error event 추가.
- [x] `apps/node/internal/node/sink_test.go`: terminal observed/no duplicate/synthesized terminal helper 단위 테스트 추가.
- [x] `apps/node/internal/node/node_test.go`: adapter success/error/cancel return에 대한 store status와 terminal event behavior 보강.
- [x] `apps/node/internal/node/node_concurrency_integration_test.go`: Edge side에서 synthesized terminal event가 실제 transport로 도착하는 regression test 추가 또는 기존 integration helper 재사용.
- [x] 필요 시 `apps/node/internal/adapters/openai_compat/openai_compat_test.go`: adapter가 terminal event를 이미 내는 경우 Node가 중복하지 않는 경계 테스트 보강. (기존 adapter가 정상적으로 terminal event를 emission하므로 별도 수정 불필요)

#### 테스트 작성

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

- `TestTerminalDeferringSinkRecordsTerminal`
- `TestOnRunRequestEmitsCompleteWhenAdapterReturnsWithoutTerminal`
- `TestOnRunRequestEmitsErrorWhenAdapterReturnsErrorWithoutTerminal`
- `TestOnRunRequestDoesNotDuplicateAdapterTerminalEvent`
- `TestResolveAdapterErrorObservedByEdge`

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat
```

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/node/node.go` | BUG-1 |
| `apps/node/internal/node/sink_test.go` | BUG-1 |
| `apps/node/internal/node/node_test.go` | BUG-1 |
| `apps/node/internal/node/node_concurrency_integration_test.go` | BUG-1 |
| `apps/node/internal/adapters/openai_compat/openai_compat_test.go` | BUG-1 |

## 최종 검증

```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat
```

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

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