<!-- task=m-provider-resource-admission-ownership-alignment/15+14_controlplane_connected_mapping plan=1 tag=REVIEW_REFACTOR -->

# CLI adapter cancellation·terminal 회귀 안정화

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

구현 에이전트는 plan/review 모델과 다른 모델이어야 한다. 코드·테스트 변경과 아래 검증, review stub의 구현 에이전트 소유 섹션만 채운 뒤 `review-ready`를 보고하고 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

## 배경

Control Plane connected mapping 자체는 검증됐지만, 필수 전체 Go 회귀가 Node CLI adapter의 취소·persistent terminal 테스트 실패로 FAIL됐다. 한-shot 취소의 stdout-read 경로와 PTY startup/prompt-replay 경로의 경쟁을 안정화해, 실제 취소 의미와 terminal 입력·출력 계약을 보존한 채 전체 회귀를 다시 통과시킨다.

## 사용자 리뷰 요청 흐름

선택 Milestone의 구현 잠금 결정만 review stub의 `사용자 리뷰 요청`에 기록한다. 구현 중 직접 사용자에게 묻지 않으며, code-review만 실제 `USER_REVIEW.md`를 작성할 수 있다.

## Archive Evidence Snapshot

- 이전 pair: `agent-task/m-provider-resource-admission-ownership-alignment/15+14_controlplane_connected_mapping/plan_local_G06_0.log`, `agent-task/m-provider-resource-admission-ownership-alignment/15+14_controlplane_connected_mapping/code_review_local_G06_0.log`
- 이전 판정: FAIL. connector connected mapping, config redaction, 대상/race/Edge test와 Control Plane-Edge wire smoke는 PASS였다.
- Required: `go test -count=1 ./...`가 `apps/node/internal/adapters/cli`의 user-cancel, carriage-return terminal, Claude session-limit, startup-ready replay, terminal-chrome 테스트 실패로 종료했다. 단독 package 재실행에서도 timeout/terminal cancellation 실패가 재현됐다.
- 이번 범위: CLI adapter 취소와 terminal session/test fixture만 수정한다. 이전 `apps/edge/internal/controlplane/connector.go` 및 `connector_test.go` 변경은 수정하지 않는다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md)
- Task ids:
  - `offline-snapshot`: configured offline Node/provider와 reconnect snapshot 수렴
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-task/m-provider-resource-admission-ownership-alignment/15+14_controlplane_connected_mapping/plan_local_G06_0.log`
- `agent-task/m-provider-resource-admission-ownership-alignment/15+14_controlplane_connected_mapping/code_review_local_G06_0.log`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/operational-observability-provider-management/PHASE.md`
- `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-resource-admission-ownership-alignment.md`
- `agent-roadmap/sdd/operational-observability-provider-management/provider-resource-admission-ownership-alignment/SDD.md`
- `apps/node/internal/adapters/cli/oneshot.go`
- `apps/node/internal/adapters/cli/persistent.go`
- `apps/node/internal/adapters/cli/persistent_process.go`
- `apps/node/internal/adapters/cli/persistent_output_filter_claude.go`
- `apps/node/internal/adapters/cli/persistent_output_filter_terminal.go`
- `apps/node/internal/adapters/cli/oneshot_blackbox_test.go` (targeted: user cancel)
- `apps/node/internal/adapters/cli/persistent_terminal_test.go` (targeted: carriage return, Claude limit/replay/chrome)
- `apps/node/internal/adapters/cli/persistent_test_support_test.go`
- `scripts/dev/edge.sh`, `scripts/dev/node.sh`, `Makefile`

### SDD 기준

- SDD: [승인됨], 잠금 해제. `S14`/`offline-snapshot`의 Control Plane snapshot·admission 연결 요구와 Evidence Map의 deterministic/race 최종 evidence를 유지한다.
- 이번 수정은 S14 mapping 구현을 바꾸지 않는다. 이전 wire evidence를 보존하고, Required 전체 회귀를 종료 코드 0으로 만들어 S14 closure의 최종 검증 evidence를 복구한다.

