<!-- task=m-scheduled-market-data-refresh/01_scheduled_runner plan=0 tag=SCHED -->

# Plan - SCHED

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

`CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 검증 명령을 실행하고 실제 stdout/stderr를 붙여 넣은 뒤 active 파일을 그대로 두고 review ready 상태로 보고한다. 선택된 SDD 결정이나 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 직접 질문하지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거를 기록한다. 환경/secret/service 준비, 일반 범위 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 plan 대상이다. 구현 에이전트는 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다.

## 배경

`schedule-config`는 작은 작업으로 직접 처리되어 `services/worker/internal/scheduler`에 YAML validation/dry-run surface가 생겼다. 남은 `scheduled-runner`는 이 config를 worker runtime에 연결해 내장 tick, bounded parallel dispatch, duplicate-safe result evidence를 만드는 큰 작업이다. SDD S02는 external cron 없이 worker tick 2회와 item별 result, duplicate row 없음, freshness/readiness output을 요구한다.

## 사용자 리뷰 요청 흐름

사용자 리뷰는 선택된 SDD decision 또는 Milestone lock decision만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 채팅 질문, 선택지 제시, `request_user_input` 호출은 금지한다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/backtest-loop/milestones/scheduled-market-data-refresh.md`
- Task ids:
  - `scheduled-runner`: 원격 worker runtime에서 내장 scheduler tick이 market data import job을 실행하고, 병렬 수집과 재시작 또는 tick 중복에도 같은 bar key를 중복 저장하지 않는다.
- Completion mode: check-on-pass

## Spec Targets

- SDD: `agent-roadmap/sdd/backtest-loop/scheduled-market-data-refresh/SDD.md`
- Acceptance scenarios:
  - `S02`: task=`scheduled-runner`; evidence=`worker 내장 scheduler tick smoke, bounded parallel dispatch output, bars key 중복 없음, freshness/readiness output`
- Completion mode: spec-check-on-pass

## 분석 결과

### 읽은 파일

- `agent-test/local/rules.md`
- `agent-test/local/worker-smoke.md`
- `agent-test/local/operations-smoke.md`
- `agent-roadmap/phase/backtest-loop/milestones/scheduled-market-data-refresh.md`
- `agent-roadmap/sdd/backtest-loop/scheduled-market-data-refresh/SDD.md`
- `services/worker/internal/scheduler/config.go`
- `services/worker/internal/scheduler/config_test.go`
- `services/worker/cmd/alt-worker-schedule-check/main.go`
- `services/worker/cmd/alt-worker/main.go`
- `services/worker/cmd/alt-worker/main_test.go`
- `services/worker/internal/config/config.go`
- `services/worker/internal/config/config_test.go`
- `services/worker/internal/jobs/job.go`
- `services/worker/internal/jobs/runner.go`
- `services/worker/internal/jobs/marketdata_jobs.go`
- `services/worker/internal/jobs/marketdata_jobs_test.go`
- `services/worker/internal/marketdata/importer/importer.go`
- `services/worker/internal/marketdata/importer/importer_test.go`
- `services/worker/internal/storage/postgres/queries/queries.sql`
- `services/worker/go.mod`

### 테스트 환경 규칙

test_env는 `local`이다. `agent-test/local/rules.md`를 읽었고, `services/worker/**` 변경이므로 `agent-test/local/worker-smoke.md`의 `go test ./services/worker/...`가 기본 필수 검증이다. command surface도 있으나 `apps/cli`가 아니라 worker cmd이므로 worker-smoke를 우선 적용한다. storage SQL 동작을 바꾸지 않으면 `bin/worker-storage-check`는 필수로 두지 않는다. `agent-test/local/operations-smoke.md`는 새 command/Go module metadata 확인에 보조로 읽었다.

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

