<!-- task=m-operation-event-outbox/02+01_operation_pick plan=0 tag=API -->

# Pending Operation Pick Boundary

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

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채워야 구현이 끝난다. 검증 명령을 실행하고 출력 원문을 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 종결, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 사용자 결정, 사용자 소유 외부 환경, scope 충돌 없이는 진행할 수 없으면 review stub의 `사용자 리뷰 요청`에 근거를 기록하고 중단한다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 재실행으로 채울 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니다.

## 배경

`op-pick`은 durable operation store가 생긴 뒤 worker가 queued operation을 중복 없이 running으로 가져가는 기준을 확정하는 작업이다. 현재 worker는 scaffold 로그만 남기며, operation store도 pick API를 갖고 있지 않다.

## 사용자 리뷰 요청 흐름

구현 중 차단은 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 직접 사용자 프롬프트는 금지되며, code-review가 요청 타당성 검증과 실제 `USER_REVIEW.md` 작성을 맡는다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/control-plane-foundation/milestones/operation-event-outbox.md`
- Task ids:
  - `op-pick`: worker가 pending operation을 안전하게 pick하는 기준을 만든다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-roadmap/current.md`
- `agent-roadmap/phase/control-plane-foundation/PHASE.md`
- `agent-roadmap/phase/control-plane-foundation/milestones/operation-event-outbox.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `services/core/go.mod`
- `services/core/cmd/worker/main.go`
- `services/core/internal/config/config.go`
- `services/core/internal/config/config_test.go`
- `services/core/internal/core/model.go`
- `services/core/internal/storage/storage.go`
- `services/core/internal/storage/postgres.go`
- `services/core/internal/storage/storage_test.go`
- `services/core/internal/storage/postgres_test.go`
- `services/core/internal/storage/postgres_repo_test.go`
- `services/core/internal/worker/runner.go`
- `services/core/migrations/00001_initial.sql`

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md` present/read. Matching profile: `agent-test/local/core-smoke.md`.
- Applied command contract: core changes require `cd services/core && go test ./...`; final verification here uses fresh `go test -count=1 ./...`.
- No `<확인 필요>` command values were found. Postgres duplicate-pick integration test depends on `GITO_TEST_DATABASE_URL`; when unset it skips by existing pattern.

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

- Worker runner has no test file today. Add `services/core/internal/worker/runner_test.go`.
- Pending operation pick has no store API or Postgres test today. Add integration coverage for concurrent picks.
- Command-level worker DB bootstrap is not covered; this plan keeps it out of scope unless a shared migration/bootstrap helper already exists after predecessor work.

### 심볼 참조

- Planned `OperationStore` extension depends on `01_operation_store` replacing `SaveOperation`. Current references to operation store are limited to storage fakes and nil accessors.
- `NewRunner` is called by `services/core/cmd/worker/main.go`; keep the public constructor backward-compatible or update the call site in the same change.

### 분할 판단

- Split policy evaluated before file selection.
- Shared task group: `agent-task/m-operation-event-outbox/`.
- `01_operation_store`: predecessor, must produce `agent-task/m-operation-event-outbox/01_operation_store/complete.log`.
- `02+01_operation_pick`: current dependent subtask. Predecessor index `01` is not satisfied yet; implementation must wait for the active predecessor to PASS.

### 범위 결정 근거

- Exclude operation execution, git command dispatch, retries, and lease release after execution.
- Exclude durable outbox append/publish and proto-socket stream mapping.
- Exclude full worker daemon loop and backoff policy; this task only defines safe pick and one worker iteration boundary.
- Exclude Flutter/provider projection work.

### 빌드 등급

- Build: `local-G06`. Review: `cloud-G06`.
- Rationale: bounded storage/worker code, but concurrent Postgres picking and worker boundary are easy to get subtly wrong.

## 의존 관계 및 구현 순서

- This task directory is `02+01_operation_pick`; runtime dependency source of truth is predecessor `01`.
- Before implementation, require `agent-task/m-operation-event-outbox/01_operation_store/complete.log`.
- Do not implement this task against the pre-01 `OperationStore` shape.

## 구현 체크리스트

