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

# Pending Node 전송 fence와 결정적 reconnect user-flow

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

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

## 역할 모델

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

## 배경

Ready handshake는 accepted Node를 pending으로 보관하지만, direct run/tunnel/cancel/node-command 경로는 관측용 `ResolveNode`를 계속 써 pending client로 wire message를 보낼 수 있다. 이는 handler 설치 전 어떠한 Node-bound request도 열지 않는 ready contract를 우회한다.

또한 기본 dev config의 generic `cli`는 복수 instance와 모호해 terminal 이전에 멈췄다. 이미 지원되는 `IOP_EDGE_CONFIG`/`IOP_NODE_CONFIG` override와 단일 mock CLI profile을 사용해, 실제 두 프로세스의 요청·명령·재연결 흐름을 끝까지 결정적으로 검증해야 한다.

## 사용자 리뷰 요청 흐름

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

## Archive Evidence Snapshot

- 이전 task: `agent-task/m-provider-resource-admission-ownership-alignment/11+08,13,15_reconnect_candidate_recovery`
- 직전 plan/review: `plan_cloud_G09_1.log`, `code_review_cloud_G10_1.log`; verdict=`FAIL`.
- Required 1: direct `RunRequest`, `ProviderTunnelRequest`, `CancelRequest`, `NodeCommandRequest` sender가 `ResolveNode`를 거쳐 pending accepted client에 전송할 수 있다.
- Required 2: 기본 `scripts/dev` profile은 generic `cli`가 `claude-tui`/`codex`/`codex-exec`에 모호해 terminal·reconnect cycle 전에 실패했다. `IOP_EDGE_CONFIG`/`IOP_NODE_CONFIG` 임시 단일-instance profile이 요구된다.
- 유지할 evidence: ready handshake, provider-pool reconnect waiter/terminal/counter integration, proto/race/전체 Go 회귀는 직전 review에서 통과했다. live preflight rc=3은 성공이 아닌 revalidation blocker다.
- 구현에 세부 이전 문맥이 필요할 때만 위 두 `.log`와 `NEXT_STATE.md`를 읽는다. 다른 archive 탐색은 하지 않는다.

## 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`: accepted reconnect가 dispatch-ready generation만 후보로 복구하고 기존 waiter를 terminal까지 정확히 한 번 실행
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `AGENTS.md`, `agent-ops/rules/project/rules.md`, `agent-ops/rules/common/rules-roadmap.md`, `agent-ops/skills/common/router.md`, `plan/SKILL.md`, `finalize-task-routing/SKILL.md`, review stub/user-review templates.
- `agent-ops/rules/project/domain/edge/rules.md`, `node/rules.md`, `platform-common/rules.md`, `testing/rules.md`; `agent-test/local/rules.md`, `edge-smoke.md`, `node-smoke.md`, `platform-common-smoke.md`, `testing-smoke.md`; `agent-ops/skills/project/e2e-smoke/SKILL.md`.
- 선택 Phase/Milestone, 승인된 `SDD.md`, `agent-contract/index.md`, `agent-contract/inner/edge-node-runtime-wire.md`, `agent-spec/index.md`, `agent-spec/runtime/edge-node-execution.md`, `agent-spec/runtime/provider-pool-config-refresh.md`.
- 직전 `NEXT_STATE.md`, `plan_cloud_G09_1.log`, `code_review_cloud_G10_1.log`.
- `apps/edge/internal/node/registry.go:230-330`, `registry_test.go:273-373`; `apps/edge/internal/service/run_submit.go:44-180`, `provider_tunnel.go:257-310`, `run_cancel.go:27-82`, `node_command.go:36-209`, `control_command.go:45-170`, `provider_resolution.go:1-80`, `status_provider.go:235-246`; 관련 `run_command_test.go`, `run_dispatch_internal_test.go`, `model_queue_admission_test.go`의 targeted test regions.
- `scripts/dev/edge.sh`, `scripts/dev/node.sh`, `scripts/e2e-smoke.sh:1-820`, `configs/edge.yaml:153-247`, `configs/node.yaml`.