### 테스트 환경 규칙

- `test_env`: local. 읽은 기준은 `agent-test/local/rules.md`, `agent-test/local/node-smoke.md`, `agent-test/local/testing-smoke.md`, Node/testing domain rule이다.
- Go cache는 `go env GOCACHE` 기본값을 그대로 사용한다. 명령은 repo root에서 실행하고, fresh 결과가 필요한 테스트는 `-count=1`을 사용한다.
- `scripts/dev/edge.sh`/`scripts/dev/node.sh` full-cycle은 임시 config와 mock CLI profile로 별도 프로세스 실행이 필요하다. 사용 중 포트·외부 CLI profile·Docker 등 준비 실패는 PASS로 바꾸지 않고 blocker로 기록한다.

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

- one-shot context cancellation은 user-cancel 이벤트를 검사하지만 stdout reader가 context 취소보다 먼저 닫힐 때의 반환 경로가 직접 고정돼 있지 않다. 회귀 assertion을 보강한다.
- persistent terminal five 시나리오는 PTY helper로 입력/출력을 검사하지만 startup drain과 fixed sleep의 스케줄 의존성이 있다. ready, cancel, prompt replay, chrome filtering의 관찰 순서를 결정적으로 고정하는 fixture/assertion을 추가·조정한다.
- 실제 외부 CLI profile은 계정·설치 상태가 필요하므로 실행 가능 여부를 profile별로 분리 기록한다.

### 심볼 참조

- renamed/removed symbol: none.
- 수정 후보 호출점: `executeCommand`은 one-shot 실행 경로에서만 사용되고, persistent prompt write는 `persistentRun.setup` 및 `handleIdleTimeout`의 replay가 `writePrompt` 또는 terminal core `WritePrompt`로 수렴한다.

### 분할 판단

- 선택 후보: review FAIL Required인 `apps/node/internal/adapters/cli` 전체 회귀 복구.
- 기존 task path `15+14_controlplane_connected_mapping`의 `plan-ready` follow-up이므로 새 sibling을 만들지 않고 같은 경로에 replacement pair를 쓴다.
- write set은 `oneshot.go`, `persistent.go`/`persistent_process.go`와 인접 blackbox/terminal helper tests로 서로 취소·PTY lifecycle 상태를 공유한다. 독립 병렬 split은 안전하지 않으며 추가 분할하지 않는다.

### 범위 결정 근거

- `apps/edge/internal/controlplane/connector.go`와 `connector_test.go`는 이전 PASS mapping evidence를 보존하며 변경하지 않는다.
- proto, config schema, Edge admission, Control Plane wire 계약과 외부 CLI provider 기능은 이 Required의 원인이 아니므로 제외한다.
- 테스트를 무조건 skip하거나 timeout만 늘려 증상을 숨기지 않는다. cancellation 의미와 raw terminal carriage-return contract를 보존하는 최소 수정만 허용한다.

### 최종 라우팅

- evaluation_mode: `isolated-reassessment`.
- build closure: scope/context/verification/evidence/ownership/decision 모두 true. 단일 CLI adapter package의 context cancellation·PTY lifecycle을 함께 수정하고 deterministic 반복, race, Node/전체 Go 회귀와 full-cycle/blocker 기록으로 판정 가능하다.
- build scores: scope coupling 1, state concurrency 2, blast irreversibility 1, evidence diagnosis 2, verification complexity 2. `local`, `G08`, `PLAN-local-G08.md`.
- review closure: scope/context/verification/evidence/ownership/decision 모두 true. 실패 로그와 구현 diff·재실행 출력으로 Required 해소를 독립 판정할 수 있다.
- review scores: scope coupling 1, state concurrency 2, blast irreversibility 1, evidence diagnosis 2, verification complexity 2. `local`, `G08`, `CODE_REVIEW-local-G08.md`.

## 구현 체크리스트

