<!-- task=m-field-bootstrap-test-port-readiness/01_serve_lifecycle plan=0 tag=TEST -->

# Field Serve Lifecycle Plan - TEST

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

구현을 마친 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채워야 한다. 검증은 계획에 적힌 명령을 실행한 실제 stdout/stderr로 기록하고, active 파일은 그대로 둔 채 리뷰 준비 완료를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review-skill 전용이다.

## 배경

현재 Milestone의 `[serve-lifecycle]`는 `edge serve --config <field-config>`가 field bootstrap용 node/openai listener를 계속 유지하고, Node registration과 `/v1/models`가 통과해야 완료된다. 작업트리에는 이미 `apps/edge/internal/bootstrap.Runtime`이 Fx startup context와 runtime lifetime을 분리하는 변경과 회귀 테스트가 들어와 있다. 이 계획은 그 변경을 검증 가능한 상태로 마무리하고 field-like 로컬 smoke로 실제 serve 경로를 확인한다.

## 분석 결과

### 읽은 파일

- `agent-ops/roadmap/current.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md`
- `agent-task/m-field-bootstrap-test-port-readiness/reboot-handoff.md`
- `docs/deploy-dev.md`
- `bin/edge.sh`
- `bin/node.sh`
- `Makefile`
- `apps/edge/cmd/edge/main.go`
- `apps/edge/cmd/edge/main_test.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/input/manager.go`
- `apps/edge/internal/input/manager_test.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/transport/server.go`
- `packages/config/config.go`
- `configs/edge.yaml`

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

- Runtime listener lifetime: `apps/edge/internal/bootstrap/runtime_test.go`의 `TestRuntimeKeepsListenersAfterStartupContextCancel`가 startup context cancel 뒤 Edge TCP와 OpenAI health listener 유지를 커버한다.
- Field serve integration: 실제 `go run ./apps/edge/cmd/edge serve --config <field-config>`와 `go run ./apps/node/cmd/node serve --config <field-node-config>` 조합의 Node registration, `/v1/models` 호출은 아직 자동 테스트로 커버되지 않는다. 이 계획의 field-like smoke가 그 공백을 보완한다.
- External code-server port mapping: remote compose recreate 여부와 외부 host port 적용은 이 계획의 코드 검증 범위가 아니다.

### 심볼 참조

- 이름 변경 또는 제거 없음.

### 분할 판단

- 분할 판단 정책을 계획 파일 선택 전에 평가했다.
- 공유 task group은 `m-field-bootstrap-test-port-readiness`다.
- `01_serve_lifecycle`는 Edge runtime lifetime과 field-like local serve smoke를 선행으로 완료한다.
- `02+01_one_line_node`는 `01_serve_lifecycle`의 `complete.log` 이후 Apple Silicon one-line bootstrap 검증을 수행한다.
- Edge process lifetime과 Mac bootstrap은 실행 환경, 실패 원인, 검증 출력이 달라 분리 대상이다.

### 범위 결정 근거

- 이 계획은 `[serve-lifecycle]`만 소유한다.
- `bin/build/field-binaries.sh`의 artifact/bootstrap 생성은 `02+01_one_line_node`가 소유한다.
- Control Plane enrollment, OTO agent 구현, cloud fallback, provider 확장은 Milestone 범위 제외라 다루지 않는다.
- remote `code-server` 컨테이너 재시작이나 compose recreate는 사용자가 명시하기 전 실행하지 않는다.

### 빌드 등급

- build=`cloud-G07`, review=`cloud-G07`: shell/CLI workflow, 장기 실행 프로세스, listener 상태, field-like integration 검증이 핵심인 terminal-agent 작업이다.

## 구현 체크리스트

- [ ] 현재 `Runtime` lifetime 변경을 유지하거나 보완해 `go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/input ./apps/edge/internal/openai ./apps/edge/cmd/edge`가 통과한다.
- [ ] 임시 field config로 `edge serve`와 `node serve`를 실제 프로세스로 실행해 Node registration과 `/v1/models`를 확인한다. 검증: `edge serve --config <field-config>` 실행 후 Node registration과 `/v1/models` 호출이 통과한다.
- [ ] `go test ./...`를 실행해 전체 Go 회귀를 확인한다. 캐시 출력은 허용한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [TEST-1] Runtime Lifetime

### 문제

`edge serve`는 Fx `OnStart`의 startup context가 반환 후 취소될 수 있는데, listener lifetime을 그 context에 묶으면 node/openai listener가 내려갈 수 있다. 현재 작업트리의 [runtime.go](/config/workspace/iop/apps/edge/internal/bootstrap/runtime.go:76)는 별도 lifetime context를 만들고 [runtime_test.go](/config/workspace/iop/apps/edge/internal/bootstrap/runtime_test.go:80)는 startup context cancel 뒤 listener 유지를 검증한다.

### 해결 방법

현재 변경을 기준선으로 삼되, 테스트 실패나 race가 발견되면 `Runtime`이 자체 cancel을 소유하고 `Stop()`에서만 cancel하도록 보완한다.

현재 기준:

