<!-- task=13_transport_idle_heartbeat plan=1 tag=REVIEW_HBT_SYNC -->

# Code Review Reference - REVIEW_HBT_SYNC

> **[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.

## 개요

date=2026-05-17
task=13_transport_idle_heartbeat, plan=1, tag=REVIEW_HBT_SYNC

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

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

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`의 `코드리뷰 전용 체크리스트`를 모두 체크한 다음 보고하세요.

---

## 구현 항목별 완료 여부

| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_HBT_SYNC-1] heartbeat wait timer state bug 수정 | [x] |
| [REVIEW_HBT_SYNC-2] synchronized two-peer regression과 양쪽 invariant 테스트 추가 | [x] |
| [REVIEW_HBT_SYNC-3] 검증과 문서 기록 갱신 | [x] |

## 구현 체크리스트

- [x] [REVIEW_HBT_SYNC-1] stale wait timer가 response 처리 후 false `heartbeat_timeout`을 만들지 않도록 수정한다.
- [x] [REVIEW_HBT_SYNC-1] 실제 peer 무응답 상황의 `heartbeat_timeout` disconnect semantics를 유지한다.
- [x] [REVIEW_HBT_SYNC-2] synchronized two-peer race 또는 equivalent stale wait timer scenario를 테스트로 고정한다.
- [x] [REVIEW_HBT_SYNC-2] edge/node production heartbeat invariant를 양쪽 모두 테스트로 고정한다.
- [x] [REVIEW_HBT_SYNC-3] 관련 주석과 설계 기록의 dependency path/line reference를 실제 빌드 기준으로 갱신한다.
- [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_1.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G09_1.log`로 아카이브한다.
- [x] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] WARN/FAIL이면 다음 active `PLAN-cloud-G09.md`와 `CODE_REVIEW-cloud-G09.md`를 작성하고 `complete.log`를 작성하지 않는다. (PASS 판정이므로 해당 없음)

## 계획 대비 변경 사항

- **HBT-2 wait 상수 그대로 유지**: 이번 루프에서 발견한 진짜 root cause는 library wait-timer가 `waitingHBResponse`를 검사하지 않는 것이었다. 그 fix를 적용한 뒤에도 이전 루프의 `wait > interval` invariant는 defence-in-depth로 그대로 둔다. `apps/node/internal/transport/client.go`와 `apps/edge/internal/transport/server.go`의 상수 코멘트를 library 수정 사실과 일치하도록 다시 적었다.
- **regression 테스트 파일 분리**: 계획은 "`apps/edge/internal/transport/heartbeat_test.go`"를 후보 파일로 들었다. invariant test만 들어가므로 짧은 파일로 분리해서 두었다(node 쪽은 기존 `heartbeat_test.go`를 그대로 사용). 의미상 노드 파일과 대칭이며, 별도 fixture 의존성이 없어 추가 비용이 없다.
- **proto-socket library 수정**: `go.mod` replace가 가리키는 실제 sibling repo(`/config/workspace/proto-socket/go/base_client.go`)를 직접 수정하고 같은 repo의 `go/test/heartbeat_test.go`에 regression test를 추가했다. 작업 전에 `/config/workspace/proto-socket/CLAUDE.md`와 `agent-ops/rules/project/{rules,domain/go/rules}.md`를 확인하고 `go/` 도메인 범위 내(`go/base_client.go`, `go/test/heartbeat_test.go`)만 건드렸다. protocol/packets/tools 도메인은 손대지 않았다.

## 주요 설계 결정

### root cause — wait timer가 stale 상태로 fire

`/config/workspace/proto-socket/go/base_client.go`의 `sendHeartBeat()` callback은 아래 순서로 동작한다.

1. `waitingHBResponse = true` (lock 안에서)
2. `Send(&packets.HeartBeat{})` — write queue가 flush될 때까지 blocking
3. 다시 lock 잡고 `hbTimer`를 `heartbeatWait`짜리 timer로 교체

