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

# Plan - HBT

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

**필수: 구현 마지막에는 반드시 `CODE_REVIEW-cloud-G09.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 명령 출력으로 채운다.** 이 task는 12번 real CLI idle 검증에서 노출된 transport heartbeat regression을 고치는 작업이다. 구현 체크리스트와 review stub의 구현 체크리스트를 모두 완료하고, 중간/최종 검증 출력은 실제 stdout/stderr로 남긴다. review 파일의 `이 파일을 읽는 리뷰 에이전트에게` 아카이브 지시와 `코드리뷰 전용 체크리스트`는 코드리뷰 skill 전용이므로 구현 에이전트가 실행하거나 수정하지 않는다.

## 배경

`IOP_E2E_PROFILE=gemini IOP_E2E_IDLE_SECONDS=600 make test-e2e`가 idle window 약 160초 뒤 `heartbeat_timeout`으로 실패했다. unit test와 mock smoke는 통과하지만 real idle에서는 edge-node transport가 끊어지므로, 이번 작업은 heartbeat idle 안정성을 제품 코드에서 복구한다. 범위는 TCP transport heartbeat/read-write loop, 관련 regression test, real idle 재검증에 한정한다.

## 의존 관계 및 구현 순서

- 선행 task 없음.
- `agent-task/14_e2e_temp_workspace`, `agent-task/14+ops_command_smoke`, `agent-task/15_node_command_docs_accuracy`와 병렬 진행 가능하다.
- 최종 release 판단은 이 task의 10분 real idle PASS를 우선한다.

## 분석 결과

### 읽은 파일

- `apps/node/internal/transport/client.go`
- `apps/node/internal/transport/parser.go`
- `apps/node/internal/transport/session.go`
- `apps/node/internal/transport/session_test.go`
- `apps/node/internal/transport/integration_test.go`
- `apps/edge/internal/transport/server.go`
- `apps/edge/internal/transport/server_test.go`
- `proto/iop/runtime.proto`
- `/config/go/pkg/mod/git.toki-labs.com/toki/common-proto-socket/go@v0.0.0-20260501220005-284b66a22300/base_client.go`
- `/config/go/pkg/mod/git.toki-labs.com/toki/common-proto-socket/go@v0.0.0-20260501220005-284b66a22300/tcp_client.go`
- `/config/go/pkg/mod/git.toki-labs.com/toki/common-proto-socket/go@v0.0.0-20260501220005-284b66a22300/communicator.go`
- `scripts/e2e-smoke.sh`

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

- Idle 중 heartbeat 왕복 유지: 기존 mock idle smoke는 2초 수준이라 30초 heartbeat interval을 지나지 않는다. regression test 공백.
- NodeCommandRequest 처리 직후 장시간 idle: 기존 transport integration은 request/response와 disconnect만 확인하고 heartbeat interval 이후 생존을 확인하지 않는다. regression test 공백.
- Real CLI idle 10분: 12번 task에서 harness는 추가됐지만 실패 상태로 기록됐다. 최종 acceptance에 필요.

### 심볼 참조

- renamed/removed symbol 없음.
- 새 helper를 만들 경우 public API로 export하지 않는다. 기존 `transport.DialEdge` call site는 변경하지 않는다.

### 범위 결정 근거

- `common-proto-socket` module cache는 직접 수정하지 않는다. 원인 확인용으로만 읽었고, repo 코드에서 우회 또는 보강한다.
- proto schema는 변경하지 않는다. library heartbeat는 `packets.HeartBeat`를 parser map에 자동 등록하므로 `iop.Heartbeat` 추가 등록으로 해결하려 하지 않는다.
- CLI adapter status parsing, Gemini profile 설정, ops console command UI는 이번 task 범위가 아니다.

### 빌드 등급

- Build `cloud-G09`: protocol/heartbeat/concurrency 위험, real external CLI idle 실패, long-running validation이 포함된다.
- Review `cloud-G09`: transport 생존성, regression test 의미, 10분 idle evidence를 다시 검증해야 한다.

## 구현 체크리스트

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

### [HBT-1] heartbeat idle regression test와 진단 경계 작성

#### 문제

`apps/node/internal/transport/client.go:16-19`와 `apps/edge/internal/transport/server.go:19-21`는 heartbeat interval 30초, wait 10초를 양쪽 TCP client에 적용한다. `common-proto-socket`의 `base_client.go:66-82`는 interval timer 후 heartbeat를 보내고 wait timer 안에 응답이 없으면 disconnect한다. 기존 `apps/node/internal/transport/integration_test.go`는 heartbeat interval 이후 idle 생존을 검증하지 않는다.

```go
// apps/node/internal/transport/client.go:16
const (
	heartbeatIntervalSec = 30
	heartbeatWaitSec     = 10
	registerTimeout      = 10 * time.Second
)
```

#### 해결 방법

- `apps/node/internal/transport/heartbeat_test.go`를 추가한다.
- test는 local TCP server/client를 1초 interval, 1초 wait로 구성해 app-level `RegisterRequest`와 `NodeCommandRequest` 처리 후 최소 4초 idle을 버티는지 확인한다.
- 이 test가 현재 코드에서 재현하지 못하면, CODE_REVIEW의 `계획 대비 변경 사항`에 "library synthetic pair는 PASS, real smoke에서만 FAIL"을 기록하고 HBT-2에서 real smoke 로그 기반 계측을 추가한다.
- non-trivial helper가 필요하면 public API가 아니라 unexported constructor로 제한한다.

예상 구조:

```go
// apps/node/internal/transport/heartbeat_test.go
func TestHeartbeatSurvivesIdleAfterNodeCommand(t *testing.T) {
	// heartbeat interval/wait = 1s
	// register node, send NodeCommandRequest, wait >= 4s, assert both clients IsAlive.
}
```

#### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/transport/heartbeat_test.go` 신규 작성
- [ ] test fixture는 `transport_test`가 아닌 `transport` package를 사용해 unexported helper 접근을 허용
- [ ] test timeout은 8초 이하로 제한
- [ ] failure message에 어느 쪽 client가 죽었는지 표시

