<!-- task=13_transport_idle_heartbeat plan=0 tag=HBT -->

# Code Review Reference - HBT

> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Do not modify or check the `코드리뷰 전용 체크리스트`; it is owned by the review agent only.
> Follow the ownership table at the bottom of this file for which sections you own.

## 개요

date=2026-05-17
task=13_transport_idle_heartbeat, plan=0, tag=HBT

## 이 파일을 읽는 리뷰 에이전트에게

각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.

1. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_N.log` (N = 기존 code_review_*.log 수)
2. `PLAN-cloud-G09.md` → `plan_cloud_G09_M.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 routed plan + review 스텁 작성.

어떤 판정에서도 아카이브를 건너뛰지 마세요. PASS/WARN/FAIL 모두 `코드리뷰 결과` append 후 active plan/review 파일을 먼저 아카이브하고, 그 다음 `complete.log` 또는 다음 plan/review 파일을 작성해야 합니다.
아카이브와 다음 상태 파일 작성이 끝난 뒤, archived `code_review_*.log`의 `코드리뷰 전용 체크리스트`를 모두 체크한 다음 보고하세요.

---

## 구현 항목별 완료 여부

| 항목 | 완료 여부 |
|------|---------|
| [HBT-1] heartbeat idle regression test와 진단 경계 작성 | [x] |
| [HBT-2] heartbeat timeout 원인 수정 | [x] |
| [HBT-3] smoke와 real idle acceptance 재검증 | [x] |

## 구현 체크리스트

- [x] [HBT-1] heartbeat idle regression을 빠른 transport test로 재현하거나, 재현 불가 시 원인 경계와 추가 계측을 기록한다.
- [x] [HBT-2] repo 코드 범위에서 heartbeat timeout 원인을 고치고 기존 transport API 동작을 유지한다.
- [x] [HBT-3] mock idle, transport tests, real Gemini 10분 idle을 실제로 재검증한다.
- [x] 중간 검증과 최종 검증 명령을 모두 실행하고 실제 stdout/stderr를 CODE_REVIEW에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 코드리뷰 전용 체크리스트

> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.

- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_0.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G09_0.log`로 아카이브한다.
- [x] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. (FAIL 판정이므로 해당 없음)
- [x] WARN/FAIL이면 다음 active `PLAN-cloud-G09.md`와 `CODE_REVIEW-cloud-G09.md`를 작성하고 `complete.log`를 작성하지 않는다.

## 계획 대비 변경 사항

- **HBT-1 regression test 구성 변경**: 계획서는 "1초 interval, 1초 wait" 구성을 제안했으나, 그 구성으로 작성한 초기 버전을 실행해 보니 라이브러리 race(아래 설계 결정 참고)가 약 1/5 확률로 fail-flaky하게 잡혀서 CI 안정성 문제가 있었다. 그래서 production fix(`heartbeatWaitSec > heartbeatIntervalSec`)와 동일한 ratio를 빠른 시간 축에서 재현하도록 `intervalSec=1, waitSec=2, idleWindow=5s`로 변경했다. 또한 fix의 invariant 자체를 영구적으로 강제하기 위해 `TestHeartbeatWaitExceedsInterval`을 추가했다(`heartbeatWaitSec > heartbeatIntervalSec` 보장). `apps/node/internal/transport/heartbeat_test.go`에 코멘트로 이유를 기록했다.
- **HBT-2 helper 추가 안 함**: 계획서는 "testable heartbeat config helper" 도입을 후보 1번으로 들었으나, regression test는 `toki.NewTcpClient`를 직접 호출해서 자체 interval/wait를 사용할 수 있었고 production 상수 변경만으로 fix가 완결되어 별도 helper를 추가할 이유가 사라졌다. `DialEdge` signature와 `NewServer` signature는 변경하지 않았다.
- **README "30초 간격" 문구 미수정**: 계획 명시대로 interval 의미는 그대로 30초이므로 문서 수정은 하지 않았다. wait 상수가 10→45로 바뀐 점은 코드 코멘트로만 설명했다. `agent-task/15_node_command_docs_accuracy`와 충돌하지 않도록 README/문서는 손대지 않았다.
- **`integration_test.go`, `server_test.go` 미수정**: 기존 disconnect metadata test가 그대로 통과하여 수정이 필요 없었다.

## 주요 설계 결정

### 원인 분석 — `common-proto-socket` library race

12번 task에서 idle 약 160초에 `transport_close_reason="heartbeat_timeout"`이 나온 근본 원인은 라이브러리 `base_client.go:66-83`의 `sendHeartBeat()`와 readLoop 사이의 race이다.

```
1. timer 콜백이 fire → hbMu lock → waitingHBResponse=true → unlock → c.Send(&HeartBeat{})
2. Send 완료(write 큐가 flush됨) 후 wait-timer 설치를 위해 다시 hbMu lock 시도
3. (race window) peer가 HB 응답을 보내고 readLoop가 OnReceivedData → onHeartBeat 호출:
     - hbMu lock → waitingHBResponse=false → unlock
   이어 readLoop의 `c.sendHeartBeat()`가:
     - hbMu lock → 기존 timer Stop → 새 interval timer 설치 → unlock
