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

# Plan - REVIEW_API

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

이 plan은 직전 code-review FAIL의 Required 1건만 해결한다. `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것까지가 구현이다. 구현 중 직접 사용자에게 질문하거나 `request_user_input`, `USER_REVIEW.md`, `complete.log`, archive 생성을 하지 않는다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막을 때만 review stub의 `사용자 리뷰 요청`을 채운다.

## 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

## Archive Evidence Snapshot

- Current archived plan: `agent-task/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/plan_cloud_G07_0.log`
- Current archived review: `agent-task/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
  - `services/core/internal/runnersocket/server.go:350`의 `setClient`가 `s.mu`를 잡은 상태에서 old `TcpClient.Close()`를 호출한다. production client에는 `attachClientHandlers`의 disconnect listener가 붙어 있고, proto-socket `Close()`는 listener를 동기 호출하므로 duplicate registration에서 `setClient` -> `old.Close()` -> listener -> `removeClient()` -> 같은 `s.mu` 재획득 경로로 교착될 수 있다.
- Affected files:
  - `services/core/internal/runnersocket/server.go`
  - `services/core/internal/runnersocket/server_test.go`
- Prior verification evidence:
  - `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` 통과
- Verification trust note:
  - 기존 Go test는 listener가 없는 `pipeClient`만 `setClient`에 넣어 production disconnect listener 재진입을 검증하지 못했다.
- Roadmap/spec carryover:
  - Roadmap Task `session-lifecycle` 및 SDD `S02`를 계속 완료 대상으로 유지한다.
- Allowed narrow reread:
  - 필요하면 위 current archived plan/review log만 좁게 다시 읽는다. `agent-task/archive/**`는 읽지 않는다.

## 배경

직전 구현은 stale disconnect guard와 Dart close/heartbeat smoke를 추가했고 모든 명령은 통과했다. 하지만 duplicate runner connection을 교체하는 실제 production 경로에서는 old client에 disconnect listener가 등록되어 있으므로, mutex 안에서 old client를 닫으면 listener가 같은 mutex를 다시 잡으려 해 멈출 수 있다.

## 범위 결정 근거

후속 범위는 서버 socket duplicate replacement의 교착 제거와 테스트 보강만 포함한다. Dart client, dispatch 상태 전이, cancel/status/self-update, compatibility boundary, smoke 문서 갱신은 이 plan에서 수정하지 않는다.

## 빌드 등급

`local-G06`: 문제와 수정 범위가 `services/core/internal/runnersocket` 안의 mutex/close 순서와 단위 테스트로 좁고, 결정적 Go 테스트로 검증 가능하다.

## 구현 체크리스트

- [ ] [REVIEW_API-1] duplicate socket replacement deadlock을 제거하고 production listener 경로 테스트를 추가한다. 검증: `cd services/core && go test -count=1 ./internal/runnersocket`
- [ ] 전체 Go 회귀를 실행한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] 전체 agent 검증을 재실행한다. 검증: `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart` 및 `cd apps/runner && dart analyze`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_API-1] duplicate socket replacement deadlock 제거

문제: `services/core/internal/runnersocket/server.go:350`의 `setClient`는 `s.mu`를 잡은 상태에서 old client를 닫는다. proto-socket `Close()`는 disconnect listener를 동기 호출하고, production listener는 `s.removeClient()`로 같은 mutex를 다시 잡는다. 기존 테스트는 listener 없는 `pipeClient`를 사용해 이 재진입 경로를 놓쳤다.

해결 방법:

```go
// 방향 예시
func (s *Server) setClient(runnerID string, client *protoSocket.TcpClient) {
    var old *protoSocket.TcpClient
    s.mu.Lock()
    old = s.clients[runnerID]
    s.clients[runnerID] = client
    s.mu.Unlock()

    if old != nil && old != client {
        _ = old.Close()
    }
}
```

새 client를 먼저 map에 저장하고 lock을 푼 뒤 old client를 닫는다. 그러면 old listener가 실행되어도 `removeClient(runnerID, old)`는 false를 반환하고 current client와 registry online 상태를 유지할 수 있다.

수정 파일 및 체크리스트:

- [ ] `services/core/internal/runnersocket/server.go`에서 old client close를 mutex 밖으로 이동한다.
- [ ] `services/core/internal/runnersocket/server_test.go`에 `attachClientHandlers` 또는 동일한 production listener 재진입을 가진 client로 duplicate replacement가 완료되는 테스트를 추가한다.
- [ ] 테스트는 deadlock을 타임아웃으로 감지하지 않고, `setClient` 호출 완료와 current client 유지, stale disconnect skip을 명확히 assert한다.

중간 검증:

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

기대 결과: runnersocket package 통과.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/runnersocket/server.go` | REVIEW_API-1 |
| `services/core/internal/runnersocket/server_test.go` | REVIEW_API-1 |

## 최종 검증

```bash
cd services/core && go test -count=1 ./internal/runnersocket
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
```

기대 결과: 모든 명령 통과. `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션에 실제 stdout/stderr를 붙인다.
