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

# OpenCode prompt-async 취소 abort 회귀 수정

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

구현 모델은 Plan/Review 모델과 달라야 한다. 소스·테스트를 수정하고 review stub의 구현 소유 섹션을 채운 뒤 `review-ready`를 보고하고 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

## 역할 모델

- Plan: `GPT-5`
- Implementation/Review: review stub의 runtime ledger에서 시작 시 기록한다. 세 identity는 모두 달라야 한다.

## 배경

`prompt_async` POST가 OpenCode 서버에 수락된 뒤 응답이 지연된 상태에서 run context가 취소될 수 있다. 현재 in-flight POST 오류 분기는 취소 event와 `ErrRunCancelled`만 반환해 서버 session의 실행을 abort하지 않는다. SSE 본문을 읽는 취소 경로와 동일하게 background timeout context로 abort를 보장하고, 그 순서를 fixture로 결정적으로 검증한다.

## 사용자 리뷰 요청 흐름

선택 Milestone의 구현 잠금 결정만 active review stub의 `사용자 리뷰 요청` 섹션에 현재 workflow template 내용으로 기록한다. 구현 중 직접 사용자에게 묻지 않으며, code-review가 검증과 실제 `USER_REVIEW.md` 작성을 소유한다.

## Archive Evidence Snapshot

- 직전 pair: `agent-task/m-provider-resource-admission-ownership-alignment/15+14_controlplane_connected_mapping/plan_local_G08_1.log`, `agent-task/m-provider-resource-admission-ownership-alignment/15+14_controlplane_connected_mapping/code_review_local_G08_1.log`
- 판정: FAIL. Required는 `opencode_sse.go:168`의 prompt-async in-flight 취소가 수락된 session에 abort를 보내지 않는 것이다.
- 기존 evidence: 반복 CLI package, race, Node/전체 Go 회귀와 mock smoke는 PASS였으나 이 특정 수락-후-응답-block 취소 순서를 검증하지 않았다.
- 변경 대상: `apps/node/internal/adapters/cli/opencode_sse.go`, `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`만 수정한다. prior Control Plane mapping과 다른 CLI cancellation/terminal 파일은 수정하지 않는다.

## 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/NEXT_STATE.md`
- `agent-task/m-provider-resource-admission-ownership-alignment/15+14_controlplane_connected_mapping/plan_local_G08_1.log`
- `agent-task/m-provider-resource-admission-ownership-alignment/15+14_controlplane_connected_mapping/code_review_local_G08_1.log`
- `apps/node/internal/adapters/cli/opencode_sse.go` (targeted lines 105-191, 396-416, 497-609)
- `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go` (targeted lines 1-118, 721-781)
- `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`
- `agent-test/local/rules.md`, `agent-test/local/node-smoke.md`
- `agent-ops/rules/project/domain/node/rules.md`, `agent-ops/rules/project/domain/testing/rules.md`

### SDD 기준

SDD는 `[승인됨]`, 잠금은 해제다. S14/`offline-snapshot`은 disconnect/reconnect snapshot·admission generation의 prior evidence이며 Evidence Map의 S01–S15 Edge/connectivity deterministic tests와 final evidence를 요구한다. 이번 abort 회귀는 prior S14 mapping을 바꾸지 않지만 같은 closure task의 Node cancellation 안전성을 회복하므로 target을 유지한다. checklist와 최종 검증은 S14 mapping/wire를 수정하지 않는지와 Node adapter 취소 회귀를 함께 확인한다.

### 테스트 환경 규칙

`test_env=local`이다. 읽은 규칙은 `agent-test/local/rules.md`, `agent-test/local/node-smoke.md`, testing domain rule이다. fresh Go 실행에는 `-count=1`을 사용하며 cache 결과는 허용하지 않는다. external OpenCode profile과 entrypoint full-cycle은 local account/server 가용성이 전제이므로 실행 불가 시 command·blocker·남은 위험을 review stub에 기록한다.

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

- SSE drive 단계 context 취소는 기존 `TestCLIExecuteOpencodeSSE_ContextCancelAbortsSession`이 abort와 취소 반환을 다루지만 prompt response가 반환된 뒤의 경로다.
- POST가 서버에 수락된 뒤 응답을 block한 취소는 기존 테스트가 다루지 않는다. 새 regression test가 abort 1회, cancelled event 정확히 1회, `ErrRunCancelled`을 검증한다.

### 심볼 참조

변경·삭제 심볼 없음. `opencodeAbort` 호출 지점은 `opencodeSSERun.finalize`와 이번 prompt-async cancellation branch이며 `opencodePromptAsync` 호출 지점은 `opencodeExecutor.Execute` 하나다.

### 분할 판단

선택 candidate는 review FAIL follow-up 한 건이며 단일 sibling `15+14_controlplane_connected_mapping`에 유지한다. write set은 CLI OpenCode source와 black-box fixture/test이고 shared mutable state는 하나의 logical OpenCode session뿐이다. 병렬 sibling과 write/state 충돌이 없으며 predecessor `14`는 `agent-task/archive/2026/07/m-provider-resource-admission-ownership-alignment/14+10_offline_provider_tuple/complete.log`로 충족된다. abort lifecycle과 fixture ordering은 한 regression contract이므로 더 나누지 않는다.

### 범위 결정 근거

`apps/edge/**`, Control Plane mapping, one-shot/persistent cancellation, protocol/config/contract 문서는 public contract 변경이 없어 제외한다. existing SSE finalization의 동작은 재설계하지 않고 prompt POST cancellation branch만 그 abort lifecycle에 맞춘다.

### 최종 라우팅