4. 콜백이 hbMu lock 재획득 → 방금 설치된 interval timer를 Stop → wait-timer로 교체
5. 해당 wait-timer는 waitingHBResponse 플래그를 검사하지 않고 무조건
   `if c.IsAlive() { c.onDisconnected() }`만 호출함
6. waitSec 후 disconnect — 응답을 받았는데도 끊김
```

160초가 걸린 이유는 매 30초 cycle마다 race가 항상 터지는 게 아니라 OS 스케줄링/네트워크 latency에 따라 가끔 step 3이 step 4보다 먼저 끝나서 stray wait-timer가 살아남는 cycle에서만 disconnect가 일어나기 때문이다. local idle에서는 4~5 cycle 즈음에 한 번 적중한다.

### 수정 방향

계획에 명시된 대로 `common-proto-socket`은 직접 수정하지 않고 repo 쪽 상수만 조정했다.

핵심 invariant: **`heartbeatWaitSec > heartbeatIntervalSec`**.

이 조건을 만족하면, race가 일어나서 stray wait-timer가 설치되더라도, 그 wait-timer가 fire하기 전에 peer의 다음 정상 heartbeat(우리가 받은 응답으로 peer 타이머가 reset된 후 정확히 `heartbeatIntervalSec` 뒤)이 readLoop를 통해 도착해 `sendHeartBeat()`가 stray wait-timer를 interval timer로 교체한다. 즉 race가 일어나도 peer가 매 cycle 우리를 구해준다.

production 값: `heartbeatIntervalSec=30`, `heartbeatWaitSec=45`. 양쪽(edge, node)이 동일 ratio여야 하므로 두 파일 모두 동시에 조정했다.

regression test도 동일 ratio(`1 < 2`)를 빠른 timescale에서 검증한다. 추가로 `TestHeartbeatWaitExceedsInterval`이 상수 단위로 invariant를 강제하여 누군가 wait를 줄여도 즉시 fail하도록 했다.

### 수정 파일

| 파일 | 변경 |
|------|------|
| `apps/node/internal/transport/client.go` | `heartbeatWaitSec` 10→45, 이유 주석 추가 |
| `apps/edge/internal/transport/server.go` | `heartbeatWaitSec` 10→45, 이유 주석 추가 |
| `apps/node/internal/transport/heartbeat_test.go` | 신규: `TestHeartbeatSurvivesIdleAfterNodeCommand`, `TestHeartbeatWaitExceedsInterval` |

## 리뷰어를 위한 체크포인트

- 10분 real idle 실패가 실제로 사라졌는가.
- heartbeat wait 조정 또는 helper 추가가 `DialEdge` public API를 깨지 않는가.
- 새 transport regression test가 heartbeat interval을 실제로 지나며 검증하는가.
- disconnect reason metadata가 기존 tests와 운영 로그 의미를 유지하는가.

## 검증 결과

필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.

### HBT-1 중간 검증
```bash
$ go test -count=1 ./apps/node/internal/transport
ok  	iop/apps/node/internal/transport	5.053s
```

### HBT-2 중간 검증
```bash
$ go test -count=1 ./apps/node/internal/transport ./apps/edge/internal/transport
ok  	iop/apps/node/internal/transport	5.069s
ok  	iop/apps/edge/internal/transport	0.005s
```

### HBT-3 중간 검증
```bash
$ mkdir -p /tmp/iop-hbt-logs
$ bash -n scripts/e2e-smoke.sh
(no output)
$ IOP_E2E_IDLE_SECONDS=2 make test-e2e > /tmp/iop-hbt-logs/mock-idle-2.log 2>&1
(exit 0)
$ grep -E "idle monitor|idle window|Smoke test|Real profile" /tmp/iop-hbt-logs/mock-idle-2.log
[e2e] idle monitor: sleeping 2s (edge baseline=26 node baseline=30)
[e2e] Smoke test PASSED.
```

### 최종 검증
```bash
$ go test -count=1 ./apps/node/internal/transport ./apps/edge/internal/transport
ok  	iop/apps/node/internal/transport	5.069s
ok  	iop/apps/edge/internal/transport	0.005s

