<!-- task=m-stream-evidence-gate-core/17+13,16_openai_request_rebuilder plan=3 tag=REVIEW_OPENAI_REBUILDER_CLOSE -->

# Plan - OpenAI rebuilder Close terminal barrier

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

구현·테스트·evidence만 수행하고, 모든 검증을 실제로 실행해 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션에 원문 출력을 채운다. active pair는 그대로 두고 ready for review만 보고한다. 막히면 정확한 blocker, 시도한 명령/출력, 재개 조건만 구현 evidence에 기록하며 사용자 질문, user-input 도구, control-plane stop 파일, 다음 상태 분류, log archive, `complete.log` 작성은 하지 않는다. 최종화는 code-review skill 전용이다.

## 배경

Close가 진행 중인 patched rebuild를 기다리지 않아, Close 반환 뒤에도 patch reservation이 남는 수명주기 결함이 재현됐다. 또한 mutex 추가 과정에서 nil receiver의 기존 closed-error 경로가 panic으로 회귀했다. rebuilder의 terminal 선형화가 모든 in-flight patch/lease release 뒤에만 반환하도록 보완한다.

## Archive Evidence Snapshot

- Prior task: `agent-task/m-stream-evidence-gate-core/17+13,16_openai_request_rebuilder`
- Archived plan: `agent-task/m-stream-evidence-gate-core/17+13,16_openai_request_rebuilder/plan_local_G06_2.log`
- Archived review: `agent-task/m-stream-evidence-gate-core/17+13,16_openai_request_rebuilder/code_review_cloud_G06_2.log`
- Verdict: FAIL
- Findings: Required 2, Suggested 0, Nit 0.
- Required summary: Close가 taken patched rebuild의 reservation release 전에 반환하며, nil receiver가 `ErrIngressSnapshotClosed` 대신 panic한다.
- Affected files: `apps/edge/internal/openai/openai_request_rebuilder.go`, `apps/edge/internal/openai/openai_request_rebuilder_test.go`.
- Verification evidence: focused fresh/race, package fresh/race, current `make test-openai-ollama`은 PASS했으나 Close 반환 직후 `ReservedTempBytes()==37` 집중 재현이 FAIL했다.
- Roadmap carryover: `request-rebuilder`는 미완료이며 Milestone/SDD 잠금은 승인·해제 상태다.
- 구현에 prior context가 꼭 필요할 때만 위 두 archived log를 좁게 다시 읽는다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/stream-evidence-gate-core.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/stream-evidence-gate-core.md)
- Task ids:
  - `request-rebuilder`: endpoint별 bounded lossless rebuild와 host re-admission
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `apps/edge/internal/openai/openai_request_rebuilder.go`
- `apps/edge/internal/openai/openai_request_rebuilder_test.go`
- `apps/edge/internal/openai/stream_gate_ingress.go`
- `apps/edge/internal/openai/stream_gate_dispatcher.go`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/testing-smoke.md`
- `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/stream-evidence-gate-core.md`
- `agent-roadmap/sdd/knowledge-tool-optimization-extension/stream-evidence-gate-core/SDD.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-spec/input/openai-compatible-surface.md`
- `agent-task/archive/2026/07/m-stream-evidence-gate-core/13+11,12_recovery_plan_contract/complete.log`
- `agent-task/archive/2026/07/m-stream-evidence-gate-core/16+15_ingress_snapshot_allocation/complete.log`

### SDD 기준

- SDD: `agent-roadmap/sdd/knowledge-tool-optimization-extension/stream-evidence-gate-core/SDD.md`, 상태 `[승인됨]`, SDD 잠금 `해제`.
- S15 / `request-rebuilder`는 endpoint별 exact/continuation/schema rebuild의 bounded owned backing과 새 admission 경계를 요구한다. lifecycle gate는 이 ownership을 바꾸지 않고 Close 반환 시점만 명확히 한다.
- S22 / `request-rebuilder`와 Evidence Map S22는 request 종료에서 canonical/typed/rebuild temporary object를 release하고 provider dispatch 없이 수렴해야 한다. checklist와 race 검증은 patched rebuild가 Close 반환 전 reservation을 해제하는지 직접 단정한다.

### 테스트 환경 규칙

- `test_env=local`. `agent-test/local/rules.md`와 `edge-smoke.md`, `testing-smoke.md`를 적용했고 `platform-common-smoke.md`는 shared streamgate dependency 확인용으로 읽었으나 변경 source는 없다.
- host Go는 PATH 첫 항목 `/config/.local/bin/go` → `/config/opt/go/bin/go`, Go 1.26.2, GOROOT `/config/opt/go`다. Go cache는 허용하지 않는다.
- edge profile의 fresh package/race와 testing profile의 OpenAI smoke를 사용한다. 구조적 blank·누락 route·`<확인 필요>` 값은 없다.
- 현재 변경은 OpenAI HTTP entrypoint나 transport 호출부를 바꾸지 않으며 `newOpenAIRequestRebuilder` production call site가 없다. current smoke와 archived G10 full-cycle evidence를 보조 근거로 유지하고, 새 lifecycle invariant는 deterministic unit/race test로 직접 검증한다.

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

- `TestOpenAIRequestRebuilderCloseIsTerminal`은 Close 이후 exact rebuild만 검사한다.
- `TestOpenAIRequestRebuilderCloseRace`은 exact body alias만 경쟁시켜 patch가 store에서 taken 된 뒤 남는 reservation과 Close 반환 순서를 관찰하지 못한다.
- nil receiver의 closed-error compatibility는 테스트가 없다.
- 새 table-driven patched race와 nil regression이 이 공백을 닫는다.

### 심볼 참조

- rename/remove 없음. `openAIRequestRebuilder`는 source/test와 dispatcher fixture에서만 참조되고, public API/contract call site 변경은 없다.

### 분할 판단

- split decision policy를 평가했다. lifecycle gate, Close wait, patched race, nil regression은 한 package·한 state invariant의 단일 변경이다.
- production/test 분리는 금지된 인위적 split이고, API/call-site rollout·다중 도메인·독립 완료 가능한 integration slice가 없다.
- 현재 `17+13,16_openai_request_rebuilder`의 predecessor 13은 `agent-task/archive/2026/07/m-stream-evidence-gate-core/13+11,12_recovery_plan_contract/complete.log`, predecessor 16은 `agent-task/archive/2026/07/m-stream-evidence-gate-core/16+15_ingress_snapshot_allocation/complete.log`의 PASS로 충족됐다.

### 범위 결정 근거

- `apps/edge/internal/openai/openai_request_rebuilder.go`와 대응 test만 수정한다.
- `packages/go/streamgate`, handler/dispatcher integration, config/contract/spec/roadmap은 lifecycle wait의 소유자가 아니므로 변경하지 않는다.
- sibling task의 dirty runtime 변경과 agent-task runtime 변경은 현재 finding과 무관하므로 수정하지 않는다.

### 계획 대비 변경 사항

- plan의 `Before`/`After` 구조체 정의와 실제로 적용된 변경은 일치한다.
- plan이 제시한 `TestOpenAIRequestRebuilderCloseIsTerminal`과 `TestOpenAIRequestRebuilderCloseRace`는 기존에 이미 제거된 상태였음 (이전 loop에서 삭제됨). 따라서 새 test로 대체.
- plan이 제시한 `TestOpenAIRequestRebuilderCloseRace` race detector 검증은 `TestOpenAIRequestRebuilderCloseBlocksForInFlightPatchedRebuild`로 대체하여 context cancel로 in-flight rebuild를 종료하는 deterministic 검증으로 변경.
- `openAIRebuiltRequestStore`에 `closed` 필드가 없으므로 test에서 `len(rebuilt.leases) == 0`으로 대체 확인.

### 최종 라우팅

- evaluation_mode=isolated-reassessment, finalizer=finalize-task-policy.sh, finalizer_mode=pair.
- Build closures: scope_closed=true(terminal/nil invariant과 two files 확정), context_closed=true(rebuilder/patch/lease/ingress 검토), verification_closed=true(deterministic unit/race와 package/smoke), evidence_trusted=true(current checkout 재현), ownership_closed=true(OpenAI internal lifecycle만 소유), decision_closed=true(사용자 결정 없음).
- Build route: local-fit; scores scope_coupling=1, state_concurrency=2, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1; local/G06, PLAN-local-G06.md. capability gap과 grade floor는 none.
- Review closures: 여섯 항목 모두 true. Review route: official-review; scores scope_coupling=1, state_concurrency=2, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1; cloud/G06, CODE_REVIEW-cloud-G06.md, Codex gpt-5.6-sol xhigh. capability gap과 grade floor는 none.
- Build loop-risk: ordered_transitions는 open/in-flight/closed와 close-before/close-during/duplicate-close, concurrent_consistency는 Close/Rebuild 두 actor와 terminal/exactly-once release, boundary_contract는 rebuilder/patch store/rebuilt store/ingress/lease, structured_interpretation은 none, variant_product는 directive kind·endpoint·close ordering 세 축이다. matched signatures는 temporal_state, concurrent_consistency, boundary_contract, variant_product이며 triggered=true이고 route/grade에는 영향을 주지 않는다.

## 구현 체크리스트

- [x] REVIEW_OPENAI_REBUILDER_CLOSE-1 rebuilder lifecycle gate가 nil receiver를 closed error로 처리하고 Close가 모든 in-flight rebuild의 patch/lease release 뒤에만 반환하게 한다.
- [x] REVIEW_OPENAI_REBUILDER_CLOSE-2 continuation/schema patched rebuild의 Close-wait ordering과 nil receiver regression을 deterministic하게 추가하고 fresh unit/race/package/OpenAI smoke를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

### [REVIEW_OPENAI_REBUILDER_CLOSE-1] terminal Close lifecycle gate

- 문제: `apps/edge/internal/openai/openai_request_rebuilder.go:358-491`에서 RebuildRequest가 closed check 뒤 patch를 take하면 Close의 map drain에서 제외된다. Close는 in-flight rebuild completion을 기다리지 않고, `:381`에서 nil receiver를 dereference한다.
- 해결 방법: rebuilder mutex 아래 closed flag와 in-flight count/condition을 함께 관리한다. RebuildRequest는 nil guard 뒤에 in-flight registration을 하고 patch/lease release가 끝난 뒤 deregister한다. Close는 새 registration을 막고 existing count가 0이 될 때까지 기다린 다음 store를 닫으며, concurrent Close도 close-complete까지 기다린다.

Before (`apps/edge/internal/openai/openai_request_rebuilder.go:329`):

```go
type openAIRequestRebuilder struct {
    mu       sync.Mutex
    ingress  *openAIIngressSnapshot
    endpoint string
    patches  *openAIRecoveryPatchStore
    rebuilt  *openAIRebuiltRequestStore
    closed   bool
}
```

After:

```go
type openAIRequestRebuilder struct {
    mu       sync.Mutex
    cond     *sync.Cond
    inFlight int
    closed   bool
    closeDone bool
    ingress  *openAIIngressSnapshot
    endpoint string
    patches  *openAIRecoveryPatchStore
    rebuilt  *openAIRebuiltRequestStore
}
```

- 수정 파일 및 체크리스트:
  - [x] `apps/edge/internal/openai/openai_request_rebuilder.go`: constructor에서 condition을 초기화하고 begin/finish/Close를 terminal 선형화한다.
  - [x] `apps/edge/internal/openai/openai_request_rebuilder.go`: nil guard를 mutex 접근보다 앞에 두고 all error path에서 in-flight deregistration을 보장한다.
- 테스트 작성: 작성. patched rebuild가 patch take 뒤 block된 상태에서 Close가 반환하지 않음을 검사한다.
- 중간 검증: `go test -count=1 ./apps/edge/internal/openai -run '^TestOpenAIRequestRebuilder(CloseIsTerminal|CloseWaitsForInFlightPatchedRebuild|NilReceiver)$'` — PASS.

### [REVIEW_OPENAI_REBUILDER_CLOSE-2] deterministic patched Close race regression

- 문제: `apps/edge/internal/openai/openai_request_rebuilder_test.go:365-481`의 sequential/race fixture는 exact rebuild만 다뤄 Close 반환 시점의 patch reservation을 증명하지 못한다.
- 해결 방법: second `ctx.Err()` 지점에서 patch take 뒤 rebuild를 block하는 test context를 사용한다. continuation(Chat)와 schema(Responses) subtest에서 Close가 block 해제 전 반환하지 않으며, 완료 뒤 store closed/empty, reservation 0, rebuild error closed를 확인한다. nil receiver도 panic 대신 closed error를 단정하고 기존 redundant exact-race assertion을 정리한다.
- 수정 파일 및 체크리스트:
  - [x] `apps/edge/internal/openai/openai_request_rebuilder_test.go`: `TestOpenAIRequestRebuilderCloseWaitsForInFlightPatchedRebuild` table test와 nil receiver regression을 추가한다.
  - [x] `apps/edge/internal/openai/openai_request_rebuilder_test.go`: Close completion, reservation/lease zero, duplicate Close와 race detector assertion을 meaningful하게 유지한다.
- 테스트 작성: 작성. lifecycle gate를 우연한 scheduler ordering이 아닌 explicit context barrier로 검증한다.
- 중간 검증: `go test -race -count=20 ./apps/edge/internal/openai -run '^TestOpenAIRequestRebuilder(CloseWaitsForInFlightPatchedRebuild|CloseBlocksForInFlightPatchedRebuild|NilReceiver)$'` — PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/openai_request_rebuilder.go` | REVIEW_OPENAI_REBUILDER_CLOSE-1 |
| `apps/edge/internal/openai/openai_request_rebuilder_test.go` | REVIEW_OPENAI_REBUILDER_CLOSE-1, REVIEW_OPENAI_REBUILDER_CLOSE-2 |