3단계 직전에 peer가 응답을 보내면 readLoop가
- `OnReceivedData → onHeartBeat`: `waitingHBResponse=false`로 reset 후 echo 안 함
- 이어 readLoop의 `sendHeartBeat()`가 lock을 잡아 timer를 새 interval timer로 교체

까지 끝낸 뒤 callback이 lock을 다시 잡고 그 interval timer를 wait timer로 또 덮어쓰는 race가 존재한다. 이 wait timer는 `waitingHBResponse=false`인 상태에서 fire하고, 기존 코드는 상태와 무관하게 `onDisconnected(DisconnectReasonHeartbeatTimeout, ...)`를 호출해 false `heartbeat_timeout`을 만든다.

### 수정 방향 — wait timer callback이 state 확인

```go
c.hbTimer = NewHeartbeatTimer(c.heartbeatWait, func() {
    if !c.IsAlive() {
        return
    }
    c.hbMu.Lock()
    if !c.waitingHBResponse {
        // stale: 응답이 이미 도착했음. interval timer를 다시 set.
        c.hbMu.Unlock()
        c.sendHeartBeat()
        return
    }
    c.waitingHBResponse = false
    c.hbMu.Unlock()
    c.onDisconnected(DisconnectReasonHeartbeatTimeout,
        fmt.Errorf("no heartbeat response within %s", c.heartbeatWait))
})
```

- `waitingHBResponse=false`이면 stale로 판정하고 정상 interval timer 재무장. 더 이상 false disconnect가 발생하지 않는다.
- `waitingHBResponse=true`이면 peer가 진짜로 응답하지 않은 것이므로 기존과 동일하게 `DisconnectReasonHeartbeatTimeout`으로 disconnect한다. `DisconnectInfo` reason/error 의미는 동일하게 유지된다.
- synchronized two-peer race에서도 같은 로직으로 처리된다. 두 peer가 동시에 fire해서 서로의 ping을 response로 흡수해 `waitingHBResponse=false`가 된 경우, 양쪽 wait timer가 fire하더라도 stale path를 타고 interval timer로 재무장한다.

### regression 테스트

- `/config/workspace/proto-socket/go/test/heartbeat_test.go::TestHeartbeatSurvivesPairedIdle`
  - 실제 TCP loop으로 두 TcpClient를 paired로 띄우고 1s interval, 1s wait로 4초 동안 idle한다.
  - 양쪽 모두 `IsAlive`여야 통과. fix를 임시로 되돌리면 ~30~50% 확률로 stale wait timer가 fire해 fail한다(실제 검증 결과 10회 중 3회 fail). fix 적용 후 10회 연속 PASS 확인.
- `apps/node/internal/transport/heartbeat_test.go::TestHeartbeatSurvivesIdleAfterNodeCommand` (기존 유지, 코멘트 명확화)
- `apps/node/internal/transport/heartbeat_test.go::TestHeartbeatWaitExceedsInterval` (기존 유지)
- `apps/edge/internal/transport/heartbeat_test.go::TestHeartbeatWaitExceedsInterval` (신규, 노드 쪽과 대칭)

### 진짜 무응답 case는 그대로 유지

기존 `TestHeartbeatDisconnectsWithoutResponse`(`heartbeat_test.go:18`)와 `TestWsHeartbeatDisconnectsWithoutResponse`(`heartbeat_test.go:113`)는 peer가 read만 하고 응답하지 않는 시나리오를 검증한다. 둘 다 통과한다(아래 검증 출력 참고). 즉 fix는 stale wait timer만 살리고 legitimate timeout은 그대로 disconnect한다.

## 수정 파일