- [ ] one-shot stdout-read/command-wait 경쟁에서 context cancellation이면 `ErrRunCancelled`와 `user-cancel`/`timeout` event를 한 번만 반환하도록 수정하고 회귀 테스트를 추가한다.
- [ ] persistent terminal startup, carriage-return, Claude session-limit cancellation, startup-ready replay, chrome filtering의 관찰 순서를 안정화하고 기존 사용자-visible 입력·출력 계약을 보존하는 테스트를 갱신한다.
- [ ] 반복 package/race, Node/전체 Go 회귀와 mock smoke를 실행하고, full-cycle 및 외부 CLI profile은 성공 또는 명시 blocker를 실제 출력과 함께 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

### [REVIEW_REFACTOR-1] One-shot cancellation 반환 경로 고정

문제: `apps/node/internal/adapters/cli/oneshot.go:63-78`은 stdout read 오류를 `cmd.Wait()` 이전에 일반 error로 반환한다. context cancel로 stdout pipe가 닫힌 경우 `apps/node/internal/adapters/cli/oneshot_blackbox_test.go:510-557`의 `ErrRunCancelled`/`user-cancel` 계약보다 read error가 우선될 수 있다.

해결 방법: read 오류 처리 전에 `ctx.Err()`를 판정해 cancellation event를 background context로 정확히 한 번 emit하고 `runtime.ErrRunCancelled`를 반환한다. 정상 read error는 기존 Error event를 유지한다.

Before (`apps/node/internal/adapters/cli/oneshot.go:67-78`):

```go
if readErr != nil {
	_ = <-stderrDone
	_ = cmd.Wait()
	_ = sink.Emit(ctx, runtime.RuntimeEvent{Type: runtime.EventTypeError})
	return combinedOutput(), fmt.Errorf("cli adapter: read stdout: %w", readErr)
}
```

After:

```go
if readErr != nil && ctx.Err() != nil {
	_ = <-stderrDone
	_ = cmd.Wait()
	_ = sink.Emit(context.Background(), runtime.RuntimeEvent{
		RunID: spec.RunID, Type: runtime.EventTypeCancelled,
		Message: cancelEventForContext(ctx.Err()), Timestamp: time.Now(),
	})
	return combinedOutput(), runtime.ErrRunCancelled
}
```

수정 파일 및 체크리스트:

- [ ] `apps/node/internal/adapters/cli/oneshot.go`: cancellation-first read-error handling을 추가하고 event 중복 여부를 확인한다.
- [ ] `apps/node/internal/adapters/cli/oneshot_blackbox_test.go`: cancelled return과 single cancelled event/message를 반복 검증한다.

테스트 작성: user-cancel test를 deterministic completion 신호 또는 bounded retry assertion으로 보강해 pipe-close 순서에서도 `ErrRunCancelled`와 `user-cancel` 하나를 확인한다.

### [REVIEW_REFACTOR-2] Persistent PTY lifecycle fixture 안정화

문제: `apps/node/internal/adapters/cli/persistent_process.go:46-48`의 startup drain과 `apps/node/internal/adapters/cli/persistent.go:101-108, 203-218`의 prompt/replay는 PTY output 도착 순서에 의존한다. `persistent_terminal_test.go:73-447`의 fixed short timeout/helper sleep은 carriage return, session-limit cancel, ready replay, chrome filter의 실패를 스케줄에 노출한다.

해결 방법: production terminal write는 `Terminal` profile의 character pacing과 `\r` terminator를 유지한다. 테스트 helper가 startup-ready, prompt accepted, response/cancel 화면을 명시적으로 handshake하도록 만들고, adapter 쪽은 cancellation/ready screen을 current tail로 판정하는 순서를 보존한다. 실제 product defect가 확인되면 그 defect를 고치되 timer 확대만으로 해결하지 않는다.

Before (`apps/node/internal/adapters/cli/persistent_test_support_test.go:161-178`):

