<!-- task=m-provider-resource-admission-ownership-alignment/02+01_lease_lifecycle plan=0 tag=REFACTOR -->

# Provider Lease Lifecycle 구현 계획

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

선행 `01_provider_resource_state/complete.log`를 확인한 뒤 구현한다. 구현과 검증 후 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션에 실제 내용과 stdout/stderr를 채우고 active 파일을 그대로 둔 채 리뷰 준비 상태를 보고한다. 종결 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. 선택된 Milestone의 `구현 잠금 > 결정 필요`가 실제 구현을 막을 때만 review stub의 `사용자 리뷰 요청`에 정확한 연결 근거를 기록하고 멈춘다. 사용자 직접 질문·선택지·`request_user_input`, `USER_REVIEW.md`, archive log, `complete.log` 작성은 금지한다. 환경/secret/서비스 차단이나 일반 follow-up은 사용자 리뷰 요청이 아니다.

## 배경

현재 `queueReservation.released`는 동기화되지 않은 bool이고 release identity가 run ID에 간접 결합되어 있다. normalized terminal과 node disconnect release는 drop 가능한 event bus subscriber에 의존하며, tunnel terminal·Close·send failure가 경쟁할 때 정확히 한 번 반환한다는 invariant가 하나의 state machine으로 표현되지 않는다. 이 작업은 고유 lease identity와 manager-owned 전이를 도입하고 authoritative transport lifecycle을 관측 fanout보다 먼저 직접 전달한다.

## 사용자 리뷰 요청 흐름

선택된 Milestone lock 결정만 active `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 템플릿에 기록한다. 구현 중 직접 사용자 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:
  - `lease-lifecycle`: 고유 provider lease와 exactly-once terminal 반환
- 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-contract/inner/edge-config-runtime-refresh.md`, `agent-contract/inner/edge-node-runtime-wire.md`, `agent-contract/inner/control-plane-edge-wire.md`, `agent-contract/outer/openai-compatible-api.md`, `agent-spec/runtime/edge-node-execution.md`, `agent-spec/runtime/provider-pool-config-refresh.md`
- 소스: `apps/edge/internal/service/model_queue_types.go`, `model_queue_admission.go`, `model_queue_release.go`, `queue_reservation.go`, `run_submit.go`, `provider_pool.go`, `provider_tunnel.go`, `service.go`, `apps/edge/internal/events/bus.go`, `apps/edge/internal/transport/server.go`, `connection_handlers.go`, `apps/edge/internal/node/registry.go`, `apps/edge/internal/bootstrap/runtime.go`
- 테스트: `apps/edge/internal/service/queue_reservation_test.go`, `model_queue_admission_test.go`, `long_context_queue_test.go`, `run_dispatch_internal_test.go`, `queue_dispatch_test.go`, `apps/edge/internal/transport/server_test.go`, `integration_test.go`, `apps/edge/internal/node/registry_test.go`, `apps/edge/internal/bootstrap/runtime_lifecycle_test.go`

### SDD 기준