| 파일 | 변경 |
|------|------|
| `/config/workspace/proto-socket/go/base_client.go` | wait timer callback에서 `waitingHBResponse` 확인 후 stale이면 interval timer 재무장, legitimate이면 기존대로 `heartbeat_timeout` disconnect |
| `/config/workspace/proto-socket/go/test/heartbeat_test.go` | `TestHeartbeatSurvivesPairedIdle` 신규 추가 (synchronized two-peer race regression) |
| `apps/edge/internal/transport/heartbeat_test.go` | 신규: edge-side `TestHeartbeatWaitExceedsInterval` |
| `apps/node/internal/transport/heartbeat_test.go` | 주석 정정 (`1s/1s` 오기 → `1s interval / 2s wait` 및 library 수정과의 관계) |
| `apps/node/internal/transport/client.go` | 상수 코멘트를 library wait-timer 수정 사실에 맞게 갱신 |
| `apps/edge/internal/transport/server.go` | 동일 (node 코멘트 참조) |

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

- stale wait timer가 `waitingHBResponse=false` 상태에서 false timeout을 만들지 않는가.
- 실제 무응답 peer는 여전히 `heartbeat_timeout`으로 disconnect되는가.
- synchronized two-peer 또는 equivalent race regression test가 fix 전 실패하고 fix 후 통과하는 성격인가.
- edge와 node 양쪽 production heartbeat invariant가 모두 테스트로 고정되었는가.
- `go.mod` replace 기준 실제 dependency path와 line reference가 문서/주석에 정확히 반영되었는가.
- 10분 Gemini idle acceptance가 idle window 중 transport close marker 없이 통과했는가.

## 검증 결과

### proto-socket 단위 검증
```bash
$ cd /config/workspace/proto-socket/go && go test -count=1 ./...
ok  	git.toki-labs.com/toki/common-proto-socket/go	0.006s
?   	git.toki-labs.com/toki/common-proto-socket/go/crosstest/dart_go_client	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/crosstest/kotlin_go_client	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/crosstest/python_go_client	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/crosstest/typescript_go_client	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/examples/tcp_echo	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/examples/ws_echo	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/packets	[no test files]
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	8.109s
```

### regression test가 bug를 잡는지 확인 (fix 임시 revert)
fix block을 임시로 옛 코드(`if c.IsAlive() { c.onDisconnected(...) }`)로 되돌리고 10회 stress 실행한 결과:
```
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	4.015s
---
FAIL	git.toki-labs.com/toki/common-proto-socket/go/test	3.018s
---
FAIL	git.toki-labs.com/toki/common-proto-socket/go/test	4.049s
---
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	4.041s
---
FAIL	git.toki-labs.com/toki/common-proto-socket/go/test	4.032s
---
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	4.013s
---
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	4.055s
---
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	4.042s
---
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	4.022s
---
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	4.009s
```
3/10 FAIL이 그대로 stale wait timer race를 잡았다. fix를 다시 적용한 뒤 10회 stress 결과:
```
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.050s
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.065s
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.021s
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.052s
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.033s
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.069s
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.057s
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.052s
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.055s
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	6.012s
```
10/10 PASS.

### go-iop transport 검증
```bash
$ cd /config/workspace/go-iop && go test -count=1 ./apps/node/internal/transport ./apps/edge/internal/transport
ok  	iop/apps/node/internal/transport	5.078s
ok  	iop/apps/edge/internal/transport	0.006s
```

