<!-- task=m-openai-workspace-agent-execution-contract/05_failure_surface plan=0 tag=FAILURE_SURFACE -->

# Plan - FAILURE_SURFACE

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

`CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 필수 마지막 단계다. 검증을 실행하고 실제 출력과 구현 메모를 기록한 뒤 active 파일을 그대로 두고 리뷰 준비 상태로 보고한다. 사용자 결정, 사용자 소유 외부 환경, 또는 범위 충돌 없이는 진행할 수 없을 때만 review stub의 `사용자 리뷰 요청` 섹션을 증거와 함께 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.

## 배경

`nomad-http-smoke`는 HTTP로 받은 workspace-bound agent 작업이 조용히 기본 cwd에서 실행되지 않는 것을 요구한다. Edge는 missing/relative workspace를 이미 400으로 거부하지만, Node CLI adapter의 실제 process launch는 존재하지 않는 경로, 파일 경로, 권한 오류를 launch 시점의 일반 OS 오류로만 노출한다. 실패 표면을 명시하면 다음 authoring smoke가 실패 원인을 구분할 수 있다.

## 사용자 리뷰 요청 흐름

구현 중 blocker는 active `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 구현 에이전트는 직접 사용자 프롬프트를 만들지 않고, code-review가 사용자 리뷰 필요성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Task ids:
  - `failure-surface`: workspace 누락, 존재하지 않는 경로, 권한 오류, agent process exit failure가 호출자에게 구분 가능한 실패로 드러난다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/update-roadmap/SKILL.md`
- `agent-ops/skills/common/agent-contract/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/testing-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-ops/rules/project/domain/edge/rules.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-ops/skills/project/e2e-smoke/SKILL.md`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/node/internal/adapters/cli/cli.go`
- `apps/node/internal/adapters/cli/oneshot.go`
- `apps/node/internal/adapters/cli/persistent.go`
- `apps/node/internal/adapters/cli/codex_app_server.go`
- `apps/node/internal/adapters/cli/opencode_sse.go`
- `apps/node/internal/adapters/cli/cli_internal_test.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/service_test.go`
- `configs/edge.yaml`

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md`를 읽었다. local quick check는 현재 checkout에서 실행하고 Docker-in-Docker는 쓰지 않는다.
- matched profiles: `agent-test/local/edge-smoke.md`, `agent-test/local/node-smoke.md`, `agent-test/local/testing-smoke.md`, `agent-test/local/platform-common-smoke.md`.
- 적용 명령: node 변경은 `go test ./apps/node/...`, edge OpenAI 경계 변경은 `go test ./apps/edge/...`와 OpenAI-compatible smoke 동등 확인, 실행 파이프라인 변경은 full-cycle 실제 구동 기준을 요구한다.
- 이 plan은 실패 preflight와 unit regression을 만든다. 실제 HTTP workspace authoring full-cycle은 sibling `06+05_authoring_http_smoke`에서 수행한다.
- `<확인 필요>` 값은 없었다. Go test cache가 실패를 숨기지 않도록 최종 명령은 `-count=1`을 쓴다.

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

- missing/relative workspace의 HTTP 400은 `apps/edge/internal/openai/server_test.go:1184-1212`, `1245-1273`에서 이미 커버된다.
- absolute workspace 전달은 `apps/edge/internal/openai/server_test.go:1214-1229`, `1275-1290`에서 커버된다.
- CLI oneshot/persistent cwd 적용은 `apps/node/internal/adapters/cli/cli_internal_test.go:794-929`에서 커버된다.
- 존재하지 않는 workspace, workspace가 파일인 경우, permission denied preflight는 현재 직접 regression test가 없다. 이 plan에서 추가한다.
- agent process exit failure는 `executeCommand`의 기존 `cmd.Wait` error 경로를 유지하되 workspace preflight error와 문구가 섞이지 않는지 테스트로 구분한다.

### 심볼 참조

- renamed/removed symbols: none.
- `workspaceDir`는 제거하지 않고 session key normalization 용도로 유지한다.
- 새 helper를 만들면 call site는 `apps/node/internal/adapters/cli/oneshot.go:26`, `persistent.go:357`, `persistent.go:380`, `codex_app_server.go:395`, `opencode_sse.go:290`다.

### 분할 판단

- split decision policy를 plan 파일 선택 전에 평가했다.
- shared task group: `m-openai-workspace-agent-execution-contract`.
- sibling subtasks:
  - `05_failure_surface`: 독립. Node CLI process launch failure 표면을 정리한다.
  - `06+05_authoring_http_smoke`: `05_failure_surface`의 `complete.log` 이후 실행한다. 실제 HTTP smoke는 실패 표면 정리 뒤에 안정적으로 판정한다.
  - `07_nomad_metadata_shape`: 독립. sibling repo `../nomadcode`의 호출 shape 정렬이다.
- 이 subtask는 predecessor가 없다. archive에는 같은 task group의 `01`~`04` 완료가 있으므로 새 번호는 `05`부터 이어간다.

### 범위 결정 근거

- Edge HTTP missing/relative workspace validation은 이미 동작하므로 메시지나 status를 바꾸지 않는다.
- OpenAI route catalog, service proto, session isolation은 이미 완료된 milestone task이므로 수정하지 않는다.
- NomadCode metadata shape와 실제 HTTP authoring smoke는 각각 `07_nomad_metadata_shape`, `06+05_authoring_http_smoke`로 분리한다.
- external CLI 설치나 provider 계정이 필요한 검증은 이 plan 범위가 아니다.

### 빌드 등급

- `local-G06`: Node CLI process launch에 닿지만 변경 파일과 call site가 작고, deterministic Go regression으로 검증 가능하다.

## 구현 체크리스트

- [ ] [FAILURE_SURFACE-1] Node CLI adapter의 workspace preflight를 추가하고 모든 CLI launch path가 같은 오류 분류를 사용하게 한다.
- [ ] [FAILURE_SURFACE-2] workspace not found, not directory, inaccessible, process exit failure regression test를 추가하고 기존 cwd/session tests를 유지한다.
- [ ] `go test -count=1 ./apps/node/internal/adapters/cli ./apps/edge/internal/openai`와 `go test -count=1 ./apps/node/... ./apps/edge/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