```go
_ = readRawLine()
time.Sleep(150 * time.Millisecond)
_ = readRawLine()

fmt.Fprint(os.Stdout, "\r\n❯ ")
```

After:

```go
// helper emits a ready marker only after the startup acknowledgement is read,
// then accepts the replayed prompt before emitting the Claude input screen.
startupAck := readRawLine()
emitReadyForReplay(startupAck)
prompt := readRawLine()
emitReplayReply(prompt)
```

수정 파일 및 체크리스트:

- [ ] `apps/node/internal/adapters/cli/persistent.go` 및 필요 시 `persistent_process.go`: terminal prompt/cancel/idle sequencing의 실제 defect만 최소 수정한다.
- [ ] `apps/node/internal/adapters/cli/persistent_terminal_test.go`: five failing PTY scenarios의 event, delta, completion/cancel assertions을 deterministic handshake 기준으로 갱신한다.
- [ ] `apps/node/internal/adapters/cli/persistent_test_support_test.go`: subprocess helper의 fixed sleep을 ready handshake로 대체한다.
- [ ] `apps/node/internal/adapters/cli/persistent_output_filter*_test.go`: production filter를 바꾸면 session-limit/chrome parser unit cases를 함께 추가한다.

테스트 작성: PTY helper가 raw `\r`을 받은 뒤에만 reply하고, rate-limit 화면은 cancelled event와 session-limit message를, replay는 정확히 한 replay delta를, chrome은 assistant delta만 남기는지 확인한다. 실제 source filter가 바뀌지 않으면 filter unit test는 추가하지 않는다.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/node/internal/adapters/cli/oneshot.go` | REVIEW_REFACTOR-1 |
| `apps/node/internal/adapters/cli/oneshot_blackbox_test.go` | REVIEW_REFACTOR-1 |
| `apps/node/internal/adapters/cli/persistent.go` | REVIEW_REFACTOR-2 |
| `apps/node/internal/adapters/cli/persistent_process.go` | REVIEW_REFACTOR-2 (필요 시) |
| `apps/node/internal/adapters/cli/persistent_terminal_test.go` | REVIEW_REFACTOR-2 |
| `apps/node/internal/adapters/cli/persistent_test_support_test.go` | REVIEW_REFACTOR-2 |
| `apps/node/internal/adapters/cli/persistent_output_filter*_test.go` | REVIEW_REFACTOR-2 (filter source 변경 시) |

## 최종 검증

- `gofmt -d apps/node/internal/adapters/cli/oneshot.go apps/node/internal/adapters/cli/persistent.go apps/node/internal/adapters/cli/persistent_process.go apps/node/internal/adapters/cli/oneshot_blackbox_test.go apps/node/internal/adapters/cli/persistent_terminal_test.go apps/node/internal/adapters/cli/persistent_test_support_test.go` → 변경한 Go 파일에서 출력 없음.
- `go test -count=20 ./apps/node/internal/adapters/cli -run '^(TestCLIExecuteOneShot_UserCancelEmitsUserCancelMessage|TestCLIExecutePersistentTerminalSendsCarriageReturn|TestCLIExecutePersistentClaudeTUICancelsSessionLimitUpgradePrompt|TestCLIExecutePersistentClaudeTUIReplaysPromptAfterStartupReadyRace|TestCLIExecutePersistentClaudeTUIFiltersTerminalChrome)$'` → 20회 모두 PASS.
- `go test -race -count=1 ./apps/node/internal/adapters/cli` → PASS.
- `go test -count=1 ./apps/node/...` → PASS.
- `go test -count=1 ./...` → PASS, 실패 package 없음.
- `IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh` → 보조 smoke PASS 또는 port/process blocker의 실제 출력 기록.
- 임시 config로 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 별도 프로세스로 실행해 CLI profile의 foreground run, cancel, session/status/terminate를 실제 entrypoint에서 검증한다. 외부 CLI profile 실행 가능 여부와 결과를 profile별로 기록한다.

검증 결과를 review stub에 기록하고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.
