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

# Node initial connectivity supervision

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

승인된 SDD의 S16/S17과 아래 reconnect 계약을 먼저 고정한 뒤 구현한다. 구현 에이전트는 코드·테스트 변경, 구현 검증, active review stub의 구현 에이전트 소유 섹션 작성까지만 수행한다. 모두 채운 뒤 active pair를 유지하고 구현 실행을 종료한다. 이 저장 상태가 runtime의 `review-ready` 신호이며 별도 handoff 파일·사용자 메시지를 만들지 않는다. code-review 실행, 역할 전환, 판정, review 전용 섹션 수정, archive/log rename, `USER_REVIEW.md`·`complete.log` 작성, 후속 pair 생성·구현은 금지한다. runtime은 사용자에게 묻거나 준비 완료를 보고하지 않고 별도 review-agent instance를 즉시 배정하며 같은 agent의 새 turn·resume·compaction으로 대체하지 않는다. 선택 Milestone의 `구현 잠금 > 결정 필요`만 linked evidence와 함께 `사용자 리뷰 요청`에 기록하고 같은 file-state handoff를 사용한다. 환경·secret·서비스·일반 scope 변경·follow-up으로 닫을 수 있는 evidence gap은 user-review 사유가 아니다.

## 배경

현재 Node `runtimeSupervisor.start`는 supervisor goroutine을 시작하기 전에 최초 `DialEdge`를 동기 실행하므로 Edge가 늦게 뜨면 Fx startup 자체가 실패한다. reconnect 경로는 `max_attempts <= 0`을 기본 10회로 치환해 명시적 `0=무제한` 계약을 지키지 못하고, 음수/무제한 interval과 retryable 여부도 검증하지 않는다. SDD S16/S17대로 최초 연결부터 단일 supervisor state machine이 담당하고, 구성 오류는 즉시 종료하되 일시적 Edge 부재는 설정 정책에 따라 재시도해야 한다.

## 사용자 리뷰 요청 흐름

Milestone 잠금 결정만 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 active review에 기록한다. 구현 중 직접 user 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:
  - `node-connectivity-supervision`: 최초 연결과 재연결을 동일 supervisor가 관리하고 `max_attempts=0` 무제한 정책을 보장
- 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/node/rules.md`, `agent-ops/rules/project/domain/platform-common/rules.md`, `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`, `agent-test/local/node-smoke.md`, `agent-test/local/platform-common-smoke.md`, `agent-test/local/testing-smoke.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `agent-spec/runtime/edge-node-execution.md`
- `packages/go/config/node_types.go`, `load.go`, `node_config_test.go` — reconnect defaults/unmarshal/validation 범위
- `apps/node/internal/bootstrap/runtime_supervisor.go`, `module.go`, `module_test.go` — Fx lifecycle, initial dial, reconnect/exhaust 범위
- `apps/node/internal/transport/client.go`, `session.go`, `client_test.go` — dial/register failure와 session close 범위
- `scripts/e2e-smoke.sh`, `Makefile` — mock entrypoint와 전체 검증 범위

### SDD 기준