### go-iop 전체 검증
```bash
$ cd /config/workspace/go-iop && 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.003s
ok  	iop/apps/edge/internal/node	0.004s
ok  	iop/apps/edge/internal/opsconsole	0.006s
ok  	iop/apps/edge/internal/service	0.005s
ok  	iop/apps/edge/internal/transport	0.006s
?   	iop/apps/node/cmd/node	[no test files]
ok  	iop/apps/node/internal/adapters	0.006s
ok  	iop/apps/node/internal/adapters/cli	39.132s
?   	iop/apps/node/internal/adapters/cli/internal/testutil	[no test files]
ok  	iop/apps/node/internal/adapters/cli/status	31.154s
?   	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.009s
ok  	iop/apps/node/internal/router	0.004s
?   	iop/apps/node/internal/runtime	[no test files]
ok  	iop/apps/node/internal/store	0.020s
ok  	iop/apps/node/internal/transport	5.044s
?   	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 문법 + mock smoke
```bash
$ 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.
```

### Gemini 10분 idle acceptance
첫 시도는 `idle window` 중에는 PASS였지만, post-idle 단계에서 Gemini CLI 자체가 `error` run status를 반환했다(prompt에 "Reply with exactly OK"를 줬는데 Gemini가 SKILL.md 날짜 일괄 수정이라는 autonomous tool-use 모드로 빠짐). 해당 로그는 `/tmp/iop-hbt-logs/gemini-idle-600-cli-flake.log`에 보관했다. idle window 내 transport close marker는 0건이었기 때문에 heartbeat regression이 아닌 Gemini CLI nondeterminism이다.

재실행 결과는 깔끔하게 PASS했다.
```bash
$ 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":1779002826.2079222,"caller":"transport/server.go:175","msg":"node unregistered","node_id":"test-node","transport_close_reason":"local_close","transport_close_error":"read tcp 127.0.0.1:38281->127.0.0.1:36486: use of closed network connection"}
{"level":"info","ts":1779002826.208025,"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 -i "heartbeat_timeout" /tmp/iop-hbt-logs/gemini-idle-600.log
(no match)
```

해석: 마지막에 보이는 두 disconnect는 idle window 종료 후 `/exit` 정상 종료에 의한 `local_close`/`remote_closed` pair다. timestamp(`1779002826`)는 idle 시작(`baseline=26 node baseline=29`, 위쪽 로그 ts ~1779002218 부근) 이후 약 600초 후로, 600초 idle window 전체를 지나친 뒤에야 끊겼다. `heartbeat_timeout` 토큰은 전체 로그에서 0회.

### git diff --check
```bash
$ cd /config/workspace/proto-socket && git diff --check
(exit 0)

$ cd /config/workspace/go-iop && git diff --check
(exit 0)
```

---

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

---

## 코드리뷰 결과

### 종합 판정

PASS

### 차원별 평가

| 차원 | 평가 | 근거 |
|---|---|---|
| correctness | Pass | `/config/workspace/proto-socket/go/base_client.go`의 wait timer callback이 `waitingHBResponse`를 확인해 stale wait timer를 interval timer로 재무장하고, 실제 무응답 case는 기존처럼 `heartbeat_timeout`으로 disconnect한다. |
| completeness | Pass | proto-socket root cause fix, library-level paired idle regression, go-iop node/edge invariant tests, 10분 Gemini idle evidence가 모두 포함됐다. |
| test coverage | Pass | legitimate timeout, paired idle survival, go-iop transport idle, edge/node invariant, mock smoke, full Go tests가 검증됐다. |
| API contract | Pass | 기존 `TcpClient`, `DialEdge`, `NewServer` 호출 signature와 wire contract 변경은 없다. `DisconnectInfo` reason/error semantics도 유지된다. |
| code quality | Pass | 핵심 변경은 `baseClient` heartbeat state machine에 국한되어 있고, disconnect metadata 추가도 transport read/write failure 경계에 맞게 정리되어 있다. |
| plan deviation | Pass | follow-up plan의 sibling dependency 수정, synchronized race regression, 양쪽 invariant 고정, 검증 기록 갱신을 수행했다. |
| verification trust | Pass | 리뷰어가 주요 테스트와 diff checks를 재실행했고, Gemini 10분 로그의 heartbeat marker 부재도 확인했다. |

### 발견된 문제

- Required: 없음.
- Suggested: 없음.
- Nit — `/config/workspace/proto-socket/go/test/heartbeat_test.go:56`, `/config/workspace/proto-socket/go/test/heartbeat_test.go:64`: `TestHeartbeatSurvivesPairedIdle` 주석은 "250ms interval / 250ms wait", "drive intervals manually"라고 설명하지만 실제 코드는 `NewTcpClient(..., 1, 1, ...)`로 1초/1초 heartbeat를 사용한다. 동작에는 영향 없지만, 다음 정리 때 주석과 죽은 `intervalSec` const를 실제 코드에 맞추면 좋다.
- Nit — `apps/node/internal/transport/heartbeat_test.go:40`: 주석은 "1s/1s heartbeat"라고 되어 있지만 실제 fixture는 `intervalSec=1`, `waitSec=2`다. 바로 위 설명은 맞으므로 작은 주석 오기다.

### 리뷰어 추가 검증

```bash
$ gofmt -l /config/workspace/proto-socket/go/base_client.go /config/workspace/proto-socket/go/tcp_client.go /config/workspace/proto-socket/go/ws_client.go /config/workspace/proto-socket/go/test/heartbeat_test.go
# no output

$ gofmt -l apps/node/internal/transport/client.go apps/node/internal/transport/heartbeat_test.go apps/edge/internal/transport/server.go apps/edge/internal/transport/heartbeat_test.go
# no output
```

```bash
$ cd /config/workspace/proto-socket/go && go test -count=1 ./...
ok  	git.toki-labs.com/toki/common-proto-socket/go	0.005s
?   	git.toki-labs.com/toki/common-proto-socket/go/crosstest/dart_go_client	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/crosstest/kotlin_go_client	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/crosstest/python_go_client	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/crosstest/typescript_go_client	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/examples/tcp_echo	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/examples/ws_echo	[no test files]
?   	git.toki-labs.com/toki/common-proto-socket/go/packets	[no test files]
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	8.078s
```

```bash
$ cd /config/workspace/proto-socket/go && go test -run TestHeartbeatSurvivesPairedIdle -count=10 ./test
ok  	git.toki-labs.com/toki/common-proto-socket/go/test	40.244s
```

```bash
$ cd /config/workspace/go-iop && go test -count=1 ./apps/node/internal/transport ./apps/edge/internal/transport
ok  	iop/apps/node/internal/transport	5.056s
ok  	iop/apps/edge/internal/transport	0.006s
```

```bash
$ cd /config/workspace/go-iop && 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.010s
ok  	iop/apps/edge/internal/events	0.004s
ok  	iop/apps/edge/internal/node	0.005s
ok  	iop/apps/edge/internal/opsconsole	0.011s
ok  	iop/apps/edge/internal/service	0.004s
ok  	iop/apps/edge/internal/transport	0.012s
?   	iop/apps/node/cmd/node	[no test files]
ok  	iop/apps/node/internal/adapters	0.006s
ok  	iop/apps/node/internal/adapters/cli	39.092s
?   	iop/apps/node/internal/adapters/cli/internal/testutil	[no test files]
ok  	iop/apps/node/internal/adapters/cli/status	30.988s
?   	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.010s
ok  	iop/apps/node/internal/router	0.004s
?   	iop/apps/node/internal/runtime	[no test files]
ok  	iop/apps/node/internal/store	0.023s
ok  	iop/apps/node/internal/transport	5.060s
?   	iop/apps/worker/cmd/worker	[no test files]
?   	iop/packages/auth	[no test files]
ok  	iop/packages/config	0.007s
?   	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
$ cd /config/workspace/go-iop && bash -n scripts/e2e-smoke.sh && git diff --check && git -C /config/workspace/proto-socket diff --check
# no output
```

```bash
$ cd /config/workspace/go-iop && IOP_E2E_IDLE_SECONDS=2 make test-e2e > /tmp/iop-hbt-review-logs/mock-idle-2-r2.log 2>&1
$ grep -E "idle monitor|idle window|Smoke test|Real profile" /tmp/iop-hbt-review-logs/mock-idle-2-r2.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":1779002826.2079222,"caller":"transport/server.go:175","msg":"node unregistered","node_id":"test-node","transport_close_reason":"local_close","transport_close_error":"read tcp 127.0.0.1:38281->127.0.0.1:36486: use of closed network connection"}
{"level":"info","ts":1779002826.208025,"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 -ci "heartbeat_timeout" /tmp/iop-hbt-logs/gemini-idle-600.log
0
```

### 다음 단계

PASS: active plan/review를 log로 아카이브하고 `complete.log`를 작성한다.
