<!-- task=m-iop-agent-cli-runtime/01_common_runtime_node_bridge plan=1 tag=REVIEW_API -->

# Terminal event ordering follow-up

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

구현과 검증을 마친 뒤 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션에 실제 변경 내용과 stdout/stderr를 기록하고 active 파일을 그대로 둔 채 리뷰 준비 완료를 보고한다. 최종 판정, 사용자 판단 요청, user-input 도구 호출, control-plane stop 파일, log archive, `complete.log` 작성은 코드리뷰 에이전트 전용이다. 진행이 막히면 구현 소유 evidence 필드에 정확한 blocker, 시도한 명령/output, 재개 조건만 기록한다.

## 배경

공통 runtime 추출은 구조·회귀 검증을 통과했지만 terminal guard가 concurrent event의 승인 순서와 실제 sink 전달 순서를 함께 직렬화하지 않는다. Node terminal-deferring sink도 terminal 뒤 non-terminal event를 보존하는 변형이 있어, terminal 이후 event를 host에 노출하지 않는 inner contract와 S04 provider behavior 보존 조건을 위반한다. 두 변형은 같은 terminal ordering 불변조건이므로 한 후속 계획에서 수정한다.

## Archive Evidence Snapshot

- 이전 task: `agent-task/m-iop-agent-cli-runtime/01_common_runtime_node_bridge/`
- 이전 plan/review: `plan_cloud_G10_0.log`, `code_review_cloud_G10_0.log`
- 판정: `FAIL`; Required 1, Suggested 0, Nit 0
- Required: `packages/go/agentruntime/emitter.go`의 concurrent delivery 역전과 `apps/node/internal/node/runtime_sink.go`의 post-terminal non-terminal flush를 함께 수정해야 한다.
- 영향 파일: `packages/go/agentruntime/emitter.go`, `packages/go/agentruntime/emitter_test.go`, `apps/node/internal/node/runtime_sink.go`, `apps/node/internal/node/sink_test.go`
- 검증 evidence: fresh Go package/race/aggregate suites와 mock smoke는 PASS했다. reviewer의 channel-controlled reproducer는 `[complete, delta]`를 관측해 FAIL했다. `IOP_DEV_RECONNECT_BIND_TIMEOUT=60 ./scripts/dev/edge-node-reconnect-diagnostic.sh`는 3개 run의 Node==Edge payload 순서, terminal-last exactly-once, reconnect와 다섯 command 응답을 검증해 PASS했다.
- reviewer 정리: 이동된 CLI 경로를 readability read-set/baseline에 반영했고 project rule의 central runtime 경로를 동기화했다. 전체 readability ratchet의 task 밖 worktree 위반은 이 follow-up 범위가 아니다.
- Roadmap carryover: `node-consumer`는 미완료이며 SDD S04 evidence를 이 후속 PASS로 닫는다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/iop-agent-cli-runtime.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/automation-runtime-bridge/milestones/iop-agent-cli-runtime.md)
- Task ids:
  - `node-consumer`: Node의 공통 runtime bridge 전환
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `packages/go/agentruntime/emitter.go`
- `packages/go/agentruntime/emitter_test.go`
- `apps/node/internal/node/runtime_sink.go`
- `apps/node/internal/node/sink_test.go`
- `agent-contract/inner/agent-runtime.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/iop-agent-cli-runtime.md`
- `agent-roadmap/sdd/automation-runtime-bridge/iop-agent-cli-runtime/SDD.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/testing-smoke.md`
- `agent-ops/skills/project/e2e-smoke/SKILL.md`
- `scripts/dev/edge.sh`
- `scripts/dev/node.sh`
- `scripts/dev/edge-node-reconnect-diagnostic.sh`
- `scripts/e2e-smoke.sh`

### SDD 기준

