<!-- task=m-provider-resource-admission-ownership-alignment/08_disconnect_exclusion plan=1 tag=REVIEW_REFACTOR -->

# Generation-scoped disconnect settlement follow-up

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

구현과 검증을 마친 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 출력으로 채우고 active 파일을 유지한 채 즉시 code-review 스킬을 실행한다. review 준비 완료를 보고하거나 다음 지시를 기다리지 않는다. 종결 판정, log rename, `complete.log`, archive 이동은 review 역할 전용이지만 별도 agent/thread/turn을 요구하지 않는다. 선택된 Milestone의 `구현 잠금 > 결정 필요`가 실구현을 막을 때만 review stub의 `사용자 리뷰 요청`에 연결 근거를 기록한 뒤 즉시 code-review로 전환한다. 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`/archive/`complete.log`를 구현 역할에서 생성하지 않는다. 환경·secret·서비스·증거 공백은 blocker evidence·결정적 해제 조건·재검증 명령을 포함하는 일반 follow-up 사유다. `USER_REVIEW.md` 또는 `complete.log`가 생성되기 전에는 루프를 종료하지 않는다.

## 배경

이전 리뷰에서 old/new connection generation lease가 같은 capacity 2 provider resource에 공존할 때 old disconnect가 lease map만 삭제하고 aggregate counter를 남길 수 있음이 확인됐다. heartbeat timeout의 authoritative generation/ordering과 provider tunnel의 stale-generation pre-send fence도 결정적 회귀 증거가 부족하다. 이 follow-up은 Required 3건만 해결하여 SDD S12의 exactly-once settlement를 닫는다.

## 사용자 리뷰 요청 흐름

선택 Milestone의 잠금 결정만 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 active review stub에 기록한다. 구현 중 직접 user prompt는 금지하며, 기록 직후 code-review를 실행한다. code-review가 요청 검증과 terminal `USER_REVIEW.md` 작성을 소유한다.

## Archive Evidence Snapshot

- 닫히는 이전 pair:
  - `agent-task/m-provider-resource-admission-ownership-alignment/08_disconnect_exclusion/plan_cloud_G08_0.log`
  - `agent-task/m-provider-resource-admission-ownership-alignment/08_disconnect_exclusion/code_review_cloud_G08_0.log`
- 판정: `FAIL`; Required 3, Suggested 0, Nit 0.
- Required 요약:
  - mixed-generation overlap에서 old lease 삭제 후 provider `inFlight`/`longInFlight` counter가 남는다.
  - heartbeat timeout handler generation/ordering과 normalized+tunnel stale pre-send 경계 테스트가 부족하다.
  - 제출된 full race PASS와 달리 reviewer 재실행에서 reconnect request timeout이 1회 발생했고, 해당 테스트 격리 3회는 PASS했다.
- 주요 영향 파일: `apps/edge/internal/service/model_queue_release.go`, `apps/edge/internal/service/queue_reservation_test.go`, `apps/edge/internal/transport/integration_test.go`, `apps/edge/internal/service/run_dispatch_internal_test.go`.
- 이전 검증: gofmt clean, Edge 대상 package PASS, reviewer full race FAIL, 실패 테스트 격리 `-race -count=3` PASS, mock E2E smoke PASS 기록.
- Roadmap carryover: `disconnect-exclusion` 완료 주장은 이 follow-up PASS 전까지 유보한다.
- 정확한 이전 출력이 필요할 때만 위 두 log를 좁게 재열람한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
- Task ids:
  - `disconnect-exclusion`: current-owner generation fencing과 lease 정확히 한 번 반환
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
- `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/testing-smoke.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `agent-spec/runtime/edge-node-execution.md`
- `agent-spec/runtime/provider-pool-config-refresh.md`
- `apps/edge/internal/node/registry.go`, `registry_test.go`
- `apps/edge/internal/transport/server.go`, `connection_handlers.go`
- `apps/edge/internal/transport/integration_test.go` — reconnect, heartbeat, disconnect recorder 구간
- `apps/edge/internal/bootstrap/runtime.go` — disconnect handler wiring symbol
- `apps/edge/internal/service/service.go`, `model_queue_types.go`, `model_queue_admission.go`, `model_queue_release.go`, `queue_reservation.go`
- `apps/edge/internal/service/provider_resolution.go`, `provider_pool.go`, `provider_tunnel.go`, `run_submit.go`
- `apps/edge/internal/service/model_queue_admission_test.go`, `queue_dispatch_test.go`, `queue_reservation_test.go`
- `apps/edge/internal/service/run_dispatch_internal_test.go` — normalized/tunnel dispatch와 stale generation 구간
- 이전 evidence: 위 `plan_cloud_G08_0.log`, `code_review_cloud_G08_0.log`

