<!-- task=m-control-plane-separation-migration/03+02_online_state plan=1 tag=REVIEW_API -->

# Plan - REVIEW_API

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

이 plan은 `code_review_cloud_G06_0.log`의 Required 이슈만 해결한다. 구현 중 사용자 결정이나 외부 환경으로 막히면 사용자에게 직접 질문하지 말고 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션을 증거와 함께 채운 뒤 멈춘다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해결할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니라 구현 범위다.

## 배경

첫 번째 리뷰는 heartbeat state machine 자체는 추가됐지만 rejected registration 이후 heartbeat가 시작될 수 있고, disconnect/timeout 이후 late heartbeat가 registry 상태를 다시 `online`으로 되살릴 수 있다고 판정했다. 또한 heartbeat endpoint가 path runner id 대신 body runner id를 사용할 수 있어 API contract가 약하다. 검증 결과는 placeholder였고, 리뷰어 재실행에서 새 OTO Server focused path는 통과했지만 전체 agent smoke는 legacy sibling `iop` 컴파일 오류로 실패했다.

## 사용자 리뷰 요청 흐름

구현 중 차단은 active review stub에 기록한다. 직접 사용자 질문, `USER_REVIEW.md`, archive, `complete.log` 작성은 금지된다. legacy iop smoke가 동일한 외부 컴파일 오류로만 실패하고 repo 내부 수정으로 해결할 수 없으면, 실행 명령과 실제 출력, 자동 후속 불가 이유, 재개 조건을 `사용자 리뷰 요청`에 기록한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md`
- Task ids:
  - `online-state`: registration accepted와 first heartbeat online 상태가 OTO Server registry에 분리되어 기록되고, heartbeat timeout/disconnect 실패 상태가 정의된다.
- Completion mode: check-on-pass

## 이전 리뷰 요약

- `code_review_cloud_G06_0.log` verdict: FAIL
- Required:
  - rejected registration도 `_OtoServerAgentSession`을 만들며 heartbeat/disconnect를 시작한다.
  - registry heartbeat가 `disconnected` 또는 `heartbeat_timeout` 상태를 `online`으로 되살릴 수 있다.
  - heartbeat handler가 body `runner_id`로 path `{id}`를 덮어쓸 수 있다.
  - active review 검증 결과가 placeholder였고, 전체 agent smoke는 legacy `iop` sibling 컴파일 오류로 실패했다.
- Nit:
  - `git diff --check`가 Go test 파일 EOF blank line을 보고했다.

## 구현 체크리스트

- [ ] [REVIEW_API-1] rejected OTO Server registration에서는 heartbeat timer, first heartbeat, disconnect request를 시작하지 않도록 수정하고 Dart regression test를 추가한다.
- [ ] [REVIEW_API-2] disconnect/heartbeat_timeout 이후 late heartbeat가 registry 상태를 `online`으로 되살리지 못하도록 registry와 runner session 정리 흐름을 보강하고 Go/Dart regression test를 추가한다.
- [ ] [REVIEW_API-3] heartbeat handler에서 path runner id와 body runner id mismatch를 400으로 거부하고 handler regression test를 추가한다.
- [ ] [REVIEW_API-4] 검증 결과를 실제 stdout/stderr로 기록하고, legacy iop smoke가 외부 차단으로 남으면 `사용자 리뷰 요청`에 정확한 차단 증거를 남긴다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 수정 파일 및 구현 지침

### [REVIEW_API-1] Rejected registration heartbeat 금지

문제: `OtoServerRegistrationClient.openSession()`이 rejected response도 `_OtoServerAgentSession`으로 감싸면서 constructor의 timer/microtask가 즉시 heartbeat를 시작한다.

해결 방법: accepted 결과일 때만 heartbeat/disconnect session을 만든다. rejected 결과는 `EdgeAgentSession` 인터페이스를 만족하지만 `close()`가 no-op인 session으로 반환하거나, session 내부에서 `result.accepted`를 기준으로 heartbeat/disconnect를 절대 시작하지 않게 한다. 기존 `RegistrationClient.register()`와 `DefaultAgentRunner`의 rejected flow는 계속 session close가 안전해야 한다.

수정 후보:

- `apps/runner/lib/oto/agent/edge_registration_client.dart`
- `apps/runner/test/oto_agent_registration_test.dart`

테스트 작성:

- rejected OTO Server response를 돌려주는 loopback HTTP server에서 `/heartbeat`와 `/disconnect` 요청이 오지 않았음을 검증한다.

중간 검증:

```bash
cd apps/runner && dart test test/oto_agent_registration_test.dart
```

### [REVIEW_API-2] Terminal state late heartbeat 방어

문제: registry `Heartbeat()`가 현재 상태를 보지 않고 항상 `StatusOnline`으로 덮어써 disconnect 이후 늦은 heartbeat나 timeout 이후 heartbeat가 상태를 되살릴 수 있다. Dart session도 close 시 in-flight heartbeat를 명시적으로 막지 않는다.

해결 방법: registry에서는 `disconnected`와 `heartbeat_timeout` 상태의 heartbeat를 reject하거나 상태를 유지한다. Dart session에서는 `_closed` flag를 두고 close 이후 예약/주기 heartbeat가 요청을 보내지 않게 하며, 가능하면 close 전에 in-flight heartbeat 완료를 기다리거나 late completion이 disconnect 이후 상태를 되살리지 않는 서버 계약에 의존한다.

수정 후보:

- `services/core/internal/runnerregistry/registry.go`
- `services/core/internal/runnerregistry/registry_test.go`
- `apps/runner/lib/oto/agent/edge_registration_client.dart`
- `apps/runner/test/oto_agent_registration_test.dart`
- `apps/runner/test/oto_server_connection_smoke_test.dart`

테스트 작성:

- Go: registered runner를 disconnect한 뒤 heartbeat를 보내도 status가 `disconnected`로 남는지 검증한다.
- Go: `heartbeat_timeout` 상태 이후 heartbeat가 `online`으로 복구되지 않는지 검증한다.
- Dart: session close 이후 추가 heartbeat request가 발생하지 않는지 focused loopback test로 검증한다.

중간 검증:

```bash
cd services/core && go test ./internal/runnerregistry
cd apps/runner && dart test test/oto_agent_registration_test.dart
```

### [REVIEW_API-3] Heartbeat path/body runner id contract

문제: `/api/v1/runners/{id}/heartbeat` handler가 body `runner_id`가 있으면 path id와 달라도 body 값을 사용한다.

해결 방법: body `runner_id`가 비어 있으면 path id를 사용한다. body `runner_id`가 path id와 다르면 400 response를 반환하고 registry를 수정하지 않는다.

수정 후보:

- `services/core/internal/httpserver/server.go`
- `services/core/internal/httpserver/server_test.go`

테스트 작성:

- `POST /api/v1/runners/runner-a/heartbeat` with body `{"runner_id":"runner-b"}`가 400을 반환하고 runner-b status를 변경하지 않는지 검증한다.

중간 검증:

```bash
cd services/core && go test ./internal/httpserver
```

### [REVIEW_API-4] Verification evidence and formatting

문제: 이전 review stub은 검증 출력이 `(output)` placeholder였고, 리뷰어 재실행에서 전체 agent smoke가 legacy sibling `iop` 컴파일 오류로 실패했다. 또한 `git diff --check`가 Go test 파일 EOF blank line을 보고했다.

해결 방법: code fix 후 명령을 실제로 실행하고 active review stub에 stdout/stderr를 붙인다. `git diff --check`도 통과시킨다. legacy `iop` smoke가 같은 외부 컴파일 오류로만 실패하면, 성공한 focused OTO Server evidence와 실패한 full command output을 함께 남기고 `사용자 리뷰 요청`을 작성한다.

수정 후보:

- `services/core/internal/httpserver/server_test.go`
- `services/core/internal/runnerregistry/registry_test.go`
- `agent-task/m-control-plane-separation-migration/03+02_online_state/CODE_REVIEW-cloud-G06.md`

중간 검증:

```bash
git diff --check
cd services/core && go test ./...
cd apps/runner && dart analyze
cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
```

## 최종 검증

```bash
git diff --check
cd services/core && go test ./...
cd apps/runner && dart analyze
cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_iop_connection_smoke_test.dart test/oto_server_connection_smoke_test.dart
```

전체 agent smoke가 legacy `iop` sibling 컴파일 오류로만 실패하고 현재 repo에서 해결할 수 없으면, active review stub의 `사용자 리뷰 요청` 섹션에 해당 명령과 실제 출력, 사용자 결정 필요 여부, 재개 조건을 남긴 뒤 멈춘다.