`evaluation_mode=isolated-reassessment`이다. build/review 모두 scope, context, verification, evidence, ownership, decision closure가 true다: two-file bounded change, direct FAIL evidence, deterministic local fixture, no external decision, isolated session state가 근거다. build scores는 scope coupling 1, state concurrency 2, blast/irreversibility 1, evidence/diagnosis 1, verification complexity 1이며 `local`, `G06`, `PLAN-local-G06.md`다. review는 독립적으로 같은 다섯 현재-scope score와 closure를 얻어 `local`, `G06`, `CODE_REVIEW-local-G06.md`다.

## 구현 체크리스트

- [x] prompt-async request가 context cancellation으로 실패할 때 동일 session에 bounded background abort를 먼저 수행하고, cancelled event 하나와 `ErrRunCancelled`을 반환한다.
- [x] response-blocking fake-server fixture와 in-flight cancellation regression test를 작성해 abort 1회·cancelled event 정확히 1회·`ErrRunCancelled`을 검증한다.
- [x] 아래 fresh package/race/Node/full Go 검증과 가능한 mock smoke·full-cycle/external profile을 실행하고 실제 출력 또는 blocker를 review stub에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 채우고 review-ready를 보고한 뒤 현재 실행을 종료한다. code-review를 실행하거나 다음 에이전트를 시작하지 않는다.

### [REVIEW_REFACTOR-1] prompt-async 취소의 server abort 보장

문제: `apps/node/internal/adapters/cli/opencode_sse.go:168-180`은 POST가 취소되면 abort 없이 반환한다.

해결 방법: SSE `finalize`와 같은 2초 background timeout context를 만들고, session ID가 있을 때 `opencodeAbort`를 호출한 뒤 단일 cancelled event와 `runtime.ErrRunCancelled`을 반환한다.

Before (`opencode_sse.go:168-180`):

```go
if err := opencodePromptAsync(ctx, sess.serverURL, sess.sessionID, prompt, opts); err != nil {
	if ctx.Err() != nil {
		_ = sink.Emit(context.Background(), runtime.RuntimeEvent{
			RunID: spec.RunID, Type: runtime.EventTypeCancelled,
			Message: cancelEventForContext(ctx.Err()), Timestamp: time.Now(),
		})
		return runtime.ErrRunCancelled
	}
}
```

After:

```go
if ctx.Err() != nil {
	bg, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if sess.sessionID != "" {
		_ = opencodeAbort(bg, sess.serverURL, sess.sessionID)
	}
	_ = sink.Emit(context.Background(), runtime.RuntimeEvent{
		RunID: spec.RunID, Type: runtime.EventTypeCancelled,
		Message: cancelEventForContext(ctx.Err()), Timestamp: time.Now(),
	})
	return runtime.ErrRunCancelled
}
```

수정 파일 및 체크리스트:

- [x] `apps/node/internal/adapters/cli/opencode_sse.go`: in-flight prompt cancellation branch에 bounded abort를 넣고 non-cancel error event 경로를 보존한다.

테스트 작성: 아래 black-box regression test로 작성한다. source만 변경하고 테스트를 생략하지 않는다.

### [REVIEW_REFACTOR-2] 수락 뒤 응답-block 취소 fixture

문제: `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go:90-103` fake server는 prompt_async에 즉시 202를 반환하므로 Required ordering을 만들지 못한다.

해결 방법: fixture에 prompt 수락 신호와 response release gate를 추가한다. 새 `TestCLIExecuteOpencodeSSE_ContextCancelAfterPromptAcceptedAbortsSession`은 수락 신호 후 context를 취소하고, abort request 수신으로 POST client cancellation과 abort lifecycle을 분리해 관찰한다.

수정 파일 및 체크리스트:

- [x] `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`: fake server의 prompt response gate를 test-only opt-in으로 추가한다.
- [x] 같은 파일: `ErrRunCancelled`, `/abort` call count `1`, `EventTypeCancelled` count `1`, error event `0`을 bounded waits로 assert하는 regression test를 추가한다.

테스트 작성: 위 named test를 작성한다. fixture는 POST body를 받은 뒤 accepted signal을 보내고 response는 gate 또는 request cancellation까지 block해야 하며, sleep-only ordering을 사용하지 않는다.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/node/internal/adapters/cli/opencode_sse.go` | REVIEW_REFACTOR-1 |
| `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go` | REVIEW_REFACTOR-2 |

## 최종 검증

- `gofmt -d apps/node/internal/adapters/cli/opencode_sse.go apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go` → 출력 없음 (gofmt: clean).
- `go test -count=1 ./apps/node/internal/adapters/cli -run '^TestCLIExecuteOpencodeSSE_ContextCancelAfterPromptAcceptedAbortsSession$' -v` → PASS (0.03s). abort call count 1, cancelled event 1, error event 0 검증 통과.
- `go test -race -count=1 ./apps/node/internal/adapters/cli` → PASS (32.253s). race/deadlock/panic 없이 통과.
- `go test -count=1 ./apps/node/...` → PASS. 14 packages: cmd/node, internal/adapters, internal/adapters/cli, internal/adapters/cli/status, internal/adapters/ollama, internal/adapters/openai_compat, internal/adapters/vllm, internal/bootstrap, internal/node, internal/router, internal/store, internal/terminal, internal/transport. 전체 29.8-39.8s.
- `go test -count=1 ./...` → PASS. 42 packages 전체. 어떤 패키지에서도 실패/패닉/race 없음.
- `IOP_E2E_PROFILE=mock ./scripts/e2e-smoke.sh` → PASS. 실제 entrypoint full-cycle과 실제 opencode profile은 external server 미가용으로 실행 불가. preflight: mock profile 사용, blocker: scripts/dev/edge.sh + scripts/dev/node.sh 필요.

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