- [ ] `OperationStore`에 pending pick API를 추가하고 `pgOperationStore`에서 `FOR UPDATE SKIP LOCKED` 기반으로 queued operation을 running으로 전환한다.
- [ ] worker runner에 one-iteration pick boundary를 추가하고 picker가 없거나 worker disabled일 때의 동작을 테스트한다.
- [ ] concurrent pick 테스트를 작성한다. 검증: 같은 operation이 중복 pick되지 않는다.
- [ ] `go test -count=1 ./internal/storage ./internal/worker`와 `go test -count=1 ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Atomic pending operation pick

#### 문제

- [services/core/internal/storage/storage.go](/config/workspace/gito/services/core/internal/storage/storage.go:53)의 operation store 경계는 pending operation을 atomically claim하는 API를 갖고 있지 않다.
- [services/core/internal/storage/postgres.go](/config/workspace/gito/services/core/internal/storage/postgres.go:61)는 현재 operation store를 반환하지 않는다. 01 완료 후에는 실제 `pgOperationStore`가 존재해야 한다.

Before:

```go
53 type OperationStore interface {
54     SaveOperation(ctx context.Context, op core.Operation) error
55     GetOperation(ctx context.Context, id string) (core.Operation, error)
56     ListOperations(ctx context.Context, repoID string) ([]core.Operation, error)
57 }
```

#### 해결 방법

- 01 완료 후의 `OperationStore`에 `PickQueuedOperation(ctx context.Context, now time.Time) (core.Operation, bool, error)`를 추가한다.
- Postgres 구현은 아래 형태의 CTE를 사용한다.

```sql
WITH candidate AS (
    SELECT id
    FROM operations
    WHERE state=$1
    ORDER BY created_at, id
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
UPDATE operations AS op
SET state=$2, updated_at=$3
FROM candidate
WHERE op.id=candidate.id
RETURNING ...
```

- no rows이면 `(core.Operation{}, false, nil)`을 반환한다.

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

- [ ] `services/core/internal/storage/storage.go`: pick method 추가.
- [ ] `services/core/internal/storage/postgres.go`: `pgOperationStore.PickQueuedOperation` 구현.
- [ ] `services/core/internal/storage/storage_test.go`: fakeOps compile check 갱신.
- [ ] `services/core/internal/storage/postgres_test.go`: concurrent duplicate-pick integration test 추가.

#### 테스트 작성

- `TestPostgresOperationStorePickQueuedOperationDoesNotDuplicate`: 여러 goroutine이 동시에 pick해도 같은 operation id가 두 번 반환되지 않고 picked count가 seeded queued count를 넘지 않는다.
- no queued row이면 `ok=false`.

#### 중간 검증

```bash
cd services/core && go test -count=1 ./internal/storage
```

Expected: storage package passes; DB-specific cases skip only when `GITO_TEST_DATABASE_URL` is unset.

### [API-2] Worker run-one pick boundary

#### 문제

- [services/core/internal/worker/runner.go](/config/workspace/gito/services/core/internal/worker/runner.go:18)의 `Run`은 enabled 상태에서도 scaffold 로그만 남기고 pending operation을 관찰하지 않는다.

Before:

```go
18 func (r *Runner) Run() error {
19     if !r.cfg.WorkerEnabled {
20         r.logger.Info("worker disabled")
21         return nil
22     }
23     r.logger.Info("worker scaffold ready")
24     return nil
25 }
```

#### 해결 방법

- worker package에 small interface를 둔다.

```go
type OperationPicker interface {
    PickQueuedOperation(ctx context.Context, now time.Time) (core.Operation, bool, error)
}
```

- `Runner`에 picker와 clock을 주입할 수 있게 한다. 기존 constructor 호환을 유지하거나 `cmd/worker/main.go`를 함께 갱신한다.
- `RunOnce(ctx)`를 추가해 disabled/no picker/no work/picked/error path를 명확히 한다. `Run()`은 이번 범위에서 `RunOnce(context.Background())`를 호출하는 one-iteration scaffold로 둔다.
- 실제 operation 실행, retry loop, sleep/backoff는 다음 작업으로 남긴다.

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

- [ ] `services/core/internal/worker/runner.go`: picker interface, `RunOnce`, logging/error path 구현.
- [ ] `services/core/internal/worker/runner_test.go`: fake picker 기반 단위 테스트 추가.
- [ ] `services/core/cmd/worker/main.go`: constructor signature가 바뀌면 call site만 맞춘다. DB bootstrap은 이번 계획 범위가 아니다.

#### 테스트 작성

- `TestRunnerRunOnceSkipsWhenDisabled`: disabled config는 picker를 호출하지 않는다.
- `TestRunnerRunOnceReportsNoWork`: picker ok=false path는 nil error.
- `TestRunnerRunOncePicksOperation`: queued operation pick path는 nil error and fake picker count=1.
- `TestRunnerRunOnceReturnsPickerError`: picker error bubbles up.

#### 중간 검증

```bash
cd services/core && go test -count=1 ./internal/worker
```

Expected: worker package passes.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `services/core/internal/storage/storage.go` | API-1 |
| `services/core/internal/storage/postgres.go` | API-1 |
| `services/core/internal/storage/storage_test.go` | API-1 |
| `services/core/internal/storage/postgres_test.go` | API-1 |
| `services/core/internal/worker/runner.go` | API-2 |
| `services/core/internal/worker/runner_test.go` | API-2 |
| `services/core/cmd/worker/main.go` | API-2 |

## 최종 검증

```bash
cd services/core && go test -count=1 ./internal/storage ./internal/worker
cd services/core && go test -count=1 ./...
```

Expected: all packages pass; Postgres integration tests skip only if `GITO_TEST_DATABASE_URL` is unset.

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