- SDD: `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`, 상태 `[승인됨]`, 잠금 `해제`.
- Acceptance Scenario S16 → `node-connectivity-supervision`: 최초 Edge 부재도 startup 밖 supervisor에서 재시도하고 delayed Edge startup 뒤 같은 Node process가 연결된다.
- Acceptance Scenario S17 → `node-connectivity-supervision`: 생략값은 기본 10회, 양수는 유한 시도, 명시적 `0`은 무제한이며 음수 및 무제한+비양수 interval은 validation error다.
- credential/config/registration rejection 같은 비재시도 오류는 exit 1, 유한 횟수 소진은 exit 1, local shutdown 취소는 정상 종료다.
- Evidence Map은 deterministic retry tests, delayed-network/Edge startup smoke, `-race`, Roadmap Completion evidence를 요구한다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`, `agent-test/local/node-smoke.md`, `agent-test/local/platform-common-smoke.md`, `agent-test/local/testing-smoke.md`를 읽고 Node/platform-common/testing profile의 fresh `-count=1` package/race/전체 회귀를 사용한다.
- 실제 sleep에 의존하지 않는 fake dial/sleeper/clock 또는 짧고 bounded한 deterministic harness를 우선한다.
- mock e2e는 보조 entrypoint이며 delayed initial-connect full-cycle evidence를 대체하지 않는다.

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

- 초기 `DialEdge` 실패 후 같은 process에서 supervisor가 계속 살아 Edge delayed startup을 받아들이는 test가 없다.
- `max_attempts=0`이 기존 10회 경계를 넘어 재시도한 뒤 성공하는 regression test가 없다.
- 생략 기본값과 명시적 0을 구분하는 config test, 음수/무제한 interval validation test가 없다.
- retryable transient failure와 non-retryable local config/registration rejection을 구분하는 typed contract가 없다.
- shutdown이 retry sleep/dial을 취소하고 추가 시도를 막는 single-flight race test가 없다.

### 심볼 참조

- 변경 후보: `config.ReconnectConf`, `config.Load`; `runtimeSupervisor.start/run/reconnect/exhaust`; `DialFunc`, `DialEdge`; Fx lifecycle wiring과 bootstrap tests.
- `apps/node/internal/transport/session.go`의 established-session close signal은 새 supervisor state machine이 계속 소비하되 wire/proto는 바꾸지 않는다.

### 분할 판단

- config presence semantics, dial failure 분류, Fx exit/cancel, single-flight supervisor는 같은 retry 계약의 원자적 변경이다. 어느 하나만 분리하면 잘못된 무제한 loop 또는 startup/error semantics가 노출되므로 단일 task로 유지한다.
- sibling set은 08,09,10,11,12다. 이 task는 connectivity admission의 Node 측 독립 write set이고 선행 task directory dependency가 없다.
- Edge Registry/queue/snapshot 구현과 proto schema는 변경하지 않는다.

### 범위 결정 근거

- 최초와 established-session reconnect에 하나의 state machine과 하나의 in-flight dial만 둔다.
- viper 기본값은 생략 시 10회를 공급하고 명시적 0은 0으로 보존해야 한다. 음수는 자동 보정하지 않고 명시적으로 거부한다.
- local config/credential와 authoritative registration rejection은 non-retryable typed error, network/Edge unavailable은 retryable error로 구분한다. 구체적인 gRPC status mapping은 기존 wire error를 보존하는 최소 범위로 구현한다.
- supervisor exhaustion/fatal은 기존 Fx shutdown/exit path를 재사용하고 사용자가 요청하지 않은 daemon restart 정책은 추가하지 않는다.

### 최종 라우팅

- `evaluation_mode=first-pass`.
- build closures: scope=true, context=false(config presence, typed dial classification, Fx lifecycle와 session concurrency를 함께 유지), verification=true, evidence=true, ownership=true, decision=true. cloud가 context 폐쇄를 해소한다. scores=`2,2,1,1,2`; `cloud/G08` → `PLAN-cloud-G08.md`.
- review도 동일한 closure와 scores로 `cloud/G08` → `CODE_REVIEW-cloud-G08.md`; retry count, fatal/exhaust exit, cancel/single-flight를 넓은 context에서 대조해야 한다.

## 구현 체크리스트

- [x] config load가 reconnect 생략값 10, 양수 유한, 명시적 0 무제한을 구분하고 음수 및 무제한+비양수 interval을 거부한다.
- [x] 최초 연결 전에도 supervisor를 시작해 retryable Edge unavailable을 설정 정책으로 재시도하고 delayed startup 뒤 같은 Node process가 정확히 한 session을 수립한다.
- [x] dial/register 오류를 retryable/non-retryable로 분류해 config/credential/authoritative rejection은 즉시 exit 1, 유한 소진은 exit 1로 수렴한다.
- [x] 모든 상태에서 dial/session은 single-flight이며 shutdown이 sleep/dial을 취소하고 추가 attempt나 실패 exit 없이 정상 종료한다.
- [x] 생략/0/양수/음수, 10회 초과 성공, delayed startup, fatal rejection, exhaustion, shutdown과 `-race`를 deterministic test로 검증한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우고 active pair를 유지한 채 구현 실행을 종료한다. 이 저장 상태가 runtime의 review-ready 신호이며 별도 handoff 파일·사용자 메시지를 만들지 않는다. 구현 에이전트는 code-review를 실행하거나 review 전용 작업을 수행하지 않는다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REFACTOR-1] Reconnect configuration contract and validation

문제: `packages/go/config/load.go:10-21,131-137`의 기본값과 `apps/node/internal/bootstrap/runtime_supervisor.go:94-102`의 runtime 보정이 합쳐져 생략값과 명시적 0을 모두 10회로 취급한다. 음수와 무제한 interval도 load 시점에 차단되지 않는다.

해결 방법: config default/unmarshal이 생략 `max_attempts`만 10으로 만들고 explicit 0은 보존하는지 test로 고정한다. load validation은 `max_attempts < 0`, `interval < 0`, 그리고 unlimited일 때 `interval <= 0`을 명확한 config error로 반환한다. runtime은 config 값을 다시 default/보정하지 않는다.

```go
// Before: packages/go/config/load.go:17-21
if err := v.Unmarshal(&cfg); err != nil { return nil, err }
return &cfg, nil

