<!-- task=m-backtest-scenario-automation/02+01_batch_runner plan=0 tag=API -->

# Plan - API Batch Runner

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

이 plan의 구현이 끝나면 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. 구현 중 사용자 결정, 사용자 소유 외부 환경, 범위 충돌로 막히면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정 필요 내용과 증거를 남기고 멈춘다. 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 명령 재실행이나 산출물 수집으로 해소 가능한 evidence 공백은 사용자 리뷰 요청이 아니다.

## 배경

01 subtask가 matrix schema와 deterministic expansion을 제공하면, `operator scenario run`은 그 matrix를 실제 backtest run batch로 실행해야 한다. 현재 [runner.go](/config/workspace/alt/apps/cli/internal/operator/runner.go:129)는 사용자가 직접 작성한 `steps`만 순차 실행하고, 여러 generated run의 start/poll lifecycle을 요약하는 batch path가 없다.

## 사용자 리뷰 요청 흐름

구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 prompt는 금지이며, code-review가 해당 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-scenario-automation.md`
- Task ids:
  - `batch-runner`: 여러 backtest run을 순차 실행하고 각 run의 polling 결과를 stable text/JSONL로 출력한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/operations/rules.md`
- `agent-ops/rules/project/domain/api/rules.md`
- `agent-ops/rules/project/domain/worker/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/operations-smoke.md`
- `agent-test/local/api-smoke.md`
- `agent-test/local/worker-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/backtest-loop/PHASE.md`
- `agent-roadmap/phase/backtest-loop/milestones/backtest-scenario-automation.md`
- `apps/cli/go.mod`
- `apps/cli/internal/operator/scenario.go`
- `apps/cli/internal/operator/runner.go`
- `apps/cli/internal/operator/output.go`
- `apps/cli/internal/operator/client.go`
- `apps/cli/internal/operator/client_test.go`
- `apps/cli/internal/operator/runner_backtest_test.go`
- `apps/cli/internal/operator/runner_error_test.go`
- `apps/cli/internal/cli/cli.go`
- `apps/cli/internal/cli/cli_test.go`
- `apps/cli/testdata/operator/backtest_run_request.yaml`
- `apps/cli/testdata/operator/backtest_run_polling.yaml`
- `apps/cli/testdata/operator/backtest_result_summary.yaml`

### 테스트 환경 규칙