```go
// apps/edge/internal/bootstrap/runtime.go:76
func (r *Runtime) Start(ctx context.Context) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	r.wireHandlers()
	lifetimeCtx := r.newLifetimeContext()
	if err := r.Server.Start(lifetimeCtx); err != nil {
		r.cancelLifetime()
		return err
	}
	if err := ctx.Err(); err != nil {
		r.cancelLifetime()
		_ = r.Server.Stop()
		return err
	}
	if err := r.Input.Start(lifetimeCtx); err != nil {
		r.cancelLifetime()
		_ = r.Server.Stop()
		return err
	}
	r.startMetrics()
	return nil
}
```

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

- [ ] `apps/edge/internal/bootstrap/runtime.go`: runtime lifetime context가 startup context cancellation에 묶이지 않는지 확인 또는 보완한다.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: startup context cancel 뒤 Edge TCP와 OpenAI health listener가 유지되는 회귀 테스트를 유지한다.

### 테스트 작성

추가 테스트는 기본적으로 작성하지 않는다. 기존 `TestRuntimeKeepsListenersAfterStartupContextCancel`가 직접 회귀를 커버하며, 실패할 때만 같은 파일에 필요한 edge case를 추가한다.

### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/input ./apps/edge/internal/openai ./apps/edge/cmd/edge
```

기대 결과: 모든 패키지가 `ok`로 종료한다.

## [TEST-2] Field-Like Serve Smoke

### 문제

Milestone의 `[serve-lifecycle]` 검증은 단위 테스트가 아니라 실제 field config에서 Node registration과 `/v1/models`가 통과하는지다. [docs/deploy-dev.md](/config/workspace/iop/docs/deploy-dev.md:187)는 실제 field 절차를 설명하지만, 현재 로컬 검증 출력은 아직 `CODE_REVIEW`에 남아 있지 않다.

### 해결 방법

임시 포트를 쓰는 edge/node config를 `/tmp`에 만들고, `go run`으로 `edge serve`와 `node serve`를 동시에 띄운다. edge 로그에서 registration을 확인하고 OpenAI-compatible `/v1/models`를 호출한다.

검증 스크립트는 repo에 남기지 말고 `/tmp`에서만 실행한다.

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

- [ ] 코드 수정이 필요하면 `apps/edge/internal/bootstrap/runtime.go` 또는 관련 테스트에만 최소 수정한다.
- [ ] 임시 config와 프로세스 제어 스크립트는 `/tmp`에 만들고 repo에 남기지 않는다.
- [ ] `CODE_REVIEW-cloud-G07.md`에 edge/node 실행 명령과 실제 출력, registration 근거, `/v1/models` 응답을 붙인다.

### 테스트 작성

자동 테스트 파일은 작성하지 않는다. 이 항목은 실제 프로세스 smoke가 목적이며, 단위 회귀는 `TEST-1`에서 커버한다.

### 중간 검증

```bash
tmpdir=$(mktemp -d /tmp/iop-serve-lifecycle.XXXXXX)
cat > "$tmpdir/edge.yaml" <<'YAML'
edge:
  id: "edge-field-local"
  name: "Field Local Edge"
server:
  listen: "127.0.0.1:39090"
openai:
  enabled: true
  listen: "127.0.0.1:38080"
  node: "node-field-local"
  adapter: "ollama"
  target: ""
  models:
    - "field-smoke-model"
  session_id: "openai-field"
  timeout_sec: 30
metrics:
  port: 0
nodes:
  - id: "node-field-local"
    alias: "field-local"
    token: "field-token"
    adapters:
      ollama:
        enabled: true
        base_url: "http://127.0.0.1:11434"
        context_size: 4096
YAML
cat > "$tmpdir/node.yaml" <<'YAML'
transport:
  edge_addr: "127.0.0.1:39090"
  token: "field-token"
logging:
  level: "error"
  pretty: true
metrics:
  port: 0
YAML
go run ./apps/edge/cmd/edge serve --config "$tmpdir/edge.yaml" > "$tmpdir/edge.log" 2>&1 &
edge_pid=$!
for _ in $(seq 1 30); do
  if timeout 1 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/39090' 2>/dev/null && curl -fsS http://127.0.0.1:38080/healthz >/dev/null; then
    break
  fi
  sleep 1
done
go run ./apps/node/cmd/node serve --config "$tmpdir/node.yaml" > "$tmpdir/node.log" 2>&1 &
node_pid=$!
cleanup() {
  kill "$node_pid" "$edge_pid" 2>/dev/null || true
  wait "$node_pid" "$edge_pid" 2>/dev/null || true
  rm -rf "$tmpdir"
}
trap cleanup EXIT
for _ in $(seq 1 30); do
  if grep -q 'node registered' "$tmpdir/edge.log" && curl -fsS http://127.0.0.1:38080/v1/models >/tmp/iop-models.json; then
    cat /tmp/iop-models.json
    exit 0
  fi
  sleep 1
done
cat "$tmpdir/edge.log"
cat "$tmpdir/node.log"
exit 1
```

기대 결과: 명령이 `0`으로 종료하고 `/v1/models` 응답에 `field-smoke-model`이 포함된다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/bootstrap/runtime.go` | TEST-1 |
| `apps/edge/internal/bootstrap/runtime_test.go` | TEST-1 |
| `/tmp/iop-serve-lifecycle.*` | TEST-2 검증 전용, repo에 남기지 않음 |

## 최종 검증

```bash
go test ./...
```

기대 결과: 모든 Go 패키지가 `ok` 또는 `[no test files]`로 종료한다. 캐시 출력은 허용한다.

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