### SDD 기준

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`; 상태 `[승인됨]`, 잠금 `해제`, USER_REVIEW 없음.
- S15/`reconnect-candidate-recovery`는 accepted reconnect가 새 generation 후보를 복구해 기존 waiter를 즉시 pump하되 stale/rejected transition은 wake-up하지 않도록 요구한다. S02는 terminal 뒤 lease/counter 정확히 한 번 0 수렴을 요구한다.
- Evidence Map의 S15 deterministic/race evidence는 pending direct wire send 0과 ready 후 send 허용 회귀로 보강하고, S11 관련 live capacity evidence는 preflight 성공 뒤 `normal-10`으로만 갱신한다.

### 테스트 환경 규칙

- `test_env=local`; edge/node/platform-common/testing smoke profile을 적용한다. Go correctness 명령은 cache가 stale 성공을 가리지 않도록 `-count=1` 이상을 사용한다.
- `scripts/dev`는 repo 내부 진단 helper다. `IOP_EDGE_CONFIG`/`IOP_NODE_CONFIG`는 기본 사용자 bootstrap UX가 아니라 `/tmp` deterministic test profile 주입에만 사용한다. 기본 `configs/*.yaml`과 tracked secret/endpoint는 변경하지 않는다.
- live preflight는 `--preflight` rc=0일 때만 `normal-10`을 실행한다. rc=3은 failed command, `/tmp` evidence, `/models`와 Control Plane status의 준비 조건을 기록한 blocker이며 구현 완료 증거가 아니다.

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

- registry test는 `ResolveReady`가 pending을 제외함을 보지만, direct `SubmitRun`, direct provider tunnel, cancel/terminate, typed node command가 모두 그 resolver만 쓰는 end-to-end service regression은 없다.
- `scripts/e2e-smoke.sh`는 임시 mock profile과 message/command를 검증하지만 Node restart 뒤 같은 console session에서 reconnect dispatch를 다시 검증하지 않는다.

### 심볼 참조

- 유지: `Service.ResolveNode`는 `ResolveNodeSnapshot`과 `ExecuteCommand(agent.status)`의 관측 lookup으로 남긴다.
- 추가 후보: `Service.ResolveDispatchReady` 또는 의미가 동등한 service-private helper. `submitRunDirect`, `submitProviderTunnelDirect`, `CancelRun`, `TerminateSession`, `UsageStatus`, `OllamaAPI`, `sendNodeCommand`과 Node-bound `agent.command` precheck가 이를 사용해야 한다.
- wire send callsites: `run_submit.go:177`, `provider_tunnel.go:274`, `run_cancel.go:33,74`, `node_command.go:43,160,188`; provider-pool selection은 이미 `Registry.ResolveReady`/`AllReady`를 사용한다.

### 분할 판단

- 선택 candidate는 FAIL follow-up 하나다. observation/dispatch resolver boundary와 pending/ready wire assertions, two-process reconnect transcript는 같은 readiness invariant와 test fixture를 공유한다.
- complete immediate sibling set은 `11+08,13,15_reconnect_candidate_recovery` 하나다. write set은 Edge service resolver/sender tests와 `scripts/e2e-smoke.sh`의 temporary-profile reconnect coverage다. shared state는 registry `DispatchReady`, live client, console session, queue/terminal ordering이므로 분리하지 않는다.

### 범위 결정 근거

- proto, ready handshake, registry state shape, Node bootstrap supervisor, provider admission/lease algorithm, contract/spec wording은 현재 contract와 prior evidence가 이미 요구를 만족하므로 변경하지 않는다.
- `agent.status`/snapshot처럼 Node에 message를 보내지 않는 observation은 pending ownership을 보여줄 수 있어야 하므로 `ResolveNode`를 유지한다. 모든 actual Node-bound `Send`/`SendRequestTyped` entrypoint만 dispatch-ready resolver로 고정한다.
- generic default config/profile을 고치지 않는다. dev validation fixture는 `/tmp`에서 생성하고 script의 documented env overrides로만 주입한다.

### 최종 라우팅

- `evaluation_mode=isolated-reassessment`; 이전 lane/G/score/rationale/filename을 입력이나 하한으로 사용하지 않았다.
- build closures: `scope_closed=true`(direct send callsites와 pending/ready invariant가 확정됨), `context_closed=true`(service/registry/script test surface가 bounded), `verification_closed=true`(unit·race·two-process·live revalidation 결과를 판정 가능), `evidence_trusted=true`(source callsites와 FAIL evidence가 재현 가능), `ownership_closed=true`(단일 task write set), `decision_closed=true`(Milestone/SDD lock 해제, external live state는 blocker/revalidation으로 처리). scores=`2,1,2,1,2`; formatter 결과 `local/G08`, `PLAN-local-G08.md`.
- review closures: `scope_closed=true`, `context_closed=true`, `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`. Review는 resolver separation, pending wire capture, user-flow transcript와 live blocker/revalidation을 bounded evidence로 대조한다. scores=`2,2,2,1,2`; formatter 결과 `local/G09`, `CODE_REVIEW-local-G09.md`.

## 구현 체크리스트

- [ ] 관측용 `ResolveNode`와 전송용 ready-only resolver를 service 경계에서 분리하고, 모든 Node-bound direct send/request entrypoint를 ready-only resolver로 옮긴다.
- [ ] pending accepted client가 run/tunnel/cancel/terminate/node command를 수신하지 않고 ready 후 동일 요청이 정확히 전송되는 regression을 추가한다.
- [ ] `scripts/e2e-smoke.sh`의 temporary single-instance mock profile을 유지한 채 Node restart/reconnect 뒤 console run을 검증하도록 확장하고, `IOP_EDGE_CONFIG`/`IOP_NODE_CONFIG`를 통한 두-process transcript를 기록한다.
- [ ] 대상/race/전체 Go 회귀, direct two-process user-flow, 보조 mock smoke, live preflight와 조건부 `normal-10`의 실제 output 또는 blocker/revalidation을 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

### [REVIEW_REFACTOR-1] Node 전송 resolver를 ready-only로 고정

문제: `apps/edge/internal/service/status_provider.go:235-236`의 `ResolveNode`는 pending ownership도 반환한다. `run_submit.go:56`, `provider_tunnel.go:258`, `run_cancel.go:28,62`, `node_command.go:37,150,183`의 direct wire paths가 이를 사용해 handler-ready fence를 우회한다.

해결 방법:

```go
// Before: run_submit.go:55-60
entry, err := s.ResolveNode(req.NodeRef)
if err != nil { return nil, err }
return s.dispatchToEntry(entry, req)

// After: dispatch/request paths select only a ready client.
entry, err := s.ResolveDispatchReady(req.NodeRef)
if err != nil { return nil, err }
return s.dispatchToEntry(entry, req)
```

`ResolveDispatchReady`는 `registry.ResolveReady`만 감싼다. `ResolveNode`는 snapshot/status의 observation semantics를 유지한다. `Send` 또는 `SendRequestTyped` 전에 entry를 얻는 모든 direct path는 새 helper를 쓰며, provider-pool의 기존 ready-only candidate selection은 중복 변경하지 않는다.

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/service/status_provider.go`: observation resolver와 dispatch-ready resolver의 이름·주석·delegation을 분리한다.
- [ ] `apps/edge/internal/service/run_submit.go`, `provider_tunnel.go`, `run_cancel.go`, `node_command.go`: Node-bound `RunRequest`, `ProviderTunnelRequest`, 두 `CancelRequest`, 모든 `NodeCommandRequest` sender가 ready-only resolver를 사용하게 한다.
- [ ] `apps/edge/internal/service/control_command.go`: `agent.status`는 observation resolver를 유지하고, 실제 Node command를 시작하는 branch는 pending client를 ready처럼 precheck하지 않도록 정리한다.
- [ ] `apps/edge/internal/service/run_command_test.go`, `run_dispatch_internal_test.go` 또는 기존 test helper가 있는 파일: pending client capture와 ready transition 뒤 send assertions을 추가한다.

테스트 작성: 하나의 pending accepted `TcpClient` fixture에 run, direct tunnel, cancel, terminate, usage/capabilities/session/transport/Ollama command를 요청해 listener 수신이 0이고 ready-only resolution error가 나는지 단언한다. 같은 fixture를 `MarkDispatchReadyIfClient` 뒤 재사용해 각 wire type의 수신과 request/response가 성공하는지 단언한다. `agent.status` snapshot은 pending을 관측할 수 있는 회귀로 유지한다.

### [REVIEW_REFACTOR-2] 결정적 scripts/dev reconnect user-flow

문제: `scripts/dev/edge.sh:6`과 `node.sh:6`은 이미 `IOP_EDGE_CONFIG`/`IOP_NODE_CONFIG` override를 지원하지만, 직전 default config는 generic `cli`가 여러 instance에 매치되어 terminal 전에 실패했다. `scripts/e2e-smoke.sh:115-237`은 `/tmp` 단일 `fake-cli` profile을 만들지만 Node restart 뒤 reconnect run을 명시적으로 검증하지 않는다.

해결 방법:

```bash
# Before: one node process serves the initial mock console cycles only.
IOP_EDGE_CONFIG="$EDGE_CONFIG" scripts/dev/edge.sh
IOP_NODE_CONFIG="$NODE_CONFIG" scripts/dev/node.sh

# After: the same temporary single-instance profile starts both helpers,
# then the harness stops/restarts only Node and proves a post-ready console run.
IOP_EDGE_CONFIG="$EDGE_CONFIG" scripts/dev/edge.sh
IOP_NODE_CONFIG="$NODE_CONFIG" scripts/dev/node.sh
# wait registered -> message x2 + commands -> restart node -> wait registered -> message
```

수정 파일 및 체크리스트:

- [ ] `scripts/e2e-smoke.sh`: existing `/tmp` mock `fake-cli` fixture와 `IOP_EDGE_CONFIG`/`IOP_NODE_CONFIG` two-process launch을 유지한다. Node process tree를 bounded cleanup한 뒤 같은 config로 restart하고 registration, post-reconnect run, payload ordering/complete를 확인하는 deterministic cycle을 추가한다.
- [ ] `scripts/e2e-smoke.sh`: message x2, `/nodes`, `/capabilities`, `/transport`, `/sessions`, persistent `/terminate-session`, reconnect 후 run의 Node-local payload와 Edge payload 내용/순서·complete ordering을 각각 fail-fast 검사한다. evidence output에는 secret이나 private endpoint를 넣지 않는다.
- [ ] test fixture는 `mktemp -d` 아래에만 두고 cleanup한다. 기본 `configs/*.yaml`과 scripts/dev의 field-user 안내는 수정하지 않는다.

테스트 작성: shell script 자체는 `IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock IOP_E2E_RECONNECT=1 ./scripts/e2e-smoke.sh`로 실행한다. mock profile이 단일 `fake-cli` instance임과 reconnection 후 fourth foreground run의 `start → payloads → complete` 및 no-late-message를 assertion한다.

## 의존 관계 및 구현 순서

- task directory `11+08,13,15_...`의 predecessor 08/13/15 completion은 이미 충족됐다.
- REVIEW_REFACTOR-1의 resolver/test fence를 먼저 완료한다. REVIEW_REFACTOR-2는 그 ready contract가 실제 Node restart에서도 지켜지는지 temporary profile로 종결 검증한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/status_provider.go` | REVIEW_REFACTOR-1 |
| `apps/edge/internal/service/run_submit.go`, `provider_tunnel.go`, `run_cancel.go`, `node_command.go`, `control_command.go` | REVIEW_REFACTOR-1 |
| `apps/edge/internal/service/run_command_test.go`, `run_dispatch_internal_test.go` 및 필요한 기존 service test | REVIEW_REFACTOR-1 |
| `scripts/e2e-smoke.sh` | REVIEW_REFACTOR-2 |

## 최종 검증

- workdir `/config/workspace/iop`: `gofmt -d apps/edge/internal/service/status_provider.go apps/edge/internal/service/run_submit.go apps/edge/internal/service/provider_tunnel.go apps/edge/internal/service/run_cancel.go apps/edge/internal/service/node_command.go apps/edge/internal/service/control_command.go apps/edge/internal/service/run_command_test.go apps/edge/internal/service/run_dispatch_internal_test.go` → 출력 없음.
- `git diff --check -- apps/edge/internal/service scripts/e2e-smoke.sh` → 출력 없음.
- `rg --sort path -n 'ResolveNode\\(|ResolveDispatchReady\\(|ResolveReady\\(|Client\\.Send\\(|SendRequestTyped' apps/edge/internal/service` → observation-only callsites와 every Node-bound sender의 ready-only resolver 관계가 확인됨.
- `go test -count=20 -timeout 180s ./apps/edge/internal/service -run 'Pending|Ready|Direct|Tunnel|Cancel|Terminate|Command'` → pending 전 send 0, ready 후 each wire send/request PASS.
- `go test -race -count=1 -timeout 300s ./apps/edge/internal/node ./apps/edge/internal/service ./apps/edge/internal/transport ./apps/edge/internal/bootstrap ./apps/node/internal/transport ./apps/node/internal/bootstrap` → PASS, race warning 0.
- `go test -count=10 -timeout 300s ./apps/edge/internal/bootstrap -run '^TestActualNodeReconnectReadyPumpsQueuedWaiterExactlyOnce$'` → 10/10 PASS; waiter dispatch 1, terminal 1, lease/normal/long/queued 0.
- `go test -count=1 -timeout 300s ./apps/edge/... ./apps/node/...` → PASS.
- `go test -count=1 -timeout 600s ./...` → PASS.
- `IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock IOP_E2E_RECONNECT=1 ./scripts/e2e-smoke.sh` → `/tmp` generated deterministic single-instance profile로 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`가 별도 process에서 실행; message x2, `/nodes`, `/capabilities`, `/transport`, `/sessions`, `/terminate-session`, Node restart/re-register, post-reconnect message의 node/edge payload order와 complete ordering PASS. 이는 direct user-flow evidence로 기록하고 generic default profile을 사용하지 않는다.
- `./scripts/e2e-long-context-admission-smoke.sh --preflight --out-dir /tmp/iop-reconnect-dispatch-preflight` → rc=3이면 failed command/evidence path와 `/models` 및 Control Plane status 준비 조건을 blocker로 기록하고 종료한다; rc=0일 때만 다음 명령으로 진행한다.
- preflight rc=0일 때 `./scripts/e2e-long-context-admission-smoke.sh --preflight --scenario normal-10 --out-dir /tmp/iop-reconnect-dispatch-normal-10` → HTTP success, peak capacity/queue 관측, final normal/long/queued 0. 기존 rc=3 해소 뒤 반드시 재실행한다.

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