- SDD: `agent-roadmap/sdd/automation-runtime-bridge/iop-agent-cli-runtime/SDD.md`, 상태 `[승인됨]`, SDD 잠금 `해제`
- 대상: S04 → Milestone Task `node-consumer`
- Evidence Map: S04의 Node wire/config compatibility suite와 기존 contract conformance evidence
- 반영: terminal ordering/suppression regression tests를 구현 체크리스트에 두고, Node package/race suite와 실제 Edge-Node two-process 진단을 최종 evidence로 재실행한다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`와 매칭 profile `node-smoke.md`, `platform-common-smoke.md`, `testing-smoke.md`를 읽었다. 구조적 공백과 `<확인 필요>` 값은 없다.
- 적용 명령: host Go identity 확인, fresh 대상/aggregate Go test, race test, `scripts/dev/edge-node-reconnect-diagnostic.sh`, 보조 mock smoke, symbol search와 `git diff --check`.
- profile의 정적 repo root `/config/workspace/iop`와 실제 checkout `/config/workspace/iop-s0`가 다르므로 root assertion은 실제 checkout을 사용한다. 이는 명령 실행을 막지 않으며 이번 bug fix의 test-rule 수정 범위는 아니다.
- 테스트 환경 프리플라이트: local runner, repo `/config/workspace/iop-s0`, branch `dev`, 기준 HEAD `432284820e36a7a3c6b35caaa8e4b9f903145b86`, task 구현으로 dirty 상태다. Go는 `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, GOROOT `/config/opt/go`다. Node binary는 `scripts/dev/node.sh`가 현재 checkout에서 `build/dev/iop-node`로 재빌드하고 Edge는 `go run`을 사용한다. 진단은 임시 loopback port/config와 `test-node` identity를 만들고 종료 시 정리하며 외부 host, model endpoint, credential은 요구하지 않는다.
- Go test cache는 허용하지 않으며 모두 `-count=1`을 사용한다.

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

- `TerminalEmitter`의 기존 test는 순차 terminal 중복만 검증한다. 먼저 시작한 sink call이 막힌 동안 뒤 terminal이 추월하지 않는 deterministic concurrent regression test가 필요하다.
- Node의 기존 `TestTerminalDeferringSinkFlushesTerminalEvents`는 terminal 뒤 late delta를 기대해 계약 위반을 고정한다. late event suppression으로 기대를 바꾸고, concurrent non-terminal/terminal/flush 전달 순서 test를 추가한다.
- 기존 fresh package/race/full-cycle evidence는 정상 경로를 덮지만 위 두 edge case를 검증하지 않는다.

### 심볼 참조

- public symbol rename/remove 없음. `TerminalEmitter.Emit`, `terminalDeferringSink.Emit`, `terminalDeferringSink.Flush`의 내부 동기화만 바꾼다.

### 분할 판단

- 단일 계획이다. common emitter와 Node deferring sink는 “accepted non-terminal events precede exactly one terminal; terminal 뒤에는 아무 event도 노출하지 않는다”는 하나의 불가분 ordering 불변조건을 공동으로 구현하므로 분리 PASS가 의미 없다.

### 범위 결정 근거

- provider 실행/stream parser, failure codec, config/protobuf schema, registry, Edge 구현, 외부 CLI profile은 변경하지 않는다.
- 이미 reviewer가 정리한 project rule/readability 경로와 task 밖 readability ratchet 위반을 다시 수정하지 않는다.
- 실제 외부 CLI profile 호출은 사용자가 요구한 profile 검증이 아니며 deterministic local two-process 진단으로 S04 bridge evidence를 닫는다.

### 최종 라우팅

- `evaluation_mode=isolated-reassessment`, `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`
- build closures: scope/context/verification/evidence/ownership/decision 모두 `true`; capability gap 없음
- build scores: scope 2, state/concurrency 2, blast 1, evidence/diagnosis 1, verification 2 → G08
- build: `base_route_basis=local-fit`, `route_basis=local-fit`, `lane=local`, `filename=PLAN-local-G08.md`
- review closures: scope/context/verification/evidence/ownership/decision 모두 `true`; capability gap 없음
- review scores: scope 2, state/concurrency 2, blast 1, evidence/diagnosis 1, verification 2 → G08
- review: `route_basis=official-review`, `lane=cloud`, adapter `codex`, model `gpt-5.6-sol`, reasoning `xhigh`, `filename=CODE_REVIEW-cloud-G08.md`
- `large_indivisible_context=false`
- positive loop risks: `temporal_state`, `concurrent_consistency`, `boundary_contract`; count 3, risk boundary false
- recovery: `review_rework_count=1`, `evidence_integrity_failure=false`, recovery boundary false