- SDD 상태 `[승인됨]`, 잠금 해제.
- S02 → `lease-lifecycle`: reserved/tracked 일반·long lease에 send failure, complete/error/cancel, tunnel terminal/close, node disconnect가 단독·경쟁으로 발생해도 event delivery와 무관하게 정확히 한 번 반환.
- Evidence Map은 deterministic connection/event-drop 회귀와 race 검증을 요구한다. 따라서 manager lock 아래 lease FSM, direct run/node lifecycle hook, 포화 bus와 중복 terminal race를 모두 checklist와 최종 검증에 포함한다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`, `edge-smoke.md`, `testing-smoke.md`를 읽었다.
- 현재 local runner의 Go 1.26.2와 `/config/workspace/iop` checkout을 사용하고 dirty user changes를 보존한다.
- fresh package/Edge 전체 테스트와 `-race`를 사용하며 `GOCACHE=/tmp/iop-go-cache`를 고정한다.
- net.Pipe와 local transport fixture로 terminal/disconnect를 재현하므로 외부 provider, Docker, secret은 필요 없다.

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

- 기존 reservation 테스트는 순차 중복 release를 검사하지만 동시 release/handoff race와 고유 lease identity를 검사하지 않는다.
- tunnel END+Close exactly-once 테스트는 있으나 normalized terminal이 event bus fanout 포화와 무관한지 검증하지 않는다.
- disconnect listener는 현재 registry ownership 확인 전에 event를 내며 service cleanup direct hook이 없다.

### 심볼 참조

- `startEventWatcher`, `trackInflight`, `releaseRun`, `releaseNode`, `queueReservation.track/release/handOff`의 모든 call site를 service tests와 `provider_pool.go`, `run_submit.go`, `provider_tunnel.go`에서 확인했다.
- 제거/rename 시 위 call site를 전부 새 lease transition API로 바꾸고 compatibility wrapper를 남길 경우 목적과 제거 조건을 review에 기록한다.

### 분할 판단

- split 정책을 적용했다. task group은 `m-provider-resource-admission-ownership-alignment`다.
- 현재 디렉터리 `02+01_lease_lifecycle`의 predecessor는 `01_provider_resource_state` 하나다.
- 선행 충족 상태: active `agent-task/m-provider-resource-admission-ownership-alignment/01_provider_resource_state/complete.log` 없음. 구현 시작 전 해당 파일이 필요하다.
- 후속 `03+01,02_cross_group_wakeup`은 본 작업과 01 완료를 모두 요구한다.

### 범위 결정 근거

- connection generation 발급, stale/rejected duplicate 전체 fencing, offline snapshot과 reconnect 복구는 connectivity epic의 별도 Task다.
- 다만 S02가 요구하는 node disconnect lease 반환을 위해 기존 `UnregisterIfClient` 성공 뒤에만 service direct hook을 호출한다. generation public model이나 wire field는 추가하지 않는다.
- event bus는 관측 fanout으로 유지하며 drop 정책 자체는 변경하지 않는다.

### 빌드 등급

- `cloud-G07`: service·transport·bootstrap을 가로지르는 concurrent lifecycle state machine과 failure race를 변경한다.

## 의존 관계 및 구현 순서

1. `01_provider_resource_state`의 active 또는 동일 task group archive `complete.log`가 존재해야 한다.
2. 선행 plan이 만든 resource key/state API 위에서 lease identity를 구현한다.
3. transport direct lifecycle hook과 bootstrap wiring을 연결한 뒤 event bus 포화·race 검증을 수행한다.

## 구현 체크리스트

- [ ] 고유 lease ID와 `reserved → tracked → released` 전이를 manager lock 아래 구현하고 send/prepare 실패와 중복 terminal에서 일반·long counter를 정확히 한 번 반환하도록 검증한다.
- [ ] normalized run terminal과 current-owner node disconnect를 service에 직접 전달하고 tunnel terminal/Close와 동일 lease release 경로로 통합하며 event bus 포화·경쟁을 검증한다.
- [ ] 변경한 service·transport·bootstrap package fresh 테스트와 race 테스트를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REFACTOR-1] Manager-owned lease FSM과 idempotent release

#### 문제

`apps/edge/internal/service/queue_reservation.go:16-24`은 mutable `runID`와 `released bool`을 reservation 객체에서 lock 없이 바꾼다. `release`는 run ID 또는 slot을 선택해 서로 다른 API를 호출하므로 END, Close, send failure가 경쟁할 때 identity 기반 exactly-once 보장이 없다.

#### 해결 방법

admission 시 monotonic lease ID를 만들고 lease record를 manager map에 넣는다. track/handoff/release는 모두 manager mutex 아래 state와 owner를 확인하며 `released` 전이만 resource counter를 감소시킨다. `queueReservation`은 immutable lease ID handle로 축소한다.

Before (`queue_reservation.go:45-64`):

```go
res.runID = runID
res.queue.trackInflight(res.groupKey, runID, res.nodeID, res.providerID, res.long)
...
if res.runID != "" {
	res.queue.releaseRun(res.runID, reason)
	return
}
res.queue.releaseSlotWithLong(res.groupKey, res.nodeID, res.providerID, res.long)
```

After:

```go
res.queue.trackLease(res.leaseID, runID)
...
res.queue.releaseLease(res.leaseID, reason)
```

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

- [ ] `apps/edge/internal/service/model_queue_types.go`: lease ID, state, lifecycle owner와 lease/run index를 추가한다.
- [ ] `apps/edge/internal/service/model_queue_admission.go`: resource reservation과 lease record 생성을 한 critical section에서 수행한다.
- [ ] `apps/edge/internal/service/queue_reservation.go`: immutable lease handle과 manager transition 호출만 남긴다.
- [ ] `apps/edge/internal/service/model_queue_release.go`: 모든 release 원인을 identity 기반 단일 함수로 합친다.
- [ ] `apps/edge/internal/service/run_submit.go`, `provider_pool.go`, `provider_tunnel.go`: build/send/terminal/Close call site를 새 FSM으로 전환한다.
- [ ] `apps/edge/internal/service/queue_reservation_test.go`, `run_dispatch_internal_test.go`: 일반·long·tunnel concurrent exactly-once 회귀를 추가한다.

#### 테스트 작성

- `TestProviderLeaseConcurrentReleaseExactlyOnce`: 동일 lease에 32개 release goroutine을 실행하고 counter 0, 음수 없음, released record 1회를 검증한다.
- `TestProviderLeaseTrackAndSendFailureRace`: track 직후 send failure/terminal 경쟁에서 resource가 남지 않는지 검증한다.
- 기존 tunnel END+Close 테스트를 lease ID/state assertion으로 강화한다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test -race -count=1 ./apps/edge/internal/service -run 'TestProviderLease|TestProviderTunnelReleaseExactlyOnceAcrossEndAndClose'
```

