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

# Current-owner generation fencing

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

구현과 검증을 마친 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 출력으로 채우고 active 파일을 그대로 둔 채 review 준비 완료를 보고한다. 종결 판정, 로그 rename, `complete.log`, archive 이동은 code-review 전용이다. 선택된 Milestone의 `구현 잠금 > 결정 필요`가 실구현을 막을 때만 review stub의 `사용자 리뷰 요청`에 연결 근거를 기록하고 멈춘다. 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`를 만들지 않는다. 환경·secret·서비스·증거 공백은 일반 follow-up 사유다.

## 배경

현재 Registry는 client pointer로 stale close를 거르지만 accepted connection generation을 admission candidate와 provider lease에 고정하지 않는다. disconnect가 live candidate 해석과 reserve/send 사이에 경합하면 이전 connection의 candidate가 새 lease 또는 dispatch handoff에 사용될 수 있다. SDD S12의 current-owner fencing을 transport, registry, queue lease가 같은 identity로 판단하도록 만든다.

## 사용자 리뷰 요청 흐름

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

## 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/PHASE.md`
- `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`
- `apps/edge/internal/node/registry_test.go`
- `apps/edge/internal/transport/server.go` — handler와 connection listener 범위
- `apps/edge/internal/transport/connection_handlers.go`
- `apps/edge/internal/transport/integration_test.go` — 609-771 current/stale disconnect 회귀 범위
- `apps/edge/internal/bootstrap/runtime.go` — transport/service handler wiring 범위
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/model_queue_types.go`
- `apps/edge/internal/service/model_queue_admission.go`
- `apps/edge/internal/service/model_queue_release.go`
- `apps/edge/internal/service/queue_reservation.go`
- `apps/edge/internal/service/provider_pool.go`
- `apps/edge/internal/service/run_submit.go`
- `apps/edge/internal/service/provider_tunnel.go` — admission/send/handoff 범위
- `apps/edge/internal/service/model_queue_admission_test.go` — 243-366 disconnect/event-drop 범위
- `apps/edge/internal/service/queue_reservation_test.go` — 344-529 disconnect/release race 범위
- `apps/edge/internal/service/run_dispatch_internal_test.go` — provider run/tunnel send와 terminal 범위

### SDD 기준

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`, 상태 `[승인됨]`, 잠금 `해제`.
- Acceptance Scenario: S12 → `disconnect-exclusion`.
- Evidence Map: Edge provider resource/connectivity/queue deterministic tests, connection ownership·event-drop 회귀, race 검증, `Roadmap Completion`의 `disconnect-exclusion`과 최종 test/race 명령.
- 따라서 generation identity가 Registry acceptance, candidate, lease, disconnect callback에 이어지고 reserve/send/handoff 경합과 stale duplicate close를 deterministic/race test로 닫는 체크리스트를 사용한다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`, `edge-smoke.md`, `testing-smoke.md`를 읽었다.
- 대상 package test → Edge 전체 test → race → 전체 Go 회귀 순으로 실행한다. fresh concurrency evidence가 필요하므로 Go cache를 허용하지 않고 `-count=1`을 사용한다.
- `IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh`는 실제 Edge/Node entrypoint 보조 smoke다. testing rule상 full-cycle 대체가 아니므로 구현 결과에는 current-owner close/reconnect 구동 evidence 또는 그 blocker를 별도로 남긴다.
- 외부 provider, Docker, secret은 필요하지 않는다. 현재 worktree의 사용자 변경은 보존한다.

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

- 기존 transport test는 client pointer 기반 stale close와 cleanup-before-event만 검증하고 accepted generation 단조 증가/전달을 검증하지 않는다.
- 기존 queue race test는 lease idempotency를 검증하지만 Registry current generation과 reserve/send/handoff를 함께 경합시키지 않는다.
- normalized와 tunnel send 직전 generation 재검증, heartbeat timeout generation fencing 회귀가 없다.

### 심볼 참조

- 변경 후보: `NodeEntry`, `Registry.RegisterIfAbsent`, `Registry.UnregisterIfClient`, `Server.SetNodeDisconnectHandler`, `Service.HandleNodeDisconnect`, `candidateNode`, `providerLease`.
- 확인한 호출 지점: `apps/edge/internal/transport/connection_handlers.go`, `apps/edge/internal/bootstrap/runtime.go`, `apps/edge/internal/transport/integration_test.go`, `apps/edge/internal/service/*_test.go`의 direct handler 호출.

### 분할 판단

- 선택 candidate는 `disconnect-exclusion` 한 Task다. generation 발급, lease fencing, dispatch handoff는 하나의 correctness 전이라 더 쪼개면 중간 상태가 안전하지 않다.
- 이번 task: `08_disconnect_exclusion`; write set은 Edge registry/transport/service와 해당 tests다.
- 같은 Epic의 sibling 전체: `08_disconnect_exclusion`, `09+08_disconnect_queue_resolution`, `10+08_offline_snapshot`, `11+08,09,10_reconnect_candidate_recovery`, `12_node_connectivity_supervision`.
- 09/10/11은 이 task의 generation 계약을 소비한다. 12는 Node/config write set이 분리되어 독립 실행 가능하다.
- shared mutable state는 Registry owner, provider resource, lease map, run/tunnel handoff다. 이 task 내부로 합쳐 atomic ordering을 한 plan에서 검증한다.

### 범위 결정 근거

- 후보가 0이 된 queue의 즉시 unavailable 종료는 09로 제외한다.
- configured-but-offline snapshot과 Control Plane `connected` 매핑은 10으로 제외한다.
- accepted reconnect의 resource activation/pump는 11로 제외한다.
- Node initial/unlimited retry policy는 12로 제외한다.
- proto/config/외부 HTTP status schema는 변경하지 않는다.

### 최종 라우팅

- `evaluation_mode=first-pass`.
- build closures: scope=true, context=false(Registry/transport/queue/run+tunnel 동시성 문맥을 함께 유지해야 함), verification=true, evidence=true, ownership=true, decision=true. cloud의 넓은 context가 context 폐쇄를 해소한다. scores=`2,2,1,1,2`; routed `cloud/G08` → `PLAN-cloud-G08.md`.
- review closures: scope=true, context=false(generation 전파와 race ordering을 전체 경로에서 대조해야 함), verification=true, evidence=true, ownership=true, decision=true. cloud가 해소한다. scores=`2,2,1,1,2`; routed `cloud/G08` → `CODE_REVIEW-cloud-G08.md`.

## 구현 체크리스트

- [ ] accepted Node connection에 단조 증가 generation을 부여하고 current client ownership과 함께 Registry에서 원자적으로 조회/해제한다.
- [ ] provider candidate와 lease가 generation을 보존하며 reserve 및 normalized/tunnel dispatch handoff가 current owner generation을 재검증한다.
- [ ] current owner disconnect/heartbeat timeout만 해당 generation을 fence하고 lease를 정확히 한 번 반환하며 stale/rejected close는 lifecycle/event/resource를 바꾸지 않는다.
- [ ] deterministic transport/service test와 `-race` 검증으로 disconnect·reconnect·duplicate registration·admission 경합을 닫고 보조 entrypoint smoke 결과 또는 blocker를 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REFACTOR-1] Registry generation identity

문제: `apps/edge/internal/node/registry.go:22-29`의 `NodeEntry`에는 accepted connection identity가 client pointer뿐이고 `RegisterIfAbsent`/`UnregisterIfClient`는 generation을 반환하지 않는다.

해결 방법:

```go
// Before: apps/edge/internal/node/registry.go:22
type NodeEntry struct {
    NodeID string
    Client *toki.TcpClient
}

// After
type NodeEntry struct {
    NodeID               string
    Client               *toki.TcpClient
    ConnectionGeneration uint64
}
```

Registry lock 아래에서 Node별 generation을 accepted registration에만 증가시키고 unregister가 제거한 owner generation을 반환한다. duplicate/stale client는 generation을 얻거나 live entry를 제거하지 못한다.

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/node/registry.go`: generation 발급·current owner 조회·조건부 unregister API.
- [ ] `apps/edge/internal/node/registry_test.go`: concurrent duplicate, reconnect monotonic generation, stale generation/client rejection.

테스트 작성: 위 3가지 Registry 정상/경계 테스트를 추가한다.

중간 검증: `go test -count=1 ./apps/edge/internal/node`가 통과한다.

### [REFACTOR-2] Transport current-owner lifecycle propagation

문제: `apps/edge/internal/transport/connection_handlers.go:184-213`은 client ownership만 확인하고 `onNodeDisconnect(nodeID, reason)`으로 generation을 전달하지 않는다.

해결 방법: accepted registration entry의 generation을 listener closure에 고정하고, `UnregisterIfClient`가 current owner임을 확인해 반환한 동일 generation만 authoritative lifecycle callback으로 보낸다. cleanup은 계속 disconnected event보다 먼저 실행한다.

```go
// Before: apps/edge/internal/transport/connection_handlers.go:193,210
if !s.registry.UnregisterIfClient(rec.ID, client) { return }
disconnectHandler(rec.ID, reason)

// After: unregister가 current owner의 generation을 반환한다.
generation, ok := s.registry.UnregisterIfClient(rec.ID, client)
if !ok { return }
disconnectHandler(rec.ID, generation, reason)
```

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/transport/server.go`: generation-aware disconnect handler 계약.
- [ ] `apps/edge/internal/transport/connection_handlers.go`: accepted generation capture, stale callback no-op, heartbeat reason 유지.
- [ ] `apps/edge/internal/bootstrap/runtime.go`: service handler wiring signature 갱신.
- [ ] `apps/edge/internal/transport/integration_test.go`: current/stale/duplicate/reconnect/heartbeat generation ordering.

테스트 작성: `apps/edge/internal/transport/integration_test.go`의 기존 current/stale fixture를 사용해 `TestDisconnectHandlerReceivesCurrentGeneration`과 `TestStaleDisconnectDoesNotEmitLifecycle`을 추가하고, reconnect 후 이전 callback이 새 owner/event/lease를 건드리지 않는지 검증한다.

중간 검증: `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/bootstrap`가 통과한다.

### [REFACTOR-3] Generation-bound provider lease and handoff

문제: `apps/edge/internal/service/model_queue_types.go:124-137`의 `providerLease`와 candidate에는 generation이 없고, `model_queue_admission.go:154-233`은 live config/resource만 재검증한다. Registry unregister와 send가 경합하면 old entry를 reserve할 수 있다.

해결 방법: candidate/lease에 generation을 저장하고 live candidate lookup에서 Registry current entry를 재해석한다. disconnect handler는 `(nodeID,generation)`에 해당하는 lease/resource만 fence한다. normalized/tunnel send/handoff 직전에도 reservation generation이 current owner인지 확인하며 실패 시 동일 lease release 경로로 수렴한다.

```go
// Before: apps/edge/internal/service/model_queue_types.go:133-141
type providerLease struct {
	nodeID string
	// generation identity 없음
}

// After: candidate와 lease가 동일 accepted owner를 가리킨다.
type providerLease struct {
	nodeID     string
	generation uint64
}
```

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/service/service.go`: generation-aware disconnect와 current-owner validation boundary.
- [ ] `apps/edge/internal/service/model_queue_types.go`: candidate/resource/lease generation.
- [ ] `apps/edge/internal/service/model_queue_admission.go`: live Registry generation resolve와 reserve fence.
- [ ] `apps/edge/internal/service/model_queue_release.go`: generation-scoped node release.
- [ ] `apps/edge/internal/service/queue_reservation.go`: pre-handoff validation/release helper.
- [ ] `apps/edge/internal/service/provider_pool.go`, `run_submit.go`, `provider_tunnel.go`: normalized/tunnel send/handoff fence 적용.
- [ ] `apps/edge/internal/service/queue_reservation_test.go`, `run_dispatch_internal_test.go`: disconnect/send/terminal 경쟁과 old-generation late release 회귀.

테스트 작성: `apps/edge/internal/service/queue_reservation_test.go`에 `TestDisconnectFencesOnlyMatchingGeneration`, `apps/edge/internal/service/run_dispatch_internal_test.go`에 `TestDispatchRejectsStaleGenerationBeforeSend`를 추가한다. 기존 fake registry/client로 normal/long lease와 normalized/tunnel, send failure, duplicate/stale disconnect, reconnect generation을 table/race 검증한다.

중간 검증: `go test -race -count=1 ./apps/edge/internal/service`가 통과하고 fenced generation의 새 dispatch가 관측되지 않는다.

## 의존 관계 및 구현 순서

1. REFACTOR-1로 identity 발급 계약을 고정한다.
2. REFACTOR-2로 authoritative disconnect에 generation을 전달한다.
3. REFACTOR-3으로 queue lease/reserve/send/handoff를 같은 identity에 묶는다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/node/registry.go`, `registry_test.go` | REFACTOR-1 |
| `apps/edge/internal/transport/server.go`, `connection_handlers.go`, `integration_test.go` | REFACTOR-2 |
| `apps/edge/internal/bootstrap/runtime.go` | REFACTOR-2 |
| `apps/edge/internal/service/service.go`, `model_queue_types.go`, `model_queue_admission.go`, `model_queue_release.go`, `queue_reservation.go` | REFACTOR-3 |
| `apps/edge/internal/service/queue_reservation_test.go`, `run_dispatch_internal_test.go` | REFACTOR-3 |

## 최종 검증

- `gofmt -d apps/edge/internal/node/registry.go apps/edge/internal/node/registry_test.go apps/edge/internal/transport/server.go apps/edge/internal/transport/connection_handlers.go apps/edge/internal/transport/integration_test.go apps/edge/internal/bootstrap/runtime.go apps/edge/internal/service/service.go apps/edge/internal/service/model_queue_types.go apps/edge/internal/service/model_queue_admission.go apps/edge/internal/service/model_queue_release.go apps/edge/internal/service/queue_reservation.go apps/edge/internal/service/queue_reservation_test.go apps/edge/internal/service/run_dispatch_internal_test.go` → 출력 없음.
- `go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/bootstrap ./apps/edge/internal/service` → PASS.
- `go test -race -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service` → PASS, race 없음.
- `go test -count=1 ./apps/edge/...` → PASS.
- `go test -count=1 ./...` → PASS.
- `IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh` → startup/register/run/command/normal shutdown PASS. 이는 보조 smoke이며 current-owner close/reconnect full-cycle evidence 또는 명시 blocker를 review에 별도로 기록한다.

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