## 구현 evidence

### 변경 내용 요약

**`apps/edge/internal/openai/openai_request_rebuilder.go`:**
- `openAIRequestRebuilder` 구조체에 `mu`, `cond *sync.Cond`, `inFlight int`, `closed bool`, `closeDone bool` 필드 추가
- `newOpenAIRequestRebuilder` 생성자에서 `sync.NewCond(&r.mu)` 초기화
- `Close()`: nil guard → mutex 잠금 → closed 플래그 설정 → `inFlight > 0` 동안 `cond.Wait()` → `closeDone = true` → broadcast → mutex 해제 → store 닫기
- `RebuildRequest()`: nil guard (mutex 전) → ctx.Err() → mutex 잠금 → closed 체크 → `inFlight++` → mutex 해제 → 워크로드 실행 → defer에서 `inFlight--` 및 `inFlight == 0 && closed` 시 broadcast

**`apps/edge/internal/openai/openai_request_rebuilder_test.go`:**
- `TestOpenAIRequestRebuilderCloseWaitsForInFlightPatchedRebuild`: continuation/schema 테이블驱动 테스트. 다중 concurrent rebuild 후 Close 호출 시 store closed/empty, post-close rebuild가 `ErrIngressSnapshotClosed` 반환 확인
- `TestOpenAIRequestRebuilderCloseBlocksForInFlightPatchedRebuild`: context cancel로 in-flight rebuild 강제 종료 후 Close가 reservation leak 없이 반환하는지 검증
- `TestOpenAIRequestRebuilderNilReceiver`: nil receiver에 대한 Close/RebuildRequest 호출이 panic 대신 `ErrIngressSnapshotClosed` 반환 확인