기대 결과: 일반·long·tunnel release race PASS, counter 0.

### [REFACTOR-2] Correctness lifecycle을 drop 가능한 event fanout에서 분리

#### 문제

`model_queue_release.go:11-39`은 buffer 256/64의 `SubscribeAll*` watcher가 terminal과 disconnect를 소비해야 cleanup한다. `events/bus.go:140-176`은 full subscriber channel에 non-blocking offer 후 drop한다. 또한 `connection_handlers.go:173-210`은 disconnect event를 emit한 뒤 `UnregisterIfClient`를 호출한다.

#### 해결 방법

transport server에 run terminal과 accepted-owner disconnect용 direct handler를 추가한다. run event listener는 terminal이면 service lifecycle handler를 동기 호출한 다음 관측 handler를 호출한다. disconnect listener는 `UnregisterIfClient`가 true인 경우에만 service node lifecycle handler를 호출하고 이후 관측 event를 emit한다. tunnel frame/Close는 이미 service 직접 경로이므로 동일 lease release API만 사용한다.

Before (`connection_handlers.go:202-210`):

```go
s.emitNodeEvent(events.NewEdgeNodeEvent(...))
s.registry.UnregisterIfClient(rec.ID, client)
```

After:

```go
if !s.registry.UnregisterIfClient(rec.ID, client) {
	return
}
s.handleNodeDisconnect(rec.ID, reason)
s.emitNodeEvent(events.NewEdgeNodeEvent(...))
```

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