$ go test -count=1 ./...
?   	iop/apps/control-plane/cmd/control-plane	[no test files]
?   	iop/apps/edge/cmd/edge	[no test files]
ok  	iop/apps/edge/internal/bootstrap	0.006s
ok  	iop/apps/edge/internal/events	0.005s
ok  	iop/apps/edge/internal/node	0.007s
ok  	iop/apps/edge/internal/opsconsole	0.006s
ok  	iop/apps/edge/internal/service	0.004s
ok  	iop/apps/edge/internal/transport	0.011s
?   	iop/apps/node/cmd/node	[no test files]
ok  	iop/apps/node/internal/adapters	0.005s
ok  	iop/apps/node/internal/adapters/cli	39.053s
?   	iop/apps/node/internal/adapters/cli/internal/testutil	[no test files]
ok  	iop/apps/node/internal/adapters/cli/status	30.880s
?   	iop/apps/node/internal/adapters/mock	[no test files]
?   	iop/apps/node/internal/adapters/ollama	[no test files]
?   	iop/apps/node/internal/adapters/vllm	[no test files]
ok  	iop/apps/node/internal/bootstrap	0.160s
ok  	iop/apps/node/internal/node	0.009s
ok  	iop/apps/node/internal/router	0.004s
?   	iop/apps/node/internal/runtime	[no test files]
ok  	iop/apps/node/internal/store	0.034s
ok  	iop/apps/node/internal/transport	5.065s
?   	iop/apps/worker/cmd/worker	[no test files]
?   	iop/packages/auth	[no test files]
ok  	iop/packages/config	0.009s
?   	iop/packages/events	[no test files]
?   	iop/packages/jobs	[no test files]
?   	iop/packages/metadata	[no test files]
?   	iop/packages/observability	[no test files]
?   	iop/packages/policy	[no test files]
?   	iop/packages/version	[no test files]
?   	iop/proto/gen/iop	[no test files]

$ mkdir -p /tmp/iop-hbt-logs
(no output)

$ bash -n scripts/e2e-smoke.sh
(no output)

$ IOP_E2E_IDLE_SECONDS=2 make test-e2e > /tmp/iop-hbt-logs/mock-idle-2.log 2>&1
(exit 0)

$ grep -E "idle monitor|idle window|Smoke test|Real profile" /tmp/iop-hbt-logs/mock-idle-2.log
[e2e] idle monitor: sleeping 2s (edge baseline=26 node baseline=30)
[e2e] Smoke test PASSED.

$ IOP_E2E_PROFILE=gemini IOP_E2E_IDLE_SECONDS=600 make test-e2e > /tmp/iop-hbt-logs/gemini-idle-600.log 2>&1
(exit 0)

$ grep -E "idle monitor|idle window|Real profile|Smoke test|heartbeat|transport_close|disconnected from edge" /tmp/iop-hbt-logs/gemini-idle-600.log
[e2e] idle monitor: sleeping 600s (edge baseline=26 node baseline=29)
{"level":"info","ts":1778999851.5832934,"caller":"transport/server.go:176","msg":"node unregistered","node_id":"test-node","transport_close_reason":"local_close","transport_close_error":"read tcp 127.0.0.1:30637->127.0.0.1:55178: use of closed network connection"}
{"level":"info","ts":1778999851.5834274,"caller":"transport/session.go:89","msg":"disconnected from edge","transport_close_reason":"remote_closed","transport_close_error":"EOF"}
[edge-event] disconnected reason="transport_closed" transport_close_reason="remote_closed" transport_close_error="EOF"
[e2e] Real profile (gemini) status: PASSED
[e2e] Smoke test PASSED.

