<!-- task=m-architecture-refactor-foundation/07+06_fleet_polling plan=0 tag=API -->

# PLAN - API fleet-polling

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

구현 완료의 마지막 단계는 `CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 출력 원문을 붙이고, active 파일은 그대로 둔 채 리뷰 준비 상태를 보고한다. 종료 처리, 로그 아카이브, `complete.log` 작성, task directory 이동은 code-review skill 전용이다.

구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret/서비스 준비, 또는 안전하게 진행할 수 없는 충돌이 생기면 직접 질문하지 말고 `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션을 증거와 함께 채운 뒤 멈춘다. `request_user_input`, 채팅 선택지 제시, `USER_REVIEW.md` 생성, `complete.log` 작성, active 파일 아카이브는 구현 에이전트가 하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청 사유가 아니다.

## 배경

현재 `/fleet/status`와 `/fleet/commands`는 registry snapshot을 순차 순회하며 각 Edge에 직접 status/command를 요청한다. Edge 수가 늘면 한 Edge timeout이 전체 응답 지연으로 전파되고, 중복 status 조회가 계속 wire 호출을 만든다. `cp-api-split`으로 handler 파일 경계를 만든 뒤 fleet orchestration을 bounded concurrency, cache, timeout 정책을 가진 service로 분리한다.

## 사용자 리뷰 요청 흐름

구현 중 차단 결정은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`와 동일한 형식을 사용한다. 구현 에이전트는 사용자에게 직접 묻지 않으며, code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Task ids:
  - `fleet-polling`: `/fleet/status`와 fleet command 호출을 bounded concurrency, cache, timeout 정책으로 정리한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/control-plane/rules.md`