### SDD 기준

- SDD는 `[승인됨]`, 잠금 `해제`이다.
- 대상은 S12 → `disconnect-exclusion`이다.
- Evidence Map은 connection ownership·event-drop 회귀, provider resource/connectivity deterministic test, race 검증, `Roadmap Completion` test evidence를 요구한다.
- 따라서 old/new generation overlap의 최종 counter 0, heartbeat current-owner callback, normalized/tunnel pre-send fence를 같은 완료 검증으로 닫는다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`, `edge-smoke.md`, `testing-smoke.md`를 사용한다.
- 공유 Go build cache에 permission 충돌이 있었으므로 `/tmp`의 `mktemp -d` cache를 명시하고 `-count=1`로 fresh test execution을 보장한다.
- 대상 package, regression stress, full race, Edge 회귀, 전체 Go 회귀, mock entrypoint smoke 순서로 실행한다.
- Docker, 외부 provider, secret은 필요하지 않다.

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

- `TestDisconnectFencesOnlyMatchingGeneration`은 newer lease만 존재하는 stale callback은 검증하지만 old/new lease가 동시에 resource counter를 점유하는 ordering을 놓친다.
- `TestEdgeServerHeartbeatTimeoutDisconnect`는 disconnected event reason만 확인하고 authoritative handler generation과 cleanup-before-event를 확인하지 않는다.
- `TestDispatchRejectsStaleGenerationBeforeSend`는 normalized `RunRequest`만 다루고 `ProviderTunnelRequest`를 다루지 않는다.
- full transport race suite와 격리 reconnect test 결과가 충돌하므로 test deadline을 race execution에서 결정적으로 만들 필요가 있다.

### 심볼 참조

- 잠재 변경: `dropLeasesForNodeLocked`; 호출부는 `releaseNode` 한 곳이다.
- 유지 심볼: `releaseLeaseLocked`, `HandleNodeDisconnect`, `candidateIsCurrentOwner`, `UnregisterIfClient`.
- 검색으로 disconnect handler와 generation owner API의 stale 호출부가 없음을 확인한다.

### 분할 판단

- 선택 candidate는 기존 `08_disconnect_exclusion` follow-up 하나다.
- 즉시 sibling set은 기존 `08_disconnect_exclusion`, `09+08_disconnect_queue_resolution`, `10+08_offline_snapshot`, `11+08,09,10_reconnect_candidate_recovery`, `12_node_connectivity_supervision`이다.
- 이 follow-up의 write set은 queue settlement과 같은 generation ordering을 검증하는 service/transport test이며, shared mutable state는 provider resource counter, lease map, Registry owner다. 이를 분할하면 중간 불변식을 다른 task에 넘기므로 하나로 유지한다.
- 새 directory나 predecessor를 생성하지 않고 현재 exact task path를 계속한다.

### 범위 결정 근거

- queue live-candidate unavailable terminal은 09, offline snapshot은 10, reconnect waiter wake-up은 11, Node retry supervision은 12의 범위로 유지한다.
- proto/config/outer API/contract/spec은 변경하지 않는다.
- reconnect race는 S12의 generation fence에 필요한 overlap만 다루고 candidate recovery로 확장하지 않는다.

### 최종 라우팅

- `evaluation_mode=isolated-reassessment`; 이전 lane/grade/score/rationale는 입력에서 제외했다.
- build closures: scope=true(세 Required로 범위 고정), context=true(관련 service/transport/test 범위가 bounded), verification=true(결정적 test/race/smoke 명령 있음), evidence=true(실패 ordering과 충돌 출력 있음), ownership=true(Edge 내부 write set), decision=true(외부 결정 없음). scores=`2,2,1,2,2`; `local/G09` → `PLAN-local-G09.md`.
- review closures: scope=true(S12 counter/generation 불변식), context=true(변경 diff와 4개 회귀 경계로 판정 가능), verification=true(fresh full race와 stress), evidence=true(증거 충돌을 재현 명령으로 해소), ownership=true, decision=true. scores=`2,2,1,2,2`; `local/G09` → `CODE_REVIEW-local-G09.md`.

## 구현 체크리스트

- [ ] old/new generation lease가 공존해도 disconnect가 대상 lease별 counter만 정확히 한 번 반환하고 newer lease/resource를 보존하며 최종 counter가 0으로 회복된다.
- [ ] heartbeat timeout이 current owner generation을 authoritative handler에 전달하고 cleanup이 disconnected event보다 먼저 실행되는 회귀를 추가한다.
- [ ] normalized run과 provider tunnel 모두에서 stale generation을 wire send 전에 fence하고 lease를 한 번 반환하며 전송 0회를 검증한다.
- [ ] reconnect transport regression과 full race suite를 fresh cache에서 결정적으로 통과시키고 Edge/전체 회귀·mock entrypoint smoke 실제 출력을 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우고 즉시 code-review 스킬을 실행한다. USER_REVIEW.md 또는 complete.log가 생성되기 전에는 루프를 종료하거나 사용자에게 묻지 않는다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_REFACTOR-1] Generation-scoped lease settlement

문제: `apps/edge/internal/service/model_queue_release.go:61-78`은 disconnect 대상 lease를 decrement 없이 삭제한다. `releaseNode:117-129`는 resource generation이 더 새로우면 전체 resource를 건너뛰어 old lease counter를 남긴다. 또한 generation-scoped callback이 group counter/candidate를 node 단위로 지워 newer legacy owner를 보존하지 못할 수 있다.

해결 방법:

```go
// Before: apps/edge/internal/service/model_queue_release.go:71
delete(m.leases, id)
lease.state = leaseStateReleased