## 구현 체크리스트

- [ ] REVIEW_API-1 common emitter와 Node deferring sink가 concurrent accepted order, exactly-one terminal, post-terminal suppression을 함께 보장하도록 수정하고 deterministic regression tests를 추가한다.
- [ ] REVIEW_API-2 fresh/race Go suites, duplicate search, mock smoke와 실제 Edge-Node two-process 진단을 재실행해 contract와 S04 evidence를 채운다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

### [REVIEW_API-1] Terminal accepted order와 post-terminal suppression

**문제**

`packages/go/agentruntime/emitter.go:34-44`는 terminal 상태만 mutex로 보호하고 실제 sink call 전에 unlock한다.

```go
func (e *TerminalEmitter) Emit(ctx context.Context, event RuntimeEvent) error {
	e.mu.Lock()
	if e.terminal {
		e.mu.Unlock()
		return nil
	}
	if IsTerminalEvent(event.Type) {
		e.terminal = true
	}
	e.mu.Unlock()
	return e.sink.Emit(ctx, event)
}
```

`apps/node/internal/node/runtime_sink.go:38-54`는 terminal 뒤 duplicate terminal만 버리고 late delta는 deferred queue에 추가한다. non-terminal inner call과 terminal flush도 서로 직렬화되지 않는다.

```go
if s.terminalObserved && runtime.IsTerminalEvent(event.Type) {
	s.mu.Unlock()
	return nil
}
// ...
if s.deferring || runtime.IsTerminalEvent(event.Type) {
	s.deferring = true
	s.deferred = append(s.deferred, event)
```

**해결 방법**

공통 emitter에는 sink delivery 전용 mutex를 추가해 state 검사·갱신부터 wrapped sink 반환까지 한 event씩 직렬화한다. terminal state mutex는 `TerminalObserved` 조회와 분리해 downstream call 중 상태 조회가 교착되지 않게 한다.

```go
e.emitMu.Lock()
defer e.emitMu.Unlock()

e.mu.Lock()
if e.terminal {
	e.mu.Unlock()
	return nil
}
if IsTerminalEvent(event.Type) {
	e.terminal = true
}
e.mu.Unlock()
return e.sink.Emit(ctx, event)
```

Node sink에도 `Emit`/`Flush` delivery 순서를 함께 보호하는 mutex를 두고, `terminalObserved`이면 event type과 무관하게 즉시 버린다. deferred 복사와 실제 flush가 새 `Emit`에 추월되지 않게 한다.

```go
s.emitMu.Lock()
defer s.emitMu.Unlock()

s.mu.Lock()
if s.terminalObserved {
	s.mu.Unlock()
	return nil
}
```

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

- [ ] `packages/go/agentruntime/emitter.go`: state mutex와 sink delivery serialization 책임을 분리하고 accepted order를 보존한다.
- [ ] `packages/go/agentruntime/emitter_test.go`: channel-controlled blocking sink로 concurrent delta가 terminal에 추월되지 않음을 검증한다.
- [ ] `apps/node/internal/node/runtime_sink.go`: `Emit`/`Flush`를 직렬화하고 terminal 뒤 모든 event를 억제한다.
- [ ] `apps/node/internal/node/sink_test.go`: late delta 기대를 suppression으로 바꾸고 concurrent flush ordering을 검증한다.

**테스트 작성**

- `packages/go/agentruntime/emitter_test.go`에 `TestTerminalEmitterPreservesConcurrentAcceptedOrder`를 추가한다. 첫 delta sink call 진입을 channel로 확인한 뒤 block하고 complete를 시작해, delta release 전 complete가 sink에 도착하지 않으며 최종 순서가 delta→complete인지 assertion한다.
- `apps/node/internal/node/sink_test.go`의 late-delta test를 `TestTerminalDeferringSinkDropsPostTerminalEvents` 의미로 수정해 flush 결과가 start→complete 두 건뿐인지 검증한다.
- 같은 파일에 `TestTerminalDeferringSinkPreservesConcurrentDeliveryOrder`를 추가해 먼저 시작한 non-terminal inner call이 막힌 동안 terminal/flush가 추월하지 않음을 channel로 검증한다. sleep은 사용하지 않고 timeout은 deadlock 실패 guard로만 둔다.