- `agent-ops/rules/project/domain/client/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/control-plane-smoke.md`
- `agent-test/local/client-smoke.md`
- `.gitignore`
- `go.mod`
- `apps/control-plane/cmd/control-plane/main.go`
- `apps/control-plane/cmd/control-plane/main_test.go`
- `apps/control-plane/internal/wire/edge_registry.go`
- `apps/control-plane/internal/wire/edge_server.go`
- `apps/client/pubspec.yaml`
- `apps/client/lib/main.dart`
- `apps/client/lib/control_plane_status_client.dart`
- `apps/client/lib/control_plane_status_widgets.dart`
- `apps/client/test/widget_test.dart`

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md` 존재 및 정독 완료. Control Plane 변경은 `agent-test/local/control-plane-smoke.md`를 baseline으로 사용한다.
- 적용 명령: 변경한 Control Plane 패키지는 `go test ./apps/control-plane/...`를 실행한다. concurrency/cache 동작은 fresh execution이 필요하므로 `go test -count=1 ./apps/control-plane/...`로 고정한다.
- Control Plane-Edge 실제 wire smoke는 registry lifecycle/protocol 자체 변경이 아니므로 이 plan의 필수 검증에서 제외한다.
- `<확인 필요>` 값 없음. agent-test fallback 없음.

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

- 기존 `TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities`는 online/degraded/offline view와 leak 금지를 검증하지만 concurrency bound와 cache reuse를 검증하지 않는다.
- 기존 `TestFleetCommandsHTTPHandlerFansOutToConnectedEdgesOnly`는 offline skip만 검증하고 command fan-out concurrency나 timeout 전달값을 검증하지 않는다.
- 새 tests가 `max in-flight`, cache hit count, per-call timeout 전달, stable response ordering을 검증해야 한다.

### 심볼 참조

- 제거/이름 변경 예정 없음.
- 이동/확장 예정 call site:
  - `registerFleetHandlers`: `apps/control-plane/cmd/control-plane/main.go:183`, `apps/control-plane/cmd/control-plane/main_test.go:746`, `818`
  - `fleetEdgeViewFromState`: `apps/control-plane/cmd/control-plane/main.go:510`, `690`
  - `edgeStatusRequester`: `apps/control-plane/cmd/control-plane/main.go:376`
  - `edgeCommandSender`: `apps/control-plane/cmd/control-plane/main.go:380`

### 분할 판단

- split decision policy를 평가했다. 이 plan은 multi-plan sibling 중 두 번째다.
- shared task group: `agent-task/m-architecture-refactor-foundation/`
- dependency from directory name: predecessor `06_cp_api_split`.
- predecessor status: `agent-task/m-architecture-refactor-foundation/06_cp_api_split/complete.log` missing. 구현 시작 전 `06_cp_api_split` PASS archive 또는 active sibling `complete.log`가 필요하다.
- 이 plan 내부는 단일 plan으로 유지한다. `/fleet/status`와 `/fleet/commands`가 같은 timeout/concurrency policy를 공유해야 하므로 service/options/cache를 한 번에 설계해야 한다.

### 범위 결정 근거

- `cp-api-split`의 파일 경계 정리는 이 plan에서 반복하지 않는다. 선행 plan 완료 결과를 기반으로만 구현한다.
- Edge wire protocol과 `apps/control-plane/internal/wire/**` transport 계약은 변경하지 않는다.
- Flutter client polling UI나 repository retry 정책은 `08_client_state_split` 범위로 남긴다.
- 외부 config/env YAML에 새 옵션을 노출하지 않는다. 기본 policy는 package 내부 default로 두고 tests는 option injection helper를 사용한다.

### 빌드 등급

- build lane: `local-G08`. HTTP API observable latency/error behavior와 concurrency/cache logic을 바꾸는 작업이다.
- review lane: `cloud-G08`. Race/ordering/timeout 회귀를 코드리뷰에서 집중 검증해야 한다.

## 의존 관계 및 구현 순서

- `07+06_fleet_polling`은 `06_cp_api_split` 완료 후 구현한다.
- 만족 조건: `agent-task/m-architecture-refactor-foundation/06_cp_api_split/complete.log` 또는 archive 내 동일 subtask `complete.log`.
- 현재 상태: missing.

## 구현 체크리스트

- [ ] 선행 `06_cp_api_split` 완료 근거를 확인하고, 없으면 `사용자 리뷰 요청`이 아니라 선행 작업 미완료로 구현을 중단 보고한다.
- [ ] fleet status/command orchestration service와 기본 timeout/concurrency/cache option을 추가한다.
- [ ] `/fleet/status`를 bounded concurrency와 fresh status cache를 사용하도록 바꾸고 기존 JSON 계약을 유지한다.
- [ ] `/fleet/commands` fan-out을 bounded concurrency로 바꾸고 offline skip, per-edge result, timeout 전달을 유지한다.
- [ ] concurrency/cache/timeout 회귀 테스트를 추가하고 `gofmt`, `go test -count=1 ./apps/control-plane/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [API-1] Fleet Orchestration Service 추가

### 문제

`registerFleetHandlers`가 handler closure 안에서 registry snapshot을 직접 순회한다. `/fleet/status`는 `apps/control-plane/cmd/control-plane/main.go:507`부터 `511`까지 순차로 `fleetEdgeViewFromState`를 호출하고, `/fleet/commands`는 `532`부터 `548`까지 순차 fan-out한다.

Before:

```go
// apps/control-plane/cmd/control-plane/main.go:507
states := registry.Snapshots()
views := make([]fleetEdgeView, 0, len(states))
for _, state := range states {
	views = append(views, fleetEdgeViewFromState(state, requestStatus))
}
```

### 해결 방법

- 새 파일 `apps/control-plane/cmd/control-plane/fleet_orchestrator.go`를 만든다.
- `fleetOptions`를 추가한다: `StatusTimeout`, `CommandTimeout`, `MaxConcurrentStatus`, `MaxConcurrentCommands`, `StatusCacheTTL`, `Now`.
- `newFleetService(registry, requestStatus, sendCommand, options)`를 추가한다.
- `registerFleetHandlers`는 service를 생성해 `Status()`와 `Command()` 결과만 write한다.
- default는 현재 timeout을 유지한다: status `5*time.Second`, command `10*time.Second`. concurrency default는 4, cache TTL은 2초로 둔다.

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

- [ ] `apps/control-plane/cmd/control-plane/fleet_orchestrator.go`: service/options/cache 추가.
- [ ] `apps/control-plane/cmd/control-plane/http_fleet_handlers.go`: handler가 service에 위임하도록 변경.
- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: option injection 가능한 test helper를 추가한다.

### 테스트 작성

- 작성: `TestFleetStatusHTTPHandlerUsesConfiguredTimeout`.
- assertion: requester가 받은 timeout이 `fleetOptions.StatusTimeout`과 같다.
- fixture: package-private `registerFleetHandlersWithOptions` 또는 service direct test.

### 중간 검증

```bash
gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/main_test.go
go test -count=1 ./apps/control-plane/...
```

## [API-2] `/fleet/status` bounded concurrency와 cache 적용

### 문제

`fleetEdgeViewFromState`는 connected Edge마다 `requestStatus(state.EdgeID, 5*time.Second)`를 호출한다. 현재 순차 실행이라 여러 Edge 중 하나가 느리면 전체 response가 지연되고, 바로 이어지는 동일 request도 wire status call을 반복한다.

Before:

```go
// apps/control-plane/cmd/control-plane/main.go:707
resp, err := requestStatus(state.EdgeID, 5*time.Second)
if err != nil {
	view.Health = "degraded"
	view.Error = err.Error()
	return view
}
```

### 해결 방법

- connected Edge status 요청을 goroutine worker/semaphore로 실행하되 결과 slice는 snapshot order를 유지한다.
- status cache key는 `edgeID`, value는 `fleetEdgeView`와 expiry time으로 둔다.
- cache hit은 connected Edge에만 적용한다. disconnected Edge는 registry state를 즉시 반영해야 하므로 cache를 사용하지 않는다.
- requester error와 proto `Error`는 기존처럼 degraded view로 유지한다.
- raw node snapshots, token/address leak 금지 테스트를 유지한다.

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

- [ ] `apps/control-plane/cmd/control-plane/fleet_orchestrator.go`: status cache, semaphore, stable ordering 구현.
- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: max in-flight와 cache hit tests 추가.

### 테스트 작성

- 작성: `TestFleetStatusHTTPHandlerBoundsConcurrentStatusRequests`.
  - fixture: 5개 connected Edge, `MaxConcurrentStatus=2`, blocked requester.
  - assertion: 관측된 max in-flight가 2 이하이고 response edge 수/순서가 snapshot과 일치한다.
- 작성: `TestFleetStatusHTTPHandlerReusesFreshStatusCache`.
  - fixture: `StatusCacheTTL=time.Minute`, 같은 Edge에 대해 연속 두 번 `/fleet/status`.
  - assertion: requester call count가 첫 요청 이후 증가하지 않는다.

### 중간 검증

```bash
gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/main_test.go
go test -count=1 ./apps/control-plane/...
```

## [API-3] `/fleet/commands` bounded fan-out 적용

### 문제

`/fleet/commands`는 connected Edge만 대상으로 하지만 `apps/control-plane/cmd/control-plane/main.go:532`부터 `548`까지 순차로 `sendCommand`를 호출한다. 한 Edge timeout이 전체 fan-out을 지연시키며 concurrency 상한이 없다.

Before:

```go
// apps/control-plane/cmd/control-plane/main.go:532
results := make([]edgeCommandResponseView, 0)
for _, state := range registry.Snapshots() {
	if !state.Connected {
		continue
	}
	resp, err := sendCommand(state.EdgeID, body.Operation, body.TargetSelector, body.Parameters, 10*time.Second)
```

### 해결 방법

- connected Edge command dispatch를 semaphore-bound goroutine으로 실행한다.
- response order는 connected Edge snapshot order를 유지한다.
- per-edge error는 기존처럼 `Status: "error"`와 `Error` field로 response에 포함한다.
- `sendCommand == nil`, invalid body, missing operation status codes는 기존 behavior를 유지한다.

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

- [ ] `apps/control-plane/cmd/control-plane/fleet_orchestrator.go`: command fan-out helper 추가.
- [ ] `apps/control-plane/cmd/control-plane/http_fleet_handlers.go`: command handler가 helper를 사용하도록 변경.
- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: command concurrency/timeout/order test 추가.

### 테스트 작성

- 작성: `TestFleetCommandsHTTPHandlerBoundsConcurrentFanout`.
  - fixture: 5개 connected Edge, 1개 offline Edge, `MaxConcurrentCommands=2`.
  - assertion: max in-flight 2 이하, offline Edge 제외, 결과는 connected snapshot order.
- 작성: 기존 `TestFleetCommandsHTTPHandlerFansOutToConnectedEdgesOnly`에 timeout 전달 assertion 추가 또는 별도 test 작성.

### 중간 검증

```bash
gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/main_test.go
go test -count=1 ./apps/control-plane/...
```

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/control-plane/cmd/control-plane/fleet_orchestrator.go` | API-1, API-2, API-3 |
| `apps/control-plane/cmd/control-plane/http_fleet_handlers.go` | API-1, API-3 |
| `apps/control-plane/cmd/control-plane/main_test.go` | API-1, API-2, API-3 |

## 최종 검증

```bash
gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/main_test.go
go test -count=1 ./apps/control-plane/...
```

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