- scheduler config validation/dry-run은 `services/worker/internal/scheduler/config_test.go`가 커버한다.
- worker 내장 scheduler tick, bounded parallel dispatch, duplicate running tick skip/result output은 아직 테스트가 없다.
- worker process startup에서 schedule config path를 읽고 loop를 시작/종료하는 wiring test가 없다.
- 실제 PostgreSQL duplicate row 없음은 기존 `UpsertBar`와 importer idempotency가 부분 근거지만, scheduler tick 2회 smoke evidence는 없다.

### 심볼 참조

none. 현재 plan은 symbol rename/remove를 요구하지 않는다.

### 분할 판단

split decision policy를 먼저 평가했다. shared task group은 `m-scheduled-market-data-refresh`다. `01_scheduled_runner`는 `scheduled-runner` task 전용이고 `depends_on=[]`이다. `02_retry_backfill`은 별도 stale/backfill calculator plan이고 `depends_on=[]`이다. 두 작업은 모두 현재 코드에 들어간 `schedule-config` surface를 소비하지만 active split predecessor `complete.log`를 요구하지 않도록 독립 subtask로 둔다. runtime loop와 retry/backfill policy는 서로 다른 위험/검증 단위라 하나의 plan으로 묶지 않는다.

### 범위 결정 근거

이 plan은 `scheduled-runner`만 닫는다. `retry-backfill`, `refresh-status`, `backtest-readiness`, Flutter UI, API/contracts schema 변경, external cron/systemd timer 구현은 제외한다. remote live provider smoke와 secret 주입은 tracked plan evidence에 원문을 남기지 않고 local fake executor 및 worker unit smoke로 먼저 고정한다.

### 빌드 등급

local-G06. 범위는 worker 내부 config/runner/main wiring으로 크지만, 기존 `jobs.Runner`와 새 scheduler package 안에서 deterministic unit tests로 검증 가능하다.

## 구현 체크리스트