// After: matching lease ids를 먼저 수집한 뒤 기존 exactly-once decrement 경로로 정산한다.
for _, leaseID := range matchingLeaseIDs {
    m.releaseLeaseLocked(leaseID)
}
```

- provider resource는 `res.generation <= fencedGeneration`일 때만 offline/orphan 처리하고, newer generation resource의 counter/orphan은 변경하지 않는다.
- generation 0 whole-node fence는 모든 lease를 release한 뒤 fixture 호환을 위한 residual group/resource counter만 0으로 정리한다.
- generation-scoped queued candidate 필터는 `candidate.generation <= fencedGeneration`만 제거하고 newer candidate를 보존한다.

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/service/model_queue_release.go`: lease별 decrement, generation-scoped resource/candidate/group cleanup.
- [ ] `apps/edge/internal/service/queue_reservation_test.go`: capacity 2 mixed-generation overlap과 late terminal regression/race.

테스트 작성: `TestDisconnectSettlesOldLeaseWhenNewGenerationAlreadyReserved`를 추가한다. gen1 lease → gen2 lease 동시 점유 → gen1 disconnect 후 `inFlight=1`, gen1 lease 제거, gen2 lease 존재, gen2 terminal 후 `inFlight=0`/모든 lease 제거를 assert한다. 일반·long 점유를 table로 검증하고 같은 ordering을 반복/race 명령에 포함한다.

### [REVIEW_REFACTOR-2] Heartbeat and handoff boundary evidence

문제: `apps/edge/internal/transport/integration_test.go:529-607`은 heartbeat disconnect event만 검증하고 authoritative generation과 ordering을 놓친다. `apps/edge/internal/service/run_dispatch_internal_test.go:775-876`은 normalized path만 검증한다. `integration_test.go:204-260`의 reconnect test는 full race suite에서 2초 request timeout이 관측됐다.

해결 방법:

```go
// Before: heartbeat test는 event channel만 관측한다.
server.SetNodeEventHandler(func(event *iop.EdgeNodeEvent) { nodeEventCh <- event })

// After: owner generation을 capture하고 lifecycle → event 순서를 같은 recorder로 검증한다.
server.SetNodeDisconnectHandler(rec.onDisconnect)
server.SetNodeEventHandler(rec.onNodeEvent)
```