#### 테스트 작성

- 작성: `apps/node/internal/transport/heartbeat_test.go`, `TestHeartbeatSurvivesIdleAfterNodeCommand`.
- 목표: `NodeCommandRequest` 왕복 후 idle 상태에서 heartbeat interval을 여러 번 지나도 edge/node client가 살아 있어야 한다.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/transport
```

Expected: 새 heartbeat regression test 포함 PASS. cache 출력은 허용하지 않는다.

### [HBT-2] heartbeat timeout 원인 수정

#### 문제

12번 검증 로그에서 `/status` 완료 직후 idle window가 시작되고, 약 160초 뒤 node session disconnect listener가 `transport_close_reason="heartbeat_timeout"`을 기록했다. `scripts/e2e-smoke.sh:166-185`는 idle window 이후의 transport close marker만 실패로 잡기 때문에 `/exit` 정상 종료 오탐이 아니다. `common-proto-socket`의 request handler는 `communicator.go:268-274`에서 goroutine으로 실행되므로 단순한 NodeCommand handler blocking만으로는 설명되지 않는다.

```go
// /config/go/.../communicator.go:268
if reqHandler != nil {
	msg, err := c.parse(typeName, data)
	if err != nil {
		return
	}
	go reqHandler(msg, incomingNonce)
	return
}
```

#### 해결 방법

- 먼저 HBT-1 test 결과와 real log를 비교한다.
- repo 코드에서 수정 가능한 후보는 아래 순서로 적용한다.
  1. edge/node 양쪽 transport 생성부에 testable heartbeat config helper를 만든다.
  2. false-positive timeout을 줄이기 위해 wait를 interval보다 짧게 두지 않는 방향으로 조정한다. 기본값 후보: interval 30초 유지, wait 45초.
  3. disconnect event metadata가 `heartbeat_timeout`일 때 edge/node 양쪽 로그가 동일한 close reason을 유지하는지 확인한다.
- 수정 후 README의 "하트비트: 30초 간격"은 interval 의미이므로 이 task에서 바꾸지 않는다. wait 값을 문서화해야 한다고 판단되면 `agent-task/15_node_command_docs_accuracy`와 충돌하지 않도록 CODE_REVIEW에 기록한다.

Before:

```go
// apps/edge/internal/transport/server.go:19
const (
	heartbeatIntervalSec = 30
	heartbeatWaitSec     = 10
)
```

After:

```go
const (
	heartbeatIntervalSec = 30
	heartbeatWaitSec     = 45
)
```

#### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/transport/client.go` heartbeat wait 조정 또는 helper 추가
- [ ] `apps/edge/internal/transport/server.go` heartbeat wait 조정 또는 helper 추가
- [ ] 기존 `DialEdge` signature 유지
- [ ] edge/node disconnect metadata test가 깨지지 않도록 close reason semantics 유지

