<!-- task=m-provider-resource-admission-ownership-alignment/11+08,13,15_reconnect_candidate_recovery plan=3 tag=REVIEW_REFACTOR -->

# Reconnect ownership linearization과 직접 user-flow 증거

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

Implementation 모델은 Plan/Review 모델과 달라야 한다. 이 계획의 코드·테스트·검증을 수행하고 review stub의 구현 소유 섹션을 채운 뒤 `review-ready`를 보고하고 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

## 역할 모델

- Plan: `root-followup_plan_reconnect_linearization`
- Implementation/Review identity 원장은 `CODE_REVIEW-local-G09.md`에 있으며 Runtime이 각 역할 시작 시 기록한다.

## 배경

현재 ready 전이와 disconnect/reconnect, 그리고 provider-pool의 generation 확인과 send/handoff 사이에는 각각 check-then-act 창이 남아 있다. 따라서 stale owner가 connected event를 내거나, 이미 반환된 lease로 dispatch 성공 handle을 반환할 수 있다.

또한 pending fence의 ready 후 회귀는 wire 종류별 성공을 증명하지 못했고, mock smoke는 명시적으로 보조 검증이다. 이번 범위는 ownership 선형화, 모든 direct wire의 정확한 ready 후 전송, 그리고 두 `scripts/dev` entrypoint를 실제로 사용하는 별도 reconnect transcript를 함께 닫는다.

## 사용자 리뷰 요청 흐름

선택 Milestone의 `구현 잠금 > 결정 필요`만 active review stub의 `사용자 리뷰 요청`에 기록한다. 구현 중 직접 user prompt는 금지되며 code-review가 요청 검증과 실제 `USER_REVIEW.md` 작성을 소유한다. 환경·endpoint·credential 제한은 검증 evidence와 재개 조건으로 기록하며 사용자 리뷰 사유가 아니다.

## Archive Evidence Snapshot