**중간 검증**

```bash
/config/.local/bin/go test -race -count=1 ./packages/go/agentruntime ./apps/node/internal/node -run 'TestTerminalEmitterPreservesConcurrentAcceptedOrder|TestTerminalDeferringSink(DropsPostTerminalEvents|PreservesConcurrentDeliveryOrder)'
```

예상: 두 package의 지정 regression tests가 모두 PASS하고 race report가 없다.

### [REVIEW_API-2] Contract 및 S04 회귀 evidence 재검증

**문제**

정상 경로 package/race/full-cycle은 PASS했지만 concurrency bug fix 뒤 common/Node 소비자 회귀와 실제 terminal relay 순서를 다시 확인해야 한다.

**해결 방법**

현재 checkout의 host Go를 고정해 fresh 대상·aggregate·race suites를 실행한다. 그 뒤 보조 mock smoke와 실제 `scripts/dev/edge.sh`/`scripts/dev/node.sh`를 분리 실행하는 reconnect diagnostic을 각각 기록하고, terminal이 마지막 payload 뒤 정확히 한 번 도착하는지 확인한다.

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

- [ ] production/test diff에 debug print, unrelated API/schema 변경, stale Node-owned runtime import가 없는지 확인한다.
- [ ] `CODE_REVIEW-cloud-G08.md`: 모든 명령의 실제 stdout/stderr와 full-cycle 검증 결과를 채운다.

**테스트 작성**

- 추가 test 파일은 만들지 않는다. REVIEW_API-1의 regression tests와 기존 package/integration/full-cycle suites가 이 항목의 판정 oracle이다.

**중간 검증**

```bash
test "$(git rev-parse --show-toplevel)" = "/config/workspace/iop-s0"
command -v go
readlink -f "$(command -v go)"
go version
go env GOROOT
/config/.local/bin/go test -count=1 ./packages/go/agentruntime/... ./packages/go/agentprovider/cli/...
/config/.local/bin/go test -count=1 ./apps/node/internal/...
/config/.local/bin/go test -race -count=1 ./packages/go/agentruntime/... ./packages/go/agentprovider/cli/... ./apps/node/internal/adapters/... ./apps/node/internal/router/...
```

예상: root/Go identity가 preflight와 일치하고 모든 fresh/race package test가 PASS한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `packages/go/agentruntime/emitter.go` | REVIEW_API-1 |
| `packages/go/agentruntime/emitter_test.go` | REVIEW_API-1 |
| `apps/node/internal/node/runtime_sink.go` | REVIEW_API-1 |
| `apps/node/internal/node/sink_test.go` | REVIEW_API-1 |
| `CODE_REVIEW-cloud-G08.md` | REVIEW_API-2 |

## 최종 검증

```bash
test "$(git rev-parse --show-toplevel)" = "/config/workspace/iop-s0"
/config/.local/bin/go test -count=1 ./packages/go/agentruntime/... ./packages/go/agentprovider/cli/...
/config/.local/bin/go test -count=1 ./apps/node/internal/...
/config/.local/bin/go test -race -count=1 ./packages/go/agentruntime/... ./packages/go/agentprovider/cli/... ./apps/node/internal/adapters/... ./apps/node/internal/router/...
/config/.local/bin/go test -count=1 ./packages/go/... ./apps/node/...
IOP_E2E_BIND_TIMEOUT=60 IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh
IOP_DEV_RECONNECT_BIND_TIMEOUT=60 ./scripts/dev/edge-node-reconnect-diagnostic.sh
rg --sort path -n 'type (Adapter|RuntimeEvent|ExecutionSpec|RunRequest|Capabilities|EventSink|Registry) ' packages/go apps/node/internal
rg --sort path -n 'apps/node/internal/(runtime|adapters/cli|terminal)' packages/go || true
gofmt -l packages/go/agentruntime apps/node/internal/node/runtime_sink.go apps/node/internal/node/sink_test.go
git diff --check
```

예상: fresh/race/aggregate Go tests, mock smoke와 two-process diagnostic이 PASS한다. duplicate search에는 삭제된 Node-owned runtime/CLI/terminal import가 없고 `gofmt -l` 및 `git diff --check` 출력이 없다.

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