// After: setDefaults의 omitted=10은 유지하고 explicit 0은 보존한 채 검증한다.
if err := v.Unmarshal(&cfg); err != nil { return nil, err }
if err := validateReconnect(cfg.Reconnect); err != nil { return nil, err }
return &cfg, nil
```

수정 파일 및 체크리스트:

- [x] `packages/go/config/node_types.go`: reconnect field 계약 주석/타입을 기존 구조 안에서 명시한다.
- [x] `packages/go/config/load.go`: invalid reconnect 조합 validation을 기존 Node validation 흐름에 추가한다.
- [x] `packages/go/config/node_config_test.go`: omitted=10, explicit 0=unlimited, positive finite, negative/invalid interval cases를 table test로 추가한다.

테스트 작성: `packages/go/config/node_config_test.go`에 `TestLoadReconnectPolicy`와 `TestLoadRejectsInvalidReconnectPolicy`를 table test로 추가한다. temp YAML fixture로 omitted, explicit 0, positive, negative, unlimited+zero interval을 검증한다. env override는 현재 loader가 바인딩하지 않으므로 추가하지 않는다.

중간 검증: `go test -count=1 ./packages/go/config -run 'Reconnect'`가 통과한다.

### [REFACTOR-2] Typed dial failure classification

문제: `apps/node/internal/transport/client.go:56-81`의 `DialEdge` 오류는 bootstrap에서 transient network failure와 local config/credential/registration rejection을 안정적으로 구분할 계약이 없다.

해결 방법: 기존 transport package 안에 retry classification을 보존하는 typed/wrapped error를 추가한다. 주소/credential 준비와 authoritative registration rejection은 non-retryable, dial/Edge unavailable 같은 일시 실패는 retryable로 표식하고 원래 오류/status는 `errors.Is/As`가 가능하게 wrap한다. bootstrap은 문자열 비교 없이 이 계약만 소비한다.

```go
// Before: apps/node/internal/transport/client.go:57-80
if err != nil { return nil, fmt.Errorf("transport: dial edge %s: %w", addr, err) }
if !resp.GetAccepted() { return nil, fmt.Errorf("transport: register rejected: %s", resp.GetReason()) }

// After: import "errors"를 bootstrap classifier가 사용하고 원인을 unwrap한다.
return nil, wrapConnectError(connectFailureRetryable, err)
return nil, wrapConnectError(connectFailureFatal, errors.New(resp.GetReason()))
```

수정 파일 및 체크리스트:

- [x] `apps/node/internal/transport/client.go`: 최소 typed error/classifier와 `DialEdge` wrapping.
- [x] `apps/node/internal/transport/client_test.go`: representative retryable/non-retryable cases와 unwrap/status 보존.
- [x] `apps/node/internal/bootstrap/runtime_supervisor.go`: classifier 소비, fatal path와 retry path 분기.

테스트 작성: `apps/node/internal/transport/client_test.go`에 `TestDialEdgeClassifiesConnectFailures`를 추가한다. invalid address, closed listener, fake rejected registration fixtures로 retryable bit와 `errors.Is/As` 원인 보존을 검증한다.

중간 검증: `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap -run 'Dial|Retry|Fatal'`이 통과한다.

### [REFACTOR-3] Initial-connect supervisor and bounded lifecycle

문제: `apps/node/internal/bootstrap/runtime_supervisor.go:33-57,59-87`의 `start`가 최초 dial을 동기 실행해 transient failure를 Fx startup failure로 만들고, 별도 reconnect loop는 established session 이후에만 동작한다.

해결 방법: OnStart는 cancellable supervisor를 먼저 단 한 번 시작하고 하나의 loop가 initial dial→established session wait→reconnect를 직렬 관리한다. finite 정책은 정의된 attempt 수에서 성공 또는 exit 1로 끝나고 unlimited 0은 positive interval로 취소될 때까지 지속한다. non-retryable 오류/유한 소진만 기존 shutdown+exit code 경로를 호출하고 local OnStop 취소는 정상 종료한다.

```go
// Before: apps/node/internal/bootstrap/runtime_supervisor.go:36-50
owner, err := connectRuntime(ctx, s.cfg, s.logger, s.dialer)
if err != nil { return fmt.Errorf("bootstrap: %w", err) }
go s.run(supCtx, owner.sess)