- 선택 env: `local`.
- 읽은 profile: `operations-smoke`, `api-smoke`, `worker-smoke`.
- 적용 명령: roadmap task 검증 명령 `go test -count=1 ./apps/cli/internal/operator ./services/api/internal/socket ./services/worker/internal/socket ./services/worker/internal/jobs`.
- CLI baseline으로 `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행했고 exit 0이었다.
- 이 plan은 API/worker 코드를 변경하지 않지만 boundary regression 확인을 위해 milestone 명령을 최종 검증에 둔다.

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

- generated matrix runs를 순차 start/poll하는 runner path: 기존 테스트 없음.
- batch JSONL/text output의 `matrix_run_id`, universe, strategy, timeframe, period field: 기존 테스트 없음.
- terminal `failed` run을 transport/app failure가 아닌 terminal lifecycle evidence로 출력하는 path: 기존 테스트 없음.
- 기존 single scenario steps, typed error, transport failure: 기존 tests가 커버한다.

### 심볼 참조

- renamed/removed symbol: none.
- `RunScenario` call sites: `apps/cli/internal/cli/cli.go`, `apps/cli/internal/operator/runner_error_test.go`, `runner_market_test.go`, `runner_live_test.go`, `runner_paper_test.go`.

### 분할 판단

- split policy를 먼저 평가했다.
- shared task group: `agent-task/m-backtest-scenario-automation/`.
- 현재 subtask: `02+01_batch_runner`.
- predecessor `01`: satisfied by archived `complete.log` at `agent-task/archive/2026/06/m-backtest-scenario-automation/01_scenario_matrix/complete.log`.

### 범위 결정 근거

- Protobuf schema, API socket handlers, worker execution/storage는 변경하지 않는다. Existing `StartBacktestRequest`, `GetBacktestRunRequest`, result query surface를 CLI에서 orchestration한다.
- `headless_validation.md`와 expected fixture 갱신은 03에서 수행한다.
- 새 dependency는 추가하지 않는다. `apps/cli/go.mod`에는 이미 YAML과 proto-socket dependencies가 있다.

### 빌드 등급

- build: `cloud-G07`, review: `cloud-G07`.
- 이유: CLI workflow, stdout/JSONL contract, polling/timeout/exit-code behavior가 중심이며 여러 test contexts를 함께 판단해야 한다.

## 구현 체크리스트

- [ ] 01 subtask completion을 확인한 뒤 matrix batch path를 `RunScenario`에 연결한다. 검증: `go test -count=1 ./apps/cli/internal/operator`
- [ ] 각 matrix run을 start 후 terminal status까지 poll하고 stable text/JSONL fields를 출력한다. 검증: `go test -count=1 ./apps/cli/internal/operator`
- [ ] batch success, terminal failed, timeout/transport, expectation mismatch behavior를 tests로 고정한다. 검증: `go test -count=1 ./apps/cli/internal/operator ./services/api/internal/socket ./services/worker/internal/socket ./services/worker/internal/jobs`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

- 이 directory 이름의 runtime dependency는 `01`이다.
- predecessor `01`은 `agent-task/archive/2026/06/m-backtest-scenario-automation/01_scenario_matrix/complete.log`로 충족됐다.

## [API-1] Connect Matrix To Runner

### 문제

[runner.go](/config/workspace/alt/apps/cli/internal/operator/runner.go:96)는 `RunScenario`에서 단일 `Scenario`를 열고 [runner.go](/config/workspace/alt/apps/cli/internal/operator/runner.go:129)의 `for _, step := range sc.Steps`만 실행한다. 01의 matrix expansion이 있어도 runner가 이를 사용하지 않으면 batch automation이 동작하지 않는다.

### 해결 방법

`RunScenario`에서 `runs, err := sc.MatrixRuns()`를 호출하고, `len(runs) > 0`이면 `runMatrixBatch` path를 실행한다. Matrix가 없는 기존 step scenario는 현 동작을 유지한다.

Before:

```go
// apps/cli/internal/operator/runner.go:126
passed := 0
exitCode := codeOK
savedValues := make(map[string]string)
for _, step := range sc.Steps {
```

After:

```go
runs, err := sc.MatrixRuns()
if err != nil {
	out.WriteStep(StepEvent{Scenario: sc.Name, Status: statusError, ErrorCode: "invalid_matrix", ErrorMessage: err.Error()})
	out.WriteSummary(RunSummary{Scenario: sc.Name, Status: summaryFailed, Steps: 0, Passed: 0, ExitCode: codeBadInput})
	return codeBadInput
}
if len(runs) > 0 {
	return runMatrixBatch(ctx, client, sc.Name, runs, timeout, out)
}
```

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

- [ ] `apps/cli/internal/operator/runner.go`: matrix path branch 추가.
- [ ] `apps/cli/internal/operator/runner_backtest_test.go`: matrix branch가 기존 step branch와 분리되는지 검증.

### 테스트 작성

- 작성: `TestRunBacktestMatrixRunsSequentially`로 two-run matrix가 두 번 `StartBacktest`와 polling을 호출하는지 검증한다.

### 중간 검증

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

예상: package `ok`.

## [API-2] Stable Batch Output

### 문제

[output.go](/config/workspace/alt/apps/cli/internal/operator/output.go:25)는 backtest output에 `run_id/run_status`는 있지만 generated matrix run identity, universe, strategy, timeframe, period를 담지 않는다. Batch output이 stable하지 않으면 후속 handoff가 조합별 결과를 소비할 수 없다.

### 해결 방법

`StepEvent`에 matrix fields를 추가하고 text/JSONL renderer에 조건부 출력한다.

Before:

```go
// apps/cli/internal/operator/output.go:34
// Backtest-specific output fields
RunID        string
RunStatus    string
StartingCash string
```

After:

```go
// Batch/matrix-specific output fields.
MatrixRunID string
Universe    string
StrategyID  string
Timeframe   string
PeriodID    string

// Backtest-specific output fields.
RunID        string
RunStatus    string
StartingCash string
```

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

- [ ] `apps/cli/internal/operator/output.go`: `StepEvent` fields 추가.
- [ ] `apps/cli/internal/operator/output.go`: text output keys `matrix_run_id`, `universe`, `strategy_id`, `timeframe`, `period_id` 추가.
- [ ] `apps/cli/internal/operator/output.go`: JSONL fields 추가.
- [ ] `apps/cli/internal/operator/runner_backtest_test.go`: text and JSONL output keys 검증.

### 테스트 작성

- 작성: matrix run test에서 text output key를 검증한다.
- 작성: JSONL mode test에서 `matrix_run_id`와 `period_id` key를 검증한다.

### 중간 검증

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

예상: package `ok`.

## [API-3] Polling And Exit Semantics

### 문제

[runner.go](/config/workspace/alt/apps/cli/internal/operator/runner.go:338)는 explicit `poll_backtest_run` step 하나에 대해서만 polling을 수행한다. Batch path는 각 generated run마다 start/poll을 수행하고, terminal `succeeded/failed/canceled`, timeout, transport failure, expectation mismatch를 기존 exit code 계약과 일관되게 분류해야 한다.

### 해결 방법

`runMatrixBatch`는 각 run마다 start event와 poll event를 쓴다. Terminal `succeeded/failed/canceled`는 lifecycle evidence로 `status=ok` 처리한다. Polling timeout은 `codeTransport`로 stop한다. Matrix-level optional expected terminal status를 구현하게 되면 불일치는 `status=mismatch`, `codeAppError`로 처리한다.

Before:

```go
// apps/cli/internal/operator/runner.go:387
run := resp.GetRun()
if run != nil {
	statusStr := strings.ToLower(strings.TrimPrefix(run.GetStatus().String(), "BACKTEST_RUN_STATUS_"))
```

After:

```go
func runMatrixBatch(ctx context.Context, client *APIClient, scenario string, runs []MatrixRun, timeout time.Duration, out *Writer) int {
	// start each run, poll until terminal, write per-run events, preserve exit code contract.
}
```

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

- [ ] `apps/cli/internal/operator/runner.go`: `runMatrixBatch`, `pollBacktestRun` helper 추출 또는 추가.
- [ ] `apps/cli/internal/operator/client_test.go`: fake API에 sequential start/poll canned response hook 추가.
- [ ] `apps/cli/internal/operator/runner_backtest_test.go`: success, terminal failed, polling timeout tests 추가.
- [ ] `apps/cli/internal/operator/runner_error_test.go`: expectation mismatch가 exit 1로 남는지 필요 시 보강.

### 테스트 작성

- 작성: `TestRunBacktestMatrixReportsTerminalFailedAsLifecycleResult`.
- 작성: `TestRunBacktestMatrixPollingTimeoutExitsTransport`.
- 작성: `TestRunBacktestMatrixExpectedStatusMismatchExitsOne` if matrix expectation field exists after 01/02 implementation.

### 중간 검증

```bash
go test -count=1 ./apps/cli/internal/operator ./services/api/internal/socket ./services/worker/internal/socket ./services/worker/internal/jobs
```

예상: 모든 package `ok`.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/cli/internal/operator/runner.go` | API-1, API-3 |
| `apps/cli/internal/operator/output.go` | API-2 |
| `apps/cli/internal/operator/client_test.go` | API-3 |
| `apps/cli/internal/operator/runner_backtest_test.go` | API-1, API-2, API-3 |
| `apps/cli/internal/operator/runner_error_test.go` | API-3 |

## 최종 검증

```bash
go test -count=1 ./apps/cli/internal/operator ./services/api/internal/socket ./services/worker/internal/socket ./services/worker/internal/jobs
```

예상: 모든 package `ok`; Go test cache는 fresh 실행이 필요하므로 `-count=1`을 유지한다.

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