$ git diff --check
(exit 0)
```

**해석**: Gemini real-idle 로그에 나타난 disconnect는 idle window 종료 후 `/exit` 정상 종료에 의한 `local_close`/`remote_closed` 쌍이다. timestamp 비교상 마지막 `/status` 직후(`ts=1778999248.57`)부터 disconnect(`ts=1778999851.58`)까지 약 603초 — 600초 idle window 전체를 지나친 뒤 종료 단계에서만 끊겼다. `heartbeat_timeout` 토큰은 로그 전체에서 0회 등장. 12번에서 보였던 idle 중 `heartbeat_timeout` regression이 사라졌다.

---

> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave the review-agent-only checklist unchanged.

---

## 코드리뷰 결과

### 종합 판정

FAIL

### 차원별 평가

| 차원 | 평가 | 근거 |
|---|---|---|
| correctness | Fail | 현재 fix는 단일 stray wait timer에는 효과가 있지만, 양쪽 peer가 같은 heartbeat cycle에서 모두 wait timer로 전환되는 경우 false `heartbeat_timeout` 가능성이 남는다. |
| completeness | Fail | 원인으로 지목한 실제 heartbeat state machine 자체가 응답 수신 후 stale wait timer를 설치/발화하는 문제를 여전히 허용한다. |
| test coverage | Fail | 신규 테스트는 custom `intervalSec=1, waitSec=2`와 NodeCommand 왕복으로 desync된 happy path를 검증하며, synchronized two-peer heartbeat race와 edge-side production invariant를 고정하지 않는다. |
| API contract | Pass | `DialEdge`/`NewServer` signature와 wire message contract 변경은 없다. |
| code quality | Warn | 실제 빌드는 `go.mod` replace로 `/config/workspace/proto-socket/go`를 사용하지만 주석/문서의 `base_client.go:66-83` 라인 참조는 현재 실제 소스 라인과 맞지 않는다. |
| plan deviation | Pass | 계획한 repo-side 상수 조정과 transport regression test 추가는 수행되었다. |
| verification trust | Pass | 리뷰어가 주요 검증을 재실행했고, 기존 Gemini idle 로그의 `heartbeat_timeout` 부재도 확인했다. |

### 발견된 문제

- Required — `/config/workspace/proto-socket/go/base_client.go:121`: `sendHeartBeat` timer callback은 heartbeat 전송 후 `/config/workspace/proto-socket/go/base_client.go:133`에서 wait timer를 설치하고, wait timer는 `/config/workspace/proto-socket/go/base_client.go:134`-`135`처럼 `waitingHBResponse` 상태를 확인하지 않고 연결을 끊는다. `heartbeatWaitSec > heartbeatIntervalSec`는 한쪽에만 stale wait timer가 남는 경우에는 peer의 다음 interval heartbeat가 이를 취소할 수 있지만, 양쪽 peer의 interval callback이 같은 cycle에 들어가면 양쪽 모두 응답 heartbeat를 처리해 `waitingHBResponse=false`가 된 뒤 각자 interval timer를 wait timer로 덮어쓸 수 있다. 이 상태에서는 다음 interval heartbeat를 보낼 timer가 양쪽 모두 사라져 wait timer 만료 시 false `heartbeat_timeout`이 재발할 수 있다. 따라서 이번 변경은 root cause를 완전히 제거했다고 보기 어렵다.
- Required — `apps/node/internal/transport/heartbeat_test.go:17`: 신규 `TestHeartbeatSurvivesIdleAfterNodeCommand`는 production 상수를 쓰지 않고 custom `intervalSec=1, waitSec=2`를 사용하며, NodeCommand request/response 후 desync된 idle path만 검증한다. 또한 `TestHeartbeatWaitExceedsInterval`은 `apps/node/internal/transport`의 상수만 검사해서 `apps/edge/internal/transport/server.go:19`의 production invariant가 깨져도 unit-level에서 바로 실패하지 않는다. 후속 작업에서는 synchronized two-peer race를 재현하는 regression과 edge/node 양쪽 invariant를 함께 고정해야 한다.
- Nit — `apps/node/internal/transport/client.go:20`: 현재 빌드 기준 실제 dependency는 `go.mod` replace로 `/config/workspace/proto-socket/go/base_client.go`이며 race 관련 코드는 대략 121-138 라인이다. 주석의 `base_client.go:66-83`은 현재 소스와 어긋나므로 후속 수정 시 경로/라인 참조를 최신화하는 편이 좋다.

### 리뷰어 추가 검증

```bash
$ go test -count=1 ./apps/node/internal/transport ./apps/edge/internal/transport
ok  	iop/apps/node/internal/transport	5.044s
ok  	iop/apps/edge/internal/transport	0.005s
```

```bash
$ bash -n scripts/e2e-smoke.sh && git diff --check
# no output
```

```bash
$ go test -count=1 ./...
?   	iop/apps/control-plane/cmd/control-plane	[no test files]
?   	iop/apps/edge/cmd/edge	[no test files]
ok  	iop/apps/edge/internal/bootstrap	0.015s
ok  	iop/apps/edge/internal/events	0.008s
ok  	iop/apps/edge/internal/node	0.009s
ok  	iop/apps/edge/internal/opsconsole	0.012s
ok  	iop/apps/edge/internal/service	0.010s
ok  	iop/apps/edge/internal/transport	0.014s
?   	iop/apps/node/cmd/node	[no test files]
ok  	iop/apps/node/internal/adapters	0.008s
ok  	iop/apps/node/internal/adapters/cli	39.120s
?   	iop/apps/node/internal/adapters/cli/internal/testutil	[no test files]
ok  	iop/apps/node/internal/adapters/cli/status	30.962s
?   	iop/apps/node/internal/adapters/mock	[no test files]
?   	iop/apps/node/internal/adapters/ollama	[no test files]
?   	iop/apps/node/internal/adapters/vllm	[no test files]
ok  	iop/apps/node/internal/bootstrap	0.163s
ok  	iop/apps/node/internal/node	0.011s
ok  	iop/apps/node/internal/router	0.005s
?   	iop/apps/node/internal/runtime	[no test files]
ok  	iop/apps/node/internal/store	0.029s
ok  	iop/apps/node/internal/transport	5.042s
?   	iop/apps/worker/cmd/worker	[no test files]
?   	iop/packages/auth	[no test files]
ok  	iop/packages/config	0.008s
?   	iop/packages/events	[no test files]
?   	iop/packages/jobs	[no test files]
?   	iop/packages/metadata	[no test files]
?   	iop/packages/observability	[no test files]
?   	iop/packages/policy	[no test files]
?   	iop/packages/version	[no test files]
?   	iop/proto/gen/iop	[no test files]
```

```bash
$ IOP_E2E_IDLE_SECONDS=2 make test-e2e > /tmp/iop-hbt-review-logs/mock-idle-2.log 2>&1
$ grep -E "idle monitor|idle window|Smoke test|Real profile" /tmp/iop-hbt-review-logs/mock-idle-2.log
[e2e] idle monitor: sleeping 2s (edge baseline=26 node baseline=30)
[e2e] Smoke test PASSED.
```

```bash
$ grep -E "idle monitor|idle window|Real profile|Smoke test|heartbeat|transport_close|disconnected from edge" /tmp/iop-hbt-logs/gemini-idle-600.log
[e2e] idle monitor: sleeping 600s (edge baseline=26 node baseline=29)
{"level":"info","ts":1778999851.5832934,"caller":"transport/server.go:176","msg":"node unregistered","node_id":"test-node","transport_close_reason":"local_close","transport_close_error":"read tcp 127.0.0.1:30637->127.0.0.1:55178: use of closed network connection"}
{"level":"info","ts":1778999851.5834274,"caller":"transport/session.go:89","msg":"disconnected from edge","transport_close_reason":"remote_closed","transport_close_error":"EOF"}
[edge-event] disconnected reason="transport_closed" transport_close_reason="remote_closed" transport_close_error="EOF"
[e2e] Real profile (gemini) status: PASSED
[e2e] Smoke test PASSED.

$ grep -c "heartbeat_timeout" /tmp/iop-hbt-logs/gemini-idle-600.log
0
```

```bash
$ go test -run 'TestHeartbeatSurvivesIdleAfterNodeCommand|TestHeartbeatWaitExceedsInterval' -count=20 ./apps/node/internal/transport
ok  	iop/apps/node/internal/transport	101.159s
```

### 다음 단계

FAIL: synchronized two-peer heartbeat race를 제거하는 후속 `PLAN-cloud-G09.md` / `CODE_REVIEW-cloud-G09.md` 루프를 진행한다.