- [ ] `services/worker/internal/scheduler`에 worker tick runner, bounded parallel dispatch, duplicate-running guard, stable tick result text/JSONL 출력을 구현한다.
- [ ] `services/worker/internal/config`와 `cmd/alt-worker`에 schedule config path와 loop startup/shutdown wiring을 추가하되 config가 없으면 기존 worker startup 동작을 유지한다.
- [ ] `scheduled-runner` 검증: worker scheduler tick 2회 실행 후 병렬 item별 result와 `bars` key 중복 없는 freshness/readiness 출력이 남는 unit/local smoke를 추가한다.
- [ ] 최종 검증으로 `go test -count=1 ./services/worker/internal/scheduler ./services/worker/internal/config ./services/worker/cmd/alt-worker`와 `go test ./services/worker/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [SCHED-1] Scheduler Tick Runner

문제: `services/worker/internal/scheduler/config.go:1`은 scheduler package가 config validation/dry-run만 제공하고 long-running loop를 소유하지 않는다고 명시한다. `services/worker/internal/jobs/runner.go:40`의 `Runner.Execute`는 단일 job dispatch만 제공하므로 schedule tick, bounded parallelism, duplicate-running guard, item별 result surface가 없다.

Before:

```go
// services/worker/internal/scheduler/config.go:1
// Package scheduler validates scheduled market data refresh configuration and
// renders dry-run evidence. It does not own the long-running worker loop; the
// runner can consume the validated shapes in a later orchestration layer.
```

해결 방법: `services/worker/internal/scheduler/runner.go`와 `runner_test.go`를 추가한다. `Executor` interface는 `Execute(context.Context, jobs.Job) error`로 두어 `*jobs.Runner`와 fake executor가 모두 들어오게 한다. `RunTick(ctx, cfg, opts)`는 valid schedules만 대상으로 symbols를 item 단위로 펼치고, schedule별 `ParallelismLimit`으로 worker goroutine을 제한한다. 각 item은 `jobs.DailyBarImportPayload`를 JSON으로 만들어 `KindImportDailyBars`를 실행한다. 같은 schedule/window가 이미 running이면 `status=skipped` result를 남기고, sequential 2회 tick은 실행되며 idempotency는 importer/store upsert 경계가 담당한다.

수정 파일 및 체크리스트:

- [ ] `services/worker/internal/scheduler/runner.go` 추가: `Executor`, `TickOptions`, `TickResult`, `ItemResult`, `RunTick`, `WriteTickText`, `WriteTickJSONL`.
- [ ] `services/worker/internal/scheduler/runner_test.go` 추가: parallelism limit, duplicate-running skip, two sequential ticks, JSONL/text fields 검증.
- [ ] `services/worker/internal/scheduler/config.go` 필요 시 exported helper 추가. 기존 validation output contract는 유지.

테스트 작성: `TestRunTickDispatchesItemsWithBoundedParallelism`, `TestRunTickSkipsDuplicateRunningWindow`, `TestWriteTickJSONLIncludesReadinessFields`를 추가한다. fake executor가 payload를 decode해 symbols/job id/count를 기록한다.

중간 검증:

```bash
go test -count=1 ./services/worker/internal/scheduler
```

기대 결과: exit code 0. scheduler package tests pass.

### [SCHED-2] Worker Runtime Wiring

문제: `cmd/alt-worker/main.go:31`은 config load 뒤 socket server만 시작하고 `main.go:65`까지 scheduler loop startup이 없다. `services/worker/internal/config/config.go:8`의 `Config`에도 schedule config path나 scheduler enable flag가 없다. 반면 `main.go:81`은 socket import command와 scheduled jobs가 같은 importer wiring을 공유해야 한다고 이미 설명한다.

Before:

```go
// services/worker/cmd/alt-worker/main.go:31
cfg := config.Load()
runner := jobs.NewRunner()
jobs.RegisterBuiltins(runner)
```

해결 방법: `config.Config`에 `ScheduleConfigPath`와 `SchedulerEnabled`를 추가한다. env는 `ALT_WORKER_SCHEDULE_CONFIG`를 config path로 사용하고, path가 비어 있으면 scheduler disabled로 둔다. `cmd/alt-worker`는 store-backed deps가 준비되고 `KindImportDailyBars` handler가 등록된 뒤 scheduler config를 읽고 loop goroutine을 시작한다. loop는 context cancellation으로 멈추고, startup log에는 `scheduler_enabled`와 schedule count만 남긴다. config file에 secret 값은 두지 않는다.

수정 파일 및 체크리스트:

- [ ] `services/worker/internal/config/config.go` / `config_test.go`: env parsing과 default disabled test.
- [ ] `services/worker/cmd/alt-worker/main.go`: `startScheduler` helper 추가, no-config no-op, invalid config typed startup error/log.
- [ ] `services/worker/cmd/alt-worker/main_test.go`: nil DB 없는 wiring test는 유지하고, fake runner/config로 scheduler no-op/start path를 테스트한다.

테스트 작성: config env test와 `startScheduler` helper unit test를 추가한다. 실제 DB/live KIS 없이 fake executor로만 검증한다.

중간 검증:

```bash
go test -count=1 ./services/worker/internal/config ./services/worker/cmd/alt-worker
```

기대 결과: exit code 0. worker startup wiring tests pass.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/worker/internal/scheduler/runner.go` | SCHED-1 |
| `services/worker/internal/scheduler/runner_test.go` | SCHED-1 |
| `services/worker/internal/scheduler/config.go` | SCHED-1 |
| `services/worker/internal/config/config.go` | SCHED-2 |
| `services/worker/internal/config/config_test.go` | SCHED-2 |
| `services/worker/cmd/alt-worker/main.go` | SCHED-2 |
| `services/worker/cmd/alt-worker/main_test.go` | SCHED-2 |

## 최종 검증

```bash
go test -count=1 ./services/worker/internal/scheduler ./services/worker/internal/config ./services/worker/cmd/alt-worker
go test ./services/worker/...
```

Go test cache output is acceptable only for the broad second command; the focused first command must use `-count=1`.

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