<!-- task=edge_console_foreground_dedupe plan=0 tag=ECF -->

# Edge Console Foreground Event Deduplication

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

**구현 마지막 단계에서 `CODE_REVIEW-cloud-G07.md`의 모든 섹션을 실제 내용으로 채워야 한다. 이 파일 작성 전까지 작업은 완료가 아니다.**
중간/최종 검증을 실행하고, 실행한 명령의 실제 stdout/stderr를 `CODE_REVIEW-cloud-G07.md`에 붙여 넣는다.
리뷰 파일의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 archive/complete.log 절차는 구현 에이전트가 실행하지 않는다.

## 배경

`e2e-smoke` 검증에서 foreground mock run이 정상 완료된 뒤, 같은 run의 `complete` event가 async all-runs 경로에서 뒤늦게 한 번 더 출력되었다.
그 결과 edge console에 `[node-*-message] <empty>`와 중복 `complete`가 섞여 사용자 출력이 지저분해진다.
foreground run은 `sendConsoleRun`이 동기적으로 출력하고, async router는 background/asynchronous run 출력만 담당해야 한다.

## 분석 결과

### 읽은 파일

- `agent-ops/skills/project/e2e-smoke/SKILL.md`
- `apps/edge/cmd/edge/console.go`
- `apps/edge/cmd/edge/console_events.go`
- `apps/edge/cmd/edge/console_test.go`
- `apps/edge/internal/events/bus.go`

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

- 기존 `TestConsoleEventRouterSuppressesTrackedForegroundRun`은 foreground run이 tracking 중일 때 async 출력이 억제되는지만 검증한다.
- tracking 해제 후 all-runs 채널에 남아 있던 foreground event가 뒤늦게 `Handle`로 들어오는 경우는 테스트하지 않는다.
- background async run 출력은 `TestConsoleEventRouterPrintsUnregisteredRun`이 검증하고 있으므로 유지되어야 한다.
- bin 기반 smoke에서 중복 `<empty>`/중복 `complete`가 없어야 한다는 조건은 아직 자동화된 고정 명령이 없다. 이번 구현 후 `e2e-smoke` mock 절차로 수동 검증한다.

### 심볼 참조

- renamed/removed symbol: none.
- `TrackForeground`: `apps/edge/cmd/edge/console.go:175`, `apps/edge/cmd/edge/console_events.go:36`, `apps/edge/cmd/edge/console_test.go:90`.
- `consoleEventRouter.Handle`: `apps/edge/cmd/edge/console_events.go:48`, `apps/edge/cmd/edge/console_events.go:75`, `apps/edge/cmd/edge/console_test.go:93`, `apps/edge/cmd/edge/console_test.go:104`, `apps/edge/cmd/edge/console_test.go:109`, `apps/edge/cmd/edge/console_test.go:110`, `apps/edge/cmd/edge/console_test.go:370`, `apps/edge/cmd/edge/console_test.go:387`.

### 범위 결정 근거

- `apps/edge/internal/events/bus.go`는 event fanout 자체가 정상 동작한다. foreground 전용 구독과 all-runs 구독 양쪽에 보내는 현재 구조는 여러 소비자 지원을 위한 것이므로 변경하지 않는다.
- `apps/node/**`는 `RunEvent.Background`를 이미 채워 보내고 있으므로 수정하지 않는다.
- real CLI profile 동작은 이번 버그의 원인이 아니므로 opt-in smoke만 남기고 코드 변경 범위에서 제외한다.
- 이번 수정은 edge console async 출력 라우팅과 그 테스트에 한정한다.

### 빌드 등급

- build=`cloud-G07`, review=`cloud-G07`.
- 이유: 코드 변경은 작지만 성공 조건이 bin-level smoke output, stdout 중복 억제, foreground/background event routing에 걸려 있는 terminal-agent 검증 작업이다.

## [ECF-1] Foreground Run Async 출력 차단

### 문제

`eventBus.PublishRun`은 `apps/edge/internal/events/bus.go:118`과 `apps/edge/internal/events/bus.go:121`에서 all-runs 구독자와 run-specific 구독자 모두에게 같은 event를 보낸다.
foreground run은 `apps/edge/cmd/edge/console.go:175`에서 `TrackForeground`로 suppress 등록되고, `apps/edge/cmd/edge/console.go:176`의 defer로 `sendConsoleRun` 종료 시 해제된다.
all-runs 채널에 남아 있던 foreground `complete`가 해제 후 `apps/edge/cmd/edge/console_events.go:48`의 async `Handle`로 들어오면 `apps/edge/cmd/edge/console_events.go:148`의 `finishStream`이 빈 stream을 마감하며 `<empty>`를 출력한다.

Before:

```go
// apps/edge/cmd/edge/console_events.go:48
func (r *consoleEventRouter) Handle(event *iop.RunEvent) {
	runID := event.GetRunId()

	r.mu.Lock()
	if _, ok := r.foreground[runID]; ok {
		r.mu.Unlock()
		return
	}
	r.printAsyncLocked(event)
	r.mu.Unlock()
}
```