#### 테스트 작성

- 작성: HBT-1 regression test가 이 fix의 핵심 test다.
- 기존 tests: `apps/edge/internal/transport/server_test.go`의 disconnect/unregister assertions가 metadata regressions를 잡는다.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/transport ./apps/edge/internal/transport
```

Expected: transport package tests PASS. cache 출력은 허용하지 않는다.

### [HBT-3] smoke와 real idle acceptance 재검증

#### 문제

`scripts/e2e-smoke.sh:166-185`의 idle monitor가 실제로 `heartbeat_timeout`을 잡았으므로, unit-level PASS만으로는 완료할 수 없다. real profile 검증은 시간이 오래 걸리지만 이번 bug의 acceptance 기준이다.

#### 해결 방법

- mock idle로 harness 회귀를 먼저 확인한다.
- Gemini real profile로 10분 idle을 재실행한다.
- 실패하면 로그 path와 marker grep 전체 출력을 review stub에 붙이고 FAIL 상태로 남긴다.

#### 수정 파일 및 체크리스트

- [ ] source/test 수정 후 `bash -n scripts/e2e-smoke.sh` 실행
- [ ] `IOP_E2E_IDLE_SECONDS=2 make test-e2e` 실행
- [ ] `IOP_E2E_PROFILE=gemini IOP_E2E_IDLE_SECONDS=600 make test-e2e` 실행
- [ ] real idle 로그에서 `heartbeat_timeout|transport_closed|disconnected from edge`가 idle window에 없는지 확인

#### 테스트 작성

- 추가 test 작성 없음. 이 항목은 smoke/acceptance 검증이다.

#### 중간 검증

```bash
mkdir -p /tmp/iop-hbt-logs
bash -n scripts/e2e-smoke.sh
IOP_E2E_IDLE_SECONDS=2 make test-e2e > /tmp/iop-hbt-logs/mock-idle-2.log 2>&1
grep -E "idle monitor|idle window|Smoke test|Real profile" /tmp/iop-hbt-logs/mock-idle-2.log
```

Expected: bash syntax PASS, mock idle smoke PASS, grep 출력에 idle failure가 없다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/transport/heartbeat_test.go` | HBT-1 |
| `apps/node/internal/transport/client.go` | HBT-2 |
| `apps/edge/internal/transport/server.go` | HBT-2 |
| `apps/node/internal/transport/integration_test.go` | HBT-1, 필요한 경우만 |
| `apps/edge/internal/transport/server_test.go` | HBT-2, 필요한 경우만 |
| `agent-task/13_transport_idle_heartbeat/CODE_REVIEW-cloud-G09.md` | HBT-3 |

## 최종 검증

```bash
go test -count=1 ./apps/node/internal/transport ./apps/edge/internal/transport
go test -count=1 ./...
mkdir -p /tmp/iop-hbt-logs
bash -n scripts/e2e-smoke.sh
IOP_E2E_IDLE_SECONDS=2 make test-e2e > /tmp/iop-hbt-logs/mock-idle-2.log 2>&1
grep -E "idle monitor|idle window|Smoke test|Real profile" /tmp/iop-hbt-logs/mock-idle-2.log
IOP_E2E_PROFILE=gemini IOP_E2E_IDLE_SECONDS=600 make test-e2e > /tmp/iop-hbt-logs/gemini-idle-600.log 2>&1
grep -E "idle monitor|idle window|Real profile|Smoke test|heartbeat|transport_close|disconnected from edge" /tmp/iop-hbt-logs/gemini-idle-600.log
git diff --check
```

Expected: all commands exit 0. The Gemini grep may include "idle monitor" and PASS lines, but must not include idle-window `heartbeat_timeout`, `transport_closed`, or `disconnected from edge`. Go test cache output is not acceptable because `-count=1` is specified.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