- [ ] `apps/edge/internal/service/service.go`: transport가 호출할 terminal/disconnect lifecycle method를 제공한다.
- [ ] `apps/edge/internal/transport/server.go`: direct lifecycle handler setter/reader와 wiring 상태 helper를 추가한다.
- [ ] `apps/edge/internal/transport/connection_handlers.go`: terminal cleanup과 current-owner disconnect cleanup을 관측 fanout보다 먼저 호출한다.
- [ ] `apps/edge/internal/bootstrap/runtime.go`: direct handlers를 Service에 연결하고 event bus publish는 관측 후속으로 유지한다.
- [ ] `apps/edge/internal/service/model_queue_release.go`: correctness watcher를 제거하거나 관측 호환 fallback으로 명시적으로 격리한다.
- [ ] `apps/edge/internal/service/model_queue_admission_test.go`: bus subscriber를 포화시킨 상태에서 direct terminal cleanup을 검증한다.
- [ ] `apps/edge/internal/transport/server_test.go`, `integration_test.go`: stale client는 direct disconnect를 만들지 않고 current owner만 cleanup 후 event를 내는 순서를 검증한다.
- [ ] `apps/edge/internal/bootstrap/runtime_lifecycle_test.go`: run/node lifecycle handler wiring을 검증한다.

#### 테스트 작성

- `TestRunTerminalReleasesLeaseWhenEventSubscriberFull`: fanout drop count가 증가해도 direct lifecycle로 lease가 반환된다.
- `TestCurrentOwnerDisconnectReleasesLeasesBeforeEvent`: cleanup callback이 event보다 먼저 1회 호출된다.
- `TestStaleDisconnectDoesNotReleaseLiveLease`: `UnregisterIfClient=false`이면 callback/event 모두 발생하지 않는다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test -race -count=1 ./apps/edge/internal/service ./apps/edge/internal/transport ./apps/edge/internal/bootstrap -run 'Test(RunTerminalReleasesLeaseWhenEventSubscriberFull|CurrentOwnerDisconnectReleasesLeasesBeforeEvent|StaleDisconnectDoesNotReleaseLiveLease|RuntimeWiresTransportHandlers)$'
```

기대 결과: direct lifecycle ordering과 bus 포화 회귀 PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/model_queue_types.go` | REFACTOR-1 |
| `apps/edge/internal/service/model_queue_admission.go` | REFACTOR-1 |
| `apps/edge/internal/service/queue_reservation.go` | REFACTOR-1 |
| `apps/edge/internal/service/model_queue_release.go` | REFACTOR-1, REFACTOR-2 |
| `apps/edge/internal/service/run_submit.go` | REFACTOR-1 |
| `apps/edge/internal/service/provider_pool.go` | REFACTOR-1 |
| `apps/edge/internal/service/provider_tunnel.go` | REFACTOR-1 |
| `apps/edge/internal/service/service.go` | REFACTOR-2 |
| `apps/edge/internal/transport/server.go` | REFACTOR-2 |
| `apps/edge/internal/transport/connection_handlers.go` | REFACTOR-2 |
| `apps/edge/internal/bootstrap/runtime.go` | REFACTOR-2 |
| 관련 service/transport/bootstrap 테스트 | REFACTOR-1, REFACTOR-2 |

## 최종 검증

```bash
gofmt -d apps/edge/internal/service/model_queue_types.go apps/edge/internal/service/model_queue_admission.go apps/edge/internal/service/queue_reservation.go apps/edge/internal/service/model_queue_release.go apps/edge/internal/service/run_submit.go apps/edge/internal/service/provider_pool.go apps/edge/internal/service/provider_tunnel.go apps/edge/internal/service/service.go apps/edge/internal/transport/server.go apps/edge/internal/transport/connection_handlers.go apps/edge/internal/bootstrap/runtime.go apps/edge/internal/service/queue_reservation_test.go apps/edge/internal/service/model_queue_admission_test.go apps/edge/internal/service/run_dispatch_internal_test.go apps/edge/internal/transport/server_test.go apps/edge/internal/transport/integration_test.go apps/edge/internal/bootstrap/runtime_lifecycle_test.go
GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/...
GOCACHE=/tmp/iop-go-cache go test -race -count=1 ./apps/edge/internal/service ./apps/edge/internal/transport ./apps/edge/internal/bootstrap -run 'Test(ProviderLease|RunTerminal|CurrentOwnerDisconnect|StaleDisconnect|ProviderTunnelRelease)'
```

기대 결과: gofmt diff 없음, Edge 전체 PASS, lifecycle race 없음. Go test cache 결과는 허용하지 않는다.

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