### 해결 방법

`consoleEventRouter.Handle`의 책임을 async/background 출력으로 명확히 한다.
`RunEvent.Background == false`인 event는 foreground 경로가 이미 처리하는 event이므로 tracking 상태와 무관하게 async 출력하지 않는다.
기존 `foreground` map은 방어적 suppress와 현재 테스트 호환을 위해 유지한다.

After:

```go
func (r *consoleEventRouter) Handle(event *iop.RunEvent) {
	if event == nil || !event.GetBackground() {
		return
	}

	runID := event.GetRunId()

	r.mu.Lock()
	if _, ok := r.foreground[runID]; ok {
		r.mu.Unlock()
		return
	}
	r.printAsyncLocked(event)
	r.mu.Unlock()
}
```

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

- [ ] `apps/edge/cmd/edge/console_events.go`: `Handle`에서 nil event와 non-background event를 early return한다.
- [ ] `apps/edge/cmd/edge/console_events.go`: background event 출력 경로는 변경하지 않는다.

### 테스트 작성

- 작성한다: `apps/edge/cmd/edge/console_test.go`.
- 테스트명: `TestConsoleEventRouterIgnoresLateForegroundAfterUntrack`.
- 목표: `TrackForeground` 해제 후 `Background=false`인 start/delta/complete event가 `Handle`로 들어와도 출력이 비어 있고 `<empty>`/중복 complete가 생기지 않음을 검증한다.
- 기존 `TestConsoleEventRouterPrintsUnregisteredRun`은 background event가 계속 출력되는 회귀 테스트로 유지한다.

### 중간 검증

```bash
go test -count=1 ./apps/edge/cmd/edge
```

기대 결과: `ok  	iop/apps/edge/cmd/edge`.

## [ECF-2] e2e-smoke 기준에 중복 출력 실패 조건 반영

### 문제

현재 `e2e-smoke` 스킬은 메시지 2회, event/message/complete 출력 확인을 요구하지만, foreground run의 중복 `<empty>` message와 중복 `complete`를 실패 조건으로 명시하지 않는다.
이번 이슈는 스킬 기반 smoke에서 발견되었으므로, 같은 검증 절차가 재발을 더 잘 잡아야 한다.

### 해결 방법

`agent-ops/skills/project/e2e-smoke/SKILL.md`의 기본 E2E smoke 확인 항목에 다음 조건을 추가한다.

```md
- foreground mock smoke에서는 `<empty>` message line이 없어야 하고, foreground complete 출력 수가 전송한 run 수를 초과하지 않는지 확인한다.
```

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

- [ ] `agent-ops/skills/project/e2e-smoke/SKILL.md`: 기본 E2E smoke 실행 항목에 중복 `<empty>`/중복 complete 금지 조건을 추가한다.

### 테스트 작성

- 별도 코드 테스트는 작성하지 않는다. 이 항목은 스킬 문서의 검증 체크리스트 보강이다.
- 실제 회귀 검증은 ECF-1의 unit test와 최종 `e2e-smoke` mock 절차에서 수행한다.

### 중간 검증

```bash
rg --sort path -n "<empty>|complete 출력 수|foreground mock smoke" agent-ops/skills/project/e2e-smoke/SKILL.md
```

기대 결과: foreground mock smoke의 `<empty>` 금지와 complete count 조건이 검색된다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/cmd/edge/console_events.go` | ECF-1 |
| `apps/edge/cmd/edge/console_test.go` | ECF-1 |
| `agent-ops/skills/project/e2e-smoke/SKILL.md` | ECF-2 |

## 의존 관계 및 구현 순서

1. ECF-1을 먼저 구현하고 unit regression test를 추가한다.
2. ECF-2로 smoke skill의 실패 조건을 보강한다.
3. Go 테스트 후 `e2e-smoke` mock 절차를 실행해 bin-level 출력에서 `<empty>`와 중복 complete가 사라졌는지 확인한다.

## 최종 검증

```bash
go test -count=1 ./apps/edge/cmd/edge
```

기대 결과: edge console unit tests 통과.

```bash
go test -count=1 ./...
```

기대 결과: 전체 Go test 통과. 캐시 출력은 허용하지 않는다.

```bash
rg --sort path -n "<empty>|foreground mock smoke|complete 출력 수" agent-ops/skills/project/e2e-smoke/SKILL.md
```

기대 결과: e2e-smoke skill에 foreground 중복 출력 실패 조건이 명시되어 있다.

`agent-ops/skills/project/e2e-smoke/SKILL.md`의 `mode=mock` 절차를 임시 config/포트로 실행한다.
기대 결과: node register, `/nodes`, mock message 2회 이상, event/message/complete 출력이 확인되고, edge console output에 `[node-*-message] <empty>`가 없으며 foreground run의 complete 출력 수가 전송 run 수를 초과하지 않는다.

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