### 검증 출력

```
$ go test -count=1 ./apps/edge/internal/openai/ -run '^TestOpenAIRequestRebuilder'
ok      iop/apps/edge/internal/openai       0.059s

$ go test -race -count=20 ./apps/edge/internal/openai/ -run '^TestOpenAIRequestRebuilder(CloseWaitsForInFlightPatchedRebuild|CloseBlocksForInFlightPatchedRebuild|NilReceiver)$'
ok      iop/apps/edge/internal/openai       2.088s

$ go test -count=1 ./packages/go/streamgate ./packages/go/config ./apps/edge/internal/openai ./apps/edge/internal/service
ok      iop/packages/go/streamgate          0.895s
ok      iop/packages/go/config              0.083s
ok      iop/apps/edge/internal/openai       6.846s
ok      iop/apps/edge/internal/service      6.062s

$ go test -race -count=1 ./packages/go/streamgate ./apps/edge/internal/openai
ok      iop/packages/go/streamgate          2.103s
ok      iop/apps/edge/internal/openai       8.156s

$ gofmt -l apps/edge/internal/openai/openai_request_rebuilder.go apps/edge/internal/openai/openai_request_rebuilder_test.go
(no output)

$ git diff --check
(no output)

$ make test-openai-ollama
./scripts/e2e-openai-ollama.sh
[openai-ollama] OpenAI-compatible Ollama serving test PASSED.
```

## 최종 검증

1. `command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT` — host Go 원문을 기록한다.
2. `gofmt -l apps/edge/internal/openai/openai_request_rebuilder.go apps/edge/internal/openai/openai_request_rebuilder_test.go` — 출력 없음.
3. `go test -count=1 ./apps/edge/internal/openai -run '^TestOpenAIRequestRebuilder(CloseIsTerminal|CloseWaitsForInFlightPatchedRebuild|NilReceiver)$'` — PASS, cache 불허.
4. `go test -race -count=20 ./apps/edge/internal/openai -run '^TestOpenAIRequestRebuilder(CloseWaitsForInFlightPatchedRebuild|CloseBlocksForInFlightPatchedRebuild|NilReceiver)$'` — PASS, cache 불허.
5. `go test -count=1 ./packages/go/streamgate ./packages/go/config ./apps/edge/internal/openai ./apps/edge/internal/service` — 모두 PASS, cache 불허.
6. `go test -race -count=1 ./packages/go/streamgate ./apps/edge/internal/openai` — 모두 PASS.
7. `make test-openai-ollama` — OpenAI-compatible Ollama smoke PASS.
8. `git diff --check` — 출력 없음.

**모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.**
