<!-- task=m-runner-proto-socket-transport-hardening/01_socket_lifecycle plan=0 tag=API -->

# Plan - API

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

`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것까지가 구현입니다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 review ready로 보고하세요. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈추며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive를 만들지 않습니다. 환경/secret/서비스 문제나 증거 공백은 일반 follow-up 대상으로 기록합니다.

## 배경

현 Milestone은 기본 runner transport를 proto-socket 장기 세션으로 고정한다. 서버는 register, heartbeat, duplicate client close, disconnect listener를 갖고 있지만 `runnersocket.Server` 자체 lifecycle 테스트가 없다. 이 작업은 후속 dispatch/action/smoke 작업이 의존할 runner id reconciliation과 disconnect/timeout 기준을 먼저 안정화한다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실구현을 차단할 때만 active `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자 프롬프트, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 생성은 금지이며, code-review가 검증과 파일 생성을 소유한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
  - `session-lifecycle`: runner id reconciliation, duplicate connection close, disconnect/timeout, reconnect 후보, heartbeat 실패 처리의 socket session lifecycle을 정리한다.
- Completion mode: check-on-pass

## Spec Targets

- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
  - `S02`: task=`session-lifecycle`; evidence=`Go/Dart unit 또는 smoke가 server-assigned runner id, duplicate connection close, disconnect/timeout, heartbeat failure를 검증한다.`
- Completion mode: spec-check-on-pass

## 분석 결과

### 읽은 파일