이 subtask는 runtime predecessor가 없다. 구현 순서는 helper 추가, call site 적용, regression test, 전체 검증 순서다.

### [FAILURE_SURFACE-1] Workspace Preflight

#### 문제

`apps/node/internal/adapters/cli/oneshot.go:24-28`와 persistent/server launch path는 workspace 문자열을 trim한 뒤 `cmd.Dir`에 넣는다. 존재하지 않는 경로와 파일 경로는 `cmd.Start` 또는 terminal start의 일반 오류로 드러나며, 어떤 launch path에서는 메시지가 서로 다르다.

```go
// apps/node/internal/adapters/cli/oneshot.go:24-28
func (c *CLI) executeCommand(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, args []string, prompt string, sink runtime.EventSink) (string, error) {
	cmd := exec.CommandContext(ctx, profile.Command, args...)
	if dir := workspaceDir(spec.Workspace); dir != "" {
		cmd.Dir = dir
	}
```

```go
// apps/node/internal/adapters/cli/persistent.go:350-381
if profile.Terminal {
	opts := terminal.Options{
		Command: profile.Command,
		Args:    profile.Args,
		Env:     profile.Env,
		Rows:    terminalRows,
		Cols:    terminalCols,
		Dir:     workspaceDir(workspace),
	}
	// ...
} else {
	cmd = exec.Command(profile.Command, profile.Args...)
	// ...
	if dir := workspaceDir(workspace); dir != "" {
		cmd.Dir = dir
	}
```

#### 해결 방법

`apps/node/internal/adapters/cli/cli.go`에 `prepareWorkspaceDir` helper를 추가한다. blank workspace는 기존 no-workspace 동작을 위해 `""`를 반환한다. non-blank workspace는 `os.Stat`과 directory check를 거쳐 다음 문자열을 포함하는 error로 분류한다.

```go
// apps/node/internal/adapters/cli/cli.go:493-495
func workspaceDir(w string) string {
	return strings.TrimSpace(w)
}
```

```go
// after
func prepareWorkspaceDir(w string) (string, error) {
	dir := workspaceDir(w)
	if dir == "" {
		return "", nil
	}
	info, err := os.Stat(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return "", fmt.Errorf("cli adapter: workspace not found: %s", dir)
		}
		return "", fmt.Errorf("cli adapter: workspace inaccessible: %s: %w", dir, err)
	}
	if !info.IsDir() {
		return "", fmt.Errorf("cli adapter: workspace is not a directory: %s", dir)
	}
	if f, err := os.Open(dir); err != nil {
		return "", fmt.Errorf("cli adapter: workspace inaccessible: %s: %w", dir, err)
	} else {
		_ = f.Close()
	}
	return dir, nil
}
```