- 직전 evidence: `plan_local_G08_2.log`, `code_review_local_G09_2.log`; verdict=`FAIL`.
- Required 1: ready callback이 old owner close와 새 pending registration에 경합해 stale/pending `node.connected` event를 만들지 않도록 exact owner snapshot과 unregister를 하나의 ownership linearization gate로 묶어야 한다.
- Required 2: normalized/tunnel provider-pool dispatch는 current generation 확인, wire enqueue, lease handoff를 disconnect fence와 직렬화해야 하며, send 대기 중 authoritative disconnect를 주입한 회귀가 필요하다.
- Required 3: 동일 pending fixture를 ready로 전이한 뒤 direct background run, direct tunnel, Ollama API와 모든 typed command의 wire type별 payload/횟수 `1`을 단언해야 한다.
- Required 4: `scripts/e2e-smoke.sh` 보조 결과와 별도로, `/tmp` 단일-instance profile에서 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 실행한 direct transcript로 message x2, command, reconnect 후 run, Node/Edge payload 동일성과 terminal ordering을 남겨야 한다.
- 유지할 evidence: prior Go/race/auxiliary smoke 결과는 출발점일 뿐이며, live preflight rc=3은 성공이 아닌 revalidation blocker다. 추가 문맥이 필요할 때만 위 두 log와 변경하지 않는 `NEXT_STATE.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:
  - `reconnect-candidate-recovery`: current accepted generation만 candidate 복구·waiter pump·connected event를 열고 stale/rejected transition은 no-op으로 유지
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `apps/edge/internal/node/registry.go:1-280`, `registry_test.go:271-373`: generation, pending/ready registry ownership과 기존 unit coverage.
- `apps/edge/internal/transport/connection_handlers.go:130-306`, `server.go:75-235`, `integration_test.go:1160-1370`: ready callback, disconnect callback, lifecycle handler wiring과 handshake tests.
- `apps/edge/internal/service/service.go:1-220`, `run_submit.go:45-205`, `provider_pool.go:140-270`, `provider_tunnel.go:1-240`, `run_dispatch_internal_test.go:1080-1280`: provider-pool lease handoff와 direct wire fixture.
- `scripts/dev/edge.sh`, `scripts/dev/node.sh`, `scripts/e2e-smoke.sh:1-90,380-470,720-800`: existing dev entrypoint overrides, auxiliary reconnect profile, payload/order assertions.
- `agent-contract/inner/edge-node-runtime-wire.md`, `agent-spec/runtime/edge-node-execution.md`, `agent-spec/runtime/provider-pool-config-refresh.md`: accepted/ready ownership, current generation, candidate recovery contract.

### SDD 기준

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`; 상태 `[승인됨]`, 잠금 `해제`, USER_REVIEW 없음.
- S02, S12, S15와 Evidence Map의 Edge deterministic/race evidence가 적용된다. 이 계획의 ownership gate와 stale no-op test가 S12/S15를, send/disconnect lease race가 S02를, direct diagnostic과 live preflight 분리가 completion evidence 신뢰도를 결정한다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/{edge-smoke,node-smoke,testing-smoke}.md`를 적용한다. Go 명령은 stale cache를 피하도록 `-count=1` 이상을 사용한다.
- temporary config, FIFO, logs는 `mktemp -d` 아래에 두고 `IOP_EDGE_CONFIG`/`IOP_NODE_CONFIG`는 dev diagnostic 전용 override로만 사용한다. tracked `configs/*.yaml`과 기본 bootstrap UX는 변경하지 않는다.
- live preflight는 rc=0일 때만 `normal-10`을 실행한다. rc=3은 exact output, evidence dir, `/models` 및 Control Plane status 재개 조건을 review stub에 기록하는 blocker다.

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

- ready 후 `Get` 재조회는 callback 중 owner 교체/close를 선형화하지 못하고, existing handshake tests는 그 interleaving을 강제하지 않는다.
- provider-pool tests는 pre-send stale generation은 보지만 send가 block된 뒤 disconnect되는 normalized/tunnel handoff를 고정하지 않는다.
- direct fence test는 pending failure는 넓게 보지만 ready 이후 `RunRequest`, `ProviderTunnelRequest`, Ollama와 typed command 각각의 exactly-once payload를 세지 않는다.
- existing smoke는 auxiliary-only이므로 direct `scripts/dev` transcript coverage가 없다.

### 심볼 참조

- 변경 후보: `Registry.MarkDispatchReadyIfClient`, `UnregisterIfClient`, `IsCurrentOwnerGeneration`, `Server.handleReadyRequest`, `bindDisconnectListener`, `Service.HandleNodeConnect`, `HandleNodeDisconnect`, `candidateIsCurrentOwner`, `dispatchProviderPoolRun`, `dispatchProviderPoolTunnel`.
- direct wire callsites는 `submitRunDirect`, `submitProviderTunnelDirect`, `CancelRun`, `TerminateSession`, `UsageStatus`, `sendNodeCommand`, `OllamaAPI`, `ExecuteCommand(agent.command)`이다. `ResolveNode` observation lookup은 변경하지 않는다.

### 분할 판단

- 선택 candidate는 이 FAIL follow-up 하나이며 complete immediate sibling set은 `11+08,13,15_reconnect_candidate_recovery` 하나다.
- write set은 registry/transport ownership primitive, provider-pool dispatch tests, direct fence test, 별도 dev reconnect diagnostic이다. shared mutable state는 current owner generation, queue lease, disconnect ordering, same console session이므로 분리하지 않는다.
- directory predecessor `08,13,15`는 runtime dependency 표기이며 이 active follow-up은 그 결과 위에서만 실행한다. 추가 split은 ownership gate와 proof fixture를 분리해 race coverage를 약화시키므로 하지 않는다.

### 범위 결정 근거

- proto/wire field, Node handler protocol, provider selection policy, generic default config, contract/spec wording은 이번 Required의 해결 수단이 아니므로 수정하지 않는다.
- observational `ResolveNode`와 pending snapshot visibility는 유지한다. Node-bound send/request와 provider-pool handoff만 exact current ready owner gate를 통과한다.
- `scripts/e2e-smoke.sh`는 보조 smoke로 유지하며, full-cycle 증거를 그것으로 재분류하지 않는다. 필요한 direct diagnostic만 `scripts/dev/` 아래에 별도 추가한다.

### 최종 라우팅

- `evaluation_mode=isolated-reassessment`; 이전 lane, grade, 점수, rationale, filename은 이 평가 입력과 하한에서 제외했다.
- build closures: `scope_closed=true`(ownership·handoff·evidence 범위가 명시됨), `context_closed=true`(registry/transport/service/dev harness로 bounded), `verification_closed=true`(deterministic interleaving·wire counts·direct transcript·blocker 조건이 판정 가능), `evidence_trusted=true`(현재 source와 FAIL log가 path/revision 관련성을 가짐), `ownership_closed=true`(단일 task write set), `decision_closed=true`(Milestone/SDD lock 해제; live availability는 revalidation으로 처리). scores=`2,2,2,1,2`; formatter 결과 `local/G09`, `PLAN-local-G09.md`.
- review closures: `scope_closed=true`, `context_closed=true`, `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`. Review는 ownership gate가 stale event와 lease-less success를 배제하는지, every direct wire count가 1인지, auxiliary와 direct evidence가 분리됐는지를 대조한다. scores=`2,2,2,1,2`; formatter 결과 `local/G09`, `CODE_REVIEW-local-G09.md`.

## 구현 체크리스트

- [ ] ready activation/event와 current-owner unregister를 같은 ownership linearization contract로 만들고 stale/pending connected event가 0인 deterministic race를 추가한다.
- [ ] provider-pool normalized/tunnel send와 lease handoff를 current ready owner fence 안에서 직렬화하고 send-block/disconnect race에서 dispatch·lease·counter 결과를 고정한다.
- [ ] pending/ready direct fence를 wire type별 exactly-once payload assertions로 확장한다.
- [ ] 보조 smoke와 구분되는 `scripts/dev` two-process reconnect diagnostic을 추가하고 모든 required verification output 또는 live blocker를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

### [REVIEW_REFACTOR-1] Ready/disconnect ownership을 선형화

문제: `connection_handlers.go:157-175`는 ready transition 뒤 connect callback을 실행하고 `registry.Get`으로 다시 event entry를 고른다. callback이 block된 사이 old close와 새 pending owner가 끼면 event가 current ready owner를 가리키지 않는다.

해결 방법:

```go
// Before: connection_handlers.go:157-175 — transition, callback, and later Get are separate.
generation, transitioned, ok := registry.MarkDispatchReadyIfClient(nodeID, client)
notifyNodeConnected(nodeID, generation)
entry, _ := registry.Get(nodeID)
emitNodeReady(entry)

// After: immutable owner snapshot drives both sides of the lifecycle gate.
ready := registry.MarkDispatchReadyOwner(nodeID, client)
runConnectFor(ready)
registry.WithCurrentOwner(ready, func() { emitNodeReady(ready.Entry) })
```

`UnregisterIfClient`와 ready event publish는 the same ownership gate에서 exact `(node_id, client, generation, dispatch_ready)` snapshot을 검증한다. callback 중 disconnect가 authoritative removal을 완료하면 ready continuation은 no-op이며, 새 pending entry는 connected event 대상이 될 수 없다.

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/node/registry.go`, `registry_test.go`: immutable owner snapshot과 current-ready-owner gate를 제공하고 unregister/ready 검증의 lock order를 문서화한다.
- [ ] `apps/edge/internal/transport/connection_handlers.go`, `integration_test.go`: ready activation/event와 unregister를 gate로 직렬화하고, blocked connect handler → old owner close → new pending register interleaving에서 stale/pending connected event 0, current generation만 lifecycle 1을 단언한다.
- [ ] `apps/edge/internal/bootstrap/reconnect_readiness_integration_test.go`: actual Node reconnect waiter test가 new gate에서도 one dispatch, one terminal, counters 0을 유지하는지 보강한다.

테스트 작성: transport integration test는 channel barrier로 connect handler를 멈춘 뒤 old client close, same node ID의 accepted pending register, handler release 순서를 강제한다. old snapshot의 lifecycle/event가 새 pending을 activation하지 않고, duplicate/stale ready와 stale close가 additional event/pump를 만들지 않는지 확인한다.

### [REVIEW_REFACTOR-2] Dispatch send/handoff를 disconnect fence와 직렬화

문제: `provider_pool.go:168-240`과 `provider_tunnel.go:241-253`는 generation을 확인한 후 subscribe/send/handoff를 별도 실행한다. disconnect가 그 사이 lease를 반환해도 stale client send가 성공하면 handle이 반환될 수 있다.

해결 방법:

```go
// Before: provider_pool.go:168-240 — check and enqueue/handoff are separated.
if !s.candidateIsCurrentOwner(selected) { release() }
entry.Client.Send(request)
reservation.handOff()

// After: current ready owner check, enqueue, and ownership transfer share one fence.
err := s.registry.WithCurrentDispatchOwner(selected, func() error {
    if err := entry.Client.Send(request); err != nil { return err }
    reservation.handOff()
    return nil
})
```

Create subscriptions and tracked reservation before the bounded gate, but release/close them exactly once when the gate rejects or send fails. Do not hold unrelated service/queue locks while waiting for socket I/O; use the ownership gate only for the selected owner and preserve terminal/disconnect release idempotency.

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/service/service.go`, `provider_pool.go`, `provider_tunnel.go`, `run_submit.go`: direct/provider-pool send paths use the current ready owner primitive; normalized/tunnel handoff cannot succeed after authoritative disconnect.
- [ ] `apps/edge/internal/service/model_queue_*` only if a narrow test hook or idempotent release assertion is required; do not change admission policy.
- [ ] `apps/edge/internal/service/run_dispatch_internal_test.go` and relevant provider-pool tests: blocked Send after check, injected owner disconnect, release/dispatch assertions for normalized and tunnel paths.

테스트 작성: fake client send blocks after the gate is entered. The test injects current-owner disconnect before release, then verifies no success handle, no duplicate terminal, no live lease/counter, and no stale dispatch; repeat for normalized RunRequest and ProviderTunnelRequest under `-race`.

### [REVIEW_REFACTOR-3] Every direct wire의 ready-after success를 type별로 증명

문제: `run_dispatch_internal_test.go:1095-1248`는 pending 실패를 넓게 검사하지만 ready 후 total receive count만 확인한다. Run, tunnel, Ollama와 typed command의 actual payload와 exactly-once가 드러나지 않는다.

해결 방법:

```go
// Before: run_dispatch_internal_test.go:1216-1248 — aggregate count hides missing wire paths.
if receivedCount == 0 { t.Error("expected messages") }

// After: each listener records one typed payload and its identity.
assertWireCount(t, received, "RunRequest", 1)
assertWireCount(t, received, "ProviderTunnelRequest", 1)
assertWireCount(t, received, "OLLAMA_API", 1)
```

The same pending fixture remains observationally resolvable but dispatch-ineligible. After `MarkDispatchReadyIfClient`, issue a background direct run, direct tunnel, `OllamaAPI`, and every supported typed command; assert request IDs/run IDs/types/body/path individually and exactly once.

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/service/run_dispatch_internal_test.go`: replace aggregate counting with synchronized per-wire capture; preserve pending zero-send checks.
- [ ] relevant `run_command_test.go` or existing service helper tests only when shared typed-command helpers need focused assertions.

테스트 작성: listeners must respond to request/response commands without timeout and record `RunRequest`, `ProviderTunnelRequest`, `CancelRequest`, and each `NodeCommandRequest` type. Ready-after calls cover `USAGE_STATUS`, `CAPABILITIES`, `SESSION_LIST`, `TRANSPORT_STATUS`, `OLLAMA_API`, `CancelRun`, and `TerminateSession`; each expected type appears exactly once.

### [REVIEW_REFACTOR-4] Auxiliary smoke와 독립된 dev reconnect transcript

문제: `scripts/e2e-smoke.sh`는 temporary profile과 reconnect assertions를 제공하지만 스스로 auxiliary-only라고 선언한다. 따라서 그 PASS는 direct user-flow 완료 evidence가 아니다.

해결 방법:

```bash
# Before: no direct harness exists; e2e-smoke.sh:7 labels its flow auxiliary-only.
# After: a dedicated diagnostic drives the actual dev entrypoints, not the auxiliary script.
IOP_EDGE_CONFIG="$edge_config" scripts/dev/edge.sh < "$console_fifo"
IOP_NODE_CONFIG="$node_config" scripts/dev/node.sh
# message x2 + commands -> restart Node -> re-register -> post-reconnect run
```

`scripts/dev/edge-node-reconnect-diagnostic.sh` creates only `/tmp` fixture/config/log paths, starts the two existing dev entrypoints as separate processes, compares Node/Edge payload lines by run, and fail-fast checks complete-after-last-message. It must label itself a repo-internal diagnostic, not a field bootstrap path; `scripts/e2e-smoke.sh` remains a separately run auxiliary check.

수정 파일 및 체크리스트:

- [ ] `scripts/dev/edge-node-reconnect-diagnostic.sh`: add bounded cleanup, single mock CLI instance config, console FIFO driver, message x2, `/nodes`, `/capabilities`, `/transport`, `/sessions`, `/terminate-session`, Node restart/re-register, post-reconnect foreground run, payload/order comparison, and secret-safe evidence output.
- [ ] reuse only existing `scripts/dev/edge.sh`, `scripts/dev/node.sh`, and fixture conventions; do not change default configs or promote helpers as user bootstrap commands.

테스트 작성: `bash -n` validates the harness syntax. Its runtime command records a direct transcript separately from `IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh`; both must pass for local full-cycle evidence, while live preflight remains conditional.

## 의존 관계 및 구현 순서

- Directory dependency `11+08,13,15_...` is the runtime source of truth; this task starts only after those predecessor completions.
- REVIEW_REFACTOR-1 establishes the ownership primitive. REVIEW_REFACTOR-2 consumes it for handoff, REVIEW_REFACTOR-3 proves the ready boundary, and REVIEW_REFACTOR-4 provides independently classified user-flow evidence.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/node/registry.go`, `registry_test.go` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-2 |
| `apps/edge/internal/transport/connection_handlers.go`, `integration_test.go` | REVIEW_REFACTOR-1 |
| `apps/edge/internal/bootstrap/reconnect_readiness_integration_test.go` | REVIEW_REFACTOR-1 |
| `apps/edge/internal/service/service.go`, `provider_pool.go`, `provider_tunnel.go`, `run_submit.go` | REVIEW_REFACTOR-2 |
| `apps/edge/internal/service/run_dispatch_internal_test.go` and narrowly required existing service tests | REVIEW_REFACTOR-2, REVIEW_REFACTOR-3 |
| `scripts/dev/edge-node-reconnect-diagnostic.sh` | REVIEW_REFACTOR-4 |

## 최종 검증

- workdir `/config/workspace/iop`: `gofmt -d apps/edge/internal/node/registry.go apps/edge/internal/node/registry_test.go apps/edge/internal/transport/connection_handlers.go apps/edge/internal/transport/integration_test.go apps/edge/internal/service/service.go apps/edge/internal/service/provider_pool.go apps/edge/internal/service/provider_tunnel.go apps/edge/internal/service/run_submit.go apps/edge/internal/service/run_dispatch_internal_test.go apps/edge/internal/bootstrap/reconnect_readiness_integration_test.go` → 출력 없음.
- `git diff --check -- apps/edge/internal/node apps/edge/internal/transport apps/edge/internal/service apps/edge/internal/bootstrap scripts/dev` → 출력 없음.
- `bash -n scripts/dev/edge.sh scripts/dev/node.sh scripts/dev/edge-node-reconnect-diagnostic.sh scripts/e2e-smoke.sh` → 출력 없음.
- `go test -count=20 -race -timeout 300s ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/edge/internal/service -run 'Ready|Disconnect|Ownership|Generation|PendingClientDirectSendFence|ProviderPool'` → required ownership/send race and direct wire tests PASS; race warning 0.
- `go test -count=10 -timeout 300s ./apps/edge/internal/bootstrap -run '^TestActualNodeReconnectReadyPumpsQueuedWaiterExactlyOnce$'` → 10/10 PASS; dispatch 1, terminal 1, lease/normal/long/queued counters 0.
- `go test -count=1 -timeout 300s ./apps/edge/... ./apps/node/...` → PASS.
- `go test -count=1 -timeout 600s ./...` → PASS.
- `IOP_DEV_RECONNECT_BIND_TIMEOUT=60 IOP_DEV_RECONNECT_PROFILE=mock ./scripts/dev/edge-node-reconnect-diagnostic.sh` → separate-process direct diagnostic PASS: `/tmp` config, registration, message x2, listed commands, Node restart/re-register, post-reconnect run, Node/Edge payload identity/order, and complete-after-last-message are recorded.
- `IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock IOP_E2E_RECONNECT=1 ./scripts/e2e-smoke.sh` → auxiliary smoke PASS, recorded separately; it does not replace the preceding direct diagnostic.
- `./scripts/e2e-long-context-admission-smoke.sh --preflight --out-dir /tmp/iop-reconnect-linearization-preflight` → rc=3이면 failed output/evidence dir and `/models` plus Control Plane status release conditions are recorded as blocker; rc=0일 때만 다음 명령을 실행한다.
- preflight rc=0일 때 `./scripts/e2e-long-context-admission-smoke.sh --preflight --scenario normal-10 --out-dir /tmp/iop-reconnect-linearization-normal-10` → HTTP success, observed peak/counters, final normal/long/queued 0.

검증 결과를 review stub에 기록하고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