- `agent-test/local/rules.md`
- `agent-test/local/agent-smoke.md`
- `agent-test/local/framework-smoke.md`
- `agent-ops/rules/project/domain/agent/rules.md`
- `agent-ops/rules/project/domain/framework/rules.md`
- `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- `services/core/internal/runnersocket/server.go`
- `services/core/internal/runnerregistry/registry.go`
- `services/core/internal/runnerregistry/registry_test.go`
- `services/core/internal/httpserver/server.go`
- `services/core/internal/httpserver/routes.go`
- `services/core/internal/httpserver/server_test.go`
- `services/core/internal/cicdstate/store.go`
- `services/core/internal/cicdstate/store_test.go`
- `apps/runner/lib/oto/agent/registration_client.dart`
- `apps/runner/lib/oto/agent/agent_runner.dart`
- `apps/runner/lib/oto/agent/agent_config.dart`
- `apps/runner/lib/oto/agent/oto_server_job_client.dart`
- `apps/runner/test/oto_agent_registration_test.dart`
- `apps/runner/test/oto_server_connection_smoke_test.dart`
- `proto/oto/runner.proto`
- `Makefile`
- `apps/runner/pubspec.yaml`
- `services/core/go.mod`

### 테스트 환경 규칙

`test_env=local`을 적용했다. `agent-test/local/rules.md`는 원격 runner 기준이며 agent 변경은 `agent-test/local/agent-smoke.md`의 `cd apps/runner && dart analyze`, agent 관련 `dart test`, `cd services/core && go test ./...`를 요구한다. `proto/**` 변경은 `framework-smoke`의 `make proto`가 필요하지만, 이 plan은 proto 파일을 바꾸지 않는다. Go state/lifecycle 검증은 fresh 실행이 중요하므로 `go test -count=1 ./...`를 사용한다.

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

- server socket duplicate connection close: `services/core/internal/runnersocket/server.go`에는 `setClient`가 old client를 닫지만 전용 테스트가 없다.
- socket disconnect가 current client에만 `registry.Disconnect`를 적용하는지: disconnect listener는 있으나 stale client 경합 테스트가 없다.
- Dart socket heartbeat failure 처리: `_sendHeartbeat`는 오류를 무시하지만 heartbeat 실패 후 close/stream 정리 테스트가 없다.

### 심볼 참조

renamed/removed symbol 없음. `OtoServerPushJobSession`, `OtoServerSocketRegistrationClient`, `DispatchQueuedJobs`, `CancelExecution` 참조는 `rg --sort path`로 확인했다.

### 분할 판단

split decision policy를 먼저 적용했다. 공유 task group은 `agent-task/m-runner-proto-socket-transport-hardening/`이다. `01_socket_lifecycle`은 선행 작업이 없는 기반 작업이다. 후속 `02+01_dispatch_state`, `03+01,02_runner_actions`, `04+01_compat_boundary`, `05+02,03,04_socket_smoke`가 이 작업의 `complete.log`에 의존한다.

### 범위 결정 근거

이 plan은 runner socket session lifecycle만 다룬다. queued job dispatch, invalid RunRequest, cancel/status/self-update, HTTP compatibility test renaming, smoke 기준 문서 갱신은 후속 split plan에서 다룬다. `proto/oto/runner.proto`는 현재 register/heartbeat/cancel/run/report 메시지가 충분하므로 변경하지 않는다.

### 빌드 등급

`cloud-G07`: long-running socket/session behavior와 Go/Dart 양쪽 검증이 걸려 있고, 로컬 단위 테스트만으로 잘못된 구현이 통과할 위험이 있다.

## 구현 체크리스트

- [ ] [API-1] 서버 socket lifecycle 테스트와 필요한 보강을 구현한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] [API-2] Dart socket session heartbeat/close behavior 테스트와 필요한 보강을 구현한다. 검증: `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart`
- [ ] 전체 agent 검증을 실행한다. 검증: `cd apps/runner && dart analyze`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] 서버 socket lifecycle 테스트와 보강

문제: [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:136)의 register handler는 accepted runner id를 client map에 저장하고 [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:187)의 disconnect listener가 registry를 disconnected로 바꾼다. [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:351)의 `setClient`는 duplicate client를 닫지만 stale client disconnect가 current session 상태를 덮지 않는지 검증이 없다.

해결 방법:

```go
// before: services/core/internal/runnersocket/server.go:351
func (s *Server) setClient(runnerID string, client *protoSocket.TcpClient) {
    s.mu.Lock()
    defer s.mu.Unlock()
    if old := s.clients[runnerID]; old != nil && old != client {
        _ = old.Close()
    }
    s.clients[runnerID] = client
}
```

`services/core/internal/runnersocket/server_test.go`를 추가한다. real proto-socket TCP client를 쓰거나 같은 package test helper로 `setClient/removeClient/connectedRunnerIDs`와 registry transition을 검증한다. stale client disconnect가 current client를 삭제하지 않는 현재 `removeClient` guard를 테스트로 고정하고, 필요하면 disconnect listener에서 current client 확인 후 `registry.Disconnect`하도록 보강한다.

수정 파일 및 체크리스트:

- [ ] `services/core/internal/runnersocket/server_test.go` 추가
- [ ] stale client disconnect가 current client를 지우지 않는 테스트 추가
- [ ] duplicate connection close 또는 replacement behavior 테스트 추가
- [ ] heartbeat unknown/terminal response가 registry state와 일치하는지 테스트 추가
- [ ] 필요 시 `services/core/internal/runnersocket/server.go` 수정

테스트 작성: 작성한다. 테스트 이름 후보: `TestServerReplacesDuplicateRunnerClient`, `TestServerIgnoresStaleClientDisconnect`, `TestServerHeartbeatUsesRegisteredRunnerID`.

중간 검증:

```bash
cd services/core && go test -count=1 ./...
```

기대 결과: 모든 Go package 통과.

### [API-2] Dart socket session heartbeat/close 테스트와 보강

문제: [registration_client.dart](/config/workspace/oto/apps/runner/lib/oto/agent/registration_client.dart:344)는 pushed `RunRequest` listener를 열고, [registration_client.dart](/config/workspace/oto/apps/runner/lib/oto/agent/registration_client.dart:350)는 heartbeat timer를 시작한다. [registration_client.dart](/config/workspace/oto/apps/runner/lib/oto/agent/registration_client.dart:372)는 close에서 timer와 stream/socket을 닫지만 socket session 전용 test가 부족하다.

해결 방법:

```dart
// before: apps/runner/lib/oto/agent/registration_client.dart:358
Future<void> _sendHeartbeat() async {
  if (_closed) return;
  try {
    await _client.sendRequest<oto.HeartbeatRequest, oto.HeartbeatResponse>(...);
  } catch (_) {
    // Ignore background heartbeat errors; proto-socket heartbeat owns liveness.
  }
}
```

`apps/runner/test/oto_agent_registration_test.dart`에 socket registration/session 전용 group을 보강한다. 기존 HTTP session tests는 compatibility 경로로 유지하고, socket session은 fake or loopback proto-socket으로 first heartbeat, close cancels future heartbeat, runRequests stream close를 검증한다. 직접 fake가 어려우면 `oto_server_connection_smoke_test.dart`의 real Go server helper를 재사용하되 test name에 socket을 명시한다.

수정 파일 및 체크리스트:

- [ ] `apps/runner/test/oto_agent_registration_test.dart` 또는 `apps/runner/test/oto_server_connection_smoke_test.dart`에 socket session lifecycle test 추가
- [ ] heartbeat failure가 process를 죽이지 않고 close가 정리되는지 검증
- [ ] `AgentSession.close()` 후 runRequests stream이 닫히는지 검증
- [ ] 필요 시 `apps/runner/lib/oto/agent/registration_client.dart` 수정

테스트 작성: 작성한다. 테스트 이름 후보: `OtoServerSocketRegistrationClient sends heartbeat and closes pushed session`.

중간 검증:

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

기대 결과: 두 테스트 파일 통과.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/runnersocket/server.go` | API-1 |
| `services/core/internal/runnersocket/server_test.go` | API-1 |
| `apps/runner/lib/oto/agent/registration_client.dart` | API-2 |
| `apps/runner/test/oto_agent_registration_test.dart` | API-2 |
| `apps/runner/test/oto_server_connection_smoke_test.dart` | API-2 |

## 최종 검증

```bash
cd services/core && go test -count=1 ./...
cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
cd apps/runner && dart analyze
```

기대 결과: Go tests 통과, Dart agent tests 통과, analyzer issue 없음. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