각 launch path는 `cmd.Dir` 또는 `terminal.Options.Dir` 설정 전에 helper error를 즉시 반환한다. `workspaceDir`는 `newSessionKey`에서 계속 사용한다.

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

- [ ] `apps/node/internal/adapters/cli/cli.go`: helper 추가, 필요한 import 확인.
- [ ] `apps/node/internal/adapters/cli/oneshot.go`: `executeCommand`에서 helper 사용.
- [ ] `apps/node/internal/adapters/cli/persistent.go`: terminal/non-terminal path에서 helper 사용.
- [ ] `apps/node/internal/adapters/cli/codex_app_server.go`: app-server process launch에서 helper 사용.
- [ ] `apps/node/internal/adapters/cli/opencode_sse.go`: opencode server launch에서 helper 사용.

#### 테스트 작성

테스트 작성: yes. `apps/node/internal/adapters/cli/cli_internal_test.go`에 helper unit test를 추가한다. not found, file path, blank workspace를 검증한다. permission denied는 root/CI 권한에 따라 불안정할 수 있으므로 `os.Geteuid() == 0`이면 skip하는 조건부 test로만 둔다.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/adapters/cli
```

기대 결과: workspace preflight regression과 기존 cwd/session tests가 모두 통과한다.

### [FAILURE_SURFACE-2] Failure Propagation Tests

#### 문제

Edge는 `apps/edge/internal/openai/responses_handler.go:92-105`에서 submit/run error를 OpenAI-compatible error로 반환하지만, Node CLI adapter의 workspace 오류가 분류되지 않으면 호출자는 잘못된 workspace와 agent process failure를 안정적으로 구분하기 어렵다.

```go
// apps/edge/internal/openai/responses_handler.go:92-105
if err != nil {
	writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())
	return
}
defer handle.Close()

s.completeResponse(w, r, req, handle, outputPolicy)
```

#### 해결 방법

`executeCommand` 기반 tests에서 workspace preflight error와 process exit error를 각각 만든다. process exit은 존재하는 workspace에서 `sh -lc 'exit 7'` 또는 동등한 profile을 실행해 `cli adapter: command ... exit` 계열 error가 workspace preflight와 다른지 확인한다. Edge HTTP tests는 기존 missing/relative coverage를 유지하고, 추가 변경이 없으면 새 Edge test는 만들지 않는다.

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

- [ ] `apps/node/internal/adapters/cli/cli_internal_test.go`: `TestCLIWorkspacePreflightFailures` 추가.
- [ ] `apps/node/internal/adapters/cli/cli_internal_test.go`: `TestCLIProcessExitFailureRemainsDistinct` 추가 또는 기존 command failure test 확장.
- [ ] `apps/edge/internal/openai/server_test.go`: Edge error behavior를 바꾸지 않았다면 수정하지 않는다.

#### 테스트 작성

테스트 작성: yes. 실패 표면 task는 bug fix 성격이므로 regression test가 필수다.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/adapters/cli ./apps/edge/internal/openai
```

기대 결과: Node CLI adapter와 Edge OpenAI boundary tests가 모두 통과한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/cli/cli.go` | FAILURE_SURFACE-1 |
| `apps/node/internal/adapters/cli/oneshot.go` | FAILURE_SURFACE-1 |
| `apps/node/internal/adapters/cli/persistent.go` | FAILURE_SURFACE-1 |
| `apps/node/internal/adapters/cli/codex_app_server.go` | FAILURE_SURFACE-1 |
| `apps/node/internal/adapters/cli/opencode_sse.go` | FAILURE_SURFACE-1 |
| `apps/node/internal/adapters/cli/cli_internal_test.go` | FAILURE_SURFACE-1, FAILURE_SURFACE-2 |
| `apps/edge/internal/openai/server_test.go` | FAILURE_SURFACE-2 only if Edge behavior changes |

## 최종 검증

```bash
go test -count=1 ./apps/node/internal/adapters/cli ./apps/edge/internal/openai
go test -count=1 ./apps/node/... ./apps/edge/...
git diff --check
```

기대 결과: 모든 명령이 0 exit. Go test cache output은 허용하지 않는다. `git diff --check` output은 없어야 한다.

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