// After: OnStart는 supervisor ownership만 확립하고 initial dial도 loop가 수행한다.
supCtx, cancel := context.WithCancel(context.Background())
s.cancelSupervisor = cancel
go s.run(supCtx)
```

수정 파일 및 체크리스트:

- [x] `apps/node/internal/bootstrap/runtime_supervisor.go`: unified state machine, exact attempt accounting, cancel-aware wait/dial, single-flight 보장.
- [x] `apps/node/internal/bootstrap/module.go`: Fx lifecycle가 background initial connection과 fatal/exhaust reporting을 올바르게 소유하도록 최소 wiring 조정. (start non-blocking 전환으로 기존 OnStart/OnStop wiring이 그대로 올바르게 동작해 추가 코드 변경 불필요.)
- [x] `apps/node/internal/bootstrap/module_test.go`: initial unavailable→delayed success, >10 unlimited success, finite exact exhaustion, fatal once, shutdown cancel/no-extra-attempt.
- [x] 필요 시 기존 test helper만 확장하고 production scheduler abstraction을 새 파일로 분리하지 않는다.

테스트 작성: `apps/node/internal/bootstrap/module_test.go`에 `TestSupervisorRetriesInitialDialUntilDelayedSuccess`, `TestSupervisorUnlimitedAttemptsPassesTen`, `TestSupervisorFiniteExhaustionExitsOne`, `TestSupervisorFatalFailureDoesNotRetry`, `TestSupervisorShutdownCancelsRetry`를 추가한다. fake dialer/sleeper/shutdowner와 delayed listener fixture로 concurrent dial 최대 1, exact attempts, exit code, shutdown 후 no-extra-attempt를 검증한다.

중간 검증: `go test -race -count=1 ./apps/node/internal/bootstrap -run 'Supervisor|Initial|Reconnect|Shutdown'`가 통과한다.

## 의존 관계 및 구현 순서

- 외부 predecessor `complete.log` dependency는 없다.
- REFACTOR-1로 값 의미를 고정하고 REFACTOR-2로 오류 분류를 만든 뒤 REFACTOR-3이 두 계약을 소비한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `packages/go/config/node_types.go`, `load.go`, `node_config_test.go` | REFACTOR-1 |
| `apps/node/internal/transport/client.go`, `client_test.go` | REFACTOR-2 |
| `apps/node/internal/bootstrap/runtime_supervisor.go`, `module.go`, `module_test.go` | REFACTOR-2, REFACTOR-3 |

## 최종 검증

- `gofmt -d packages/go/config/node_types.go packages/go/config/load.go packages/go/config/node_config_test.go apps/node/internal/transport/client.go apps/node/internal/transport/client_test.go apps/node/internal/bootstrap/runtime_supervisor.go apps/node/internal/bootstrap/module.go apps/node/internal/bootstrap/module_test.go` → 출력 없음.
- `go test -count=1 ./packages/go/config -run 'Reconnect'` → PASS.
- `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap` → PASS.
- `go test -race -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap` → PASS.
- `go test -count=1 ./apps/node/...` → PASS.
- `go test -count=1 ./packages/go/...` → PASS.
- `go test -count=1 ./...` → PASS.
- `IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh` → 보조 entrypoint PASS 또는 명시 blocker.
- same Node process가 Edge delayed startup 뒤 연결되는 bounded full-cycle smoke → PASS; 불가하면 exact blocker와 deterministic integration evidence를 review에 기록한다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우고 active pair를 유지한 채 구현 실행을 종료한다. 이 저장 상태가 runtime의 `review-ready` 신호이며 별도 handoff 파일·사용자 메시지를 만들지 않는다. 구현 에이전트는 code-review를 실행하거나 review 전용 작업을 수행하지 않고, runtime은 별도 review 에이전트 instance에 즉시 인계한다.