- heartbeat timeout에서 handler reason=`heartbeat_timeout`, generation=등록 owner generation, sequence=`lifecycle,event`를 assert한다.
- stale dispatch fixture를 normalized/tunnel table로 구성해 lease 0, wire capture 0, fence error를 모두 assert한다.
- reconnect integration test의 test context/request deadline을 race instrumentation에서도 안정적인 10초/5초 범위로 늘리되 production heartbeat 상수는 변경하지 않는다.

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/transport/integration_test.go`: heartbeat generation/ordering regression, reconnect race-test deadline 안정화.
- [ ] `apps/edge/internal/service/run_dispatch_internal_test.go`: normalized/tunnel stale pre-send table regression.

테스트 작성: `TestHeartbeatTimeoutDisconnectCarriesCurrentGenerationBeforeEvent`와 tunnel branch를 포함하는 `TestDispatchRejectsStaleGenerationBeforeSend`를 작성/확장한다. request parser별 net.Pipe capture로 `RunRequest`/`ProviderTunnelRequest` 수신 0회를 직접 확인한다.

## 의존 관계 및 구현 순서

1. REVIEW_REFACTOR-1로 settlement 불변식을 고정한다.
2. REVIEW_REFACTOR-2로 transport/handoff 경계와 race 결정성을 닫는다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/model_queue_release.go` | REVIEW_REFACTOR-1 |
| `apps/edge/internal/service/queue_reservation_test.go` | REVIEW_REFACTOR-1 |
| `apps/edge/internal/transport/integration_test.go` | REVIEW_REFACTOR-2 |
| `apps/edge/internal/service/run_dispatch_internal_test.go` | REVIEW_REFACTOR-2 |

## 최종 검증

```bash
review_gocache="$(mktemp -d)"
gofmt -d apps/edge/internal/service/model_queue_release.go apps/edge/internal/service/queue_reservation_test.go apps/edge/internal/transport/integration_test.go apps/edge/internal/service/run_dispatch_internal_test.go
rg --sort path -n 'dropLeasesForNodeLocked|releaseNode\(|HandleNodeDisconnect|candidateIsCurrentOwner|UnregisterIfClient' apps/edge --glob '*.go'
GOCACHE="$review_gocache" go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/bootstrap ./apps/edge/internal/service
GOCACHE="$review_gocache" go test -race -count=10 ./apps/edge/internal/service -run '^(TestDisconnectSettlesOldLeaseWhenNewGenerationAlreadyReserved|TestDisconnectFencesOnlyMatchingGeneration|TestDispatchRejectsStaleGenerationBeforeSend)$'
GOCACHE="$review_gocache" go test -race -count=5 ./apps/edge/internal/transport -run '^(TestEdgeServerReconnectAfterUnregisterAccepted|TestHeartbeatTimeoutDisconnectCarriesCurrentGenerationBeforeEvent|TestReconnectDisconnectGenerationsAreMonotonic)$'
GOCACHE="$review_gocache" go test -race -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service
GOCACHE="$review_gocache" go test -count=1 ./apps/edge/...
GOCACHE="$review_gocache" go test -count=1 ./...
GOCACHE="$review_gocache" IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh
```

- gofmt와 deterministic `rg`는 의도한 symbol/call-site 목록 외 오류를 남기지 않는다.
- 대상/stress/full race/Edge 회귀는 모두 PASS해야 한다. race detector 경고와 reconnect request timeout은 0건이어야 한다.
- `go test ./...`와 smoke가 환경으로 실패하면 실제 stdout/stderr, 격리 재현, 남은 위험을 기록하고 변경 범위 결함인지 판정 가능해야 한다.
- mock smoke는 startup/register/run/command/normal close와 authoritative disconnected event 1회를 관측한다. archived smoke를 대체 증거로만 사용하지 않는다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우고 즉시 code-review 스킬을 실행한다. `USER_REVIEW.md` 또는 `complete.log`가 생성되기 전에는 사용자에게 묻거나 루프를 종료하지 않는다.
