<!-- task=m-plane-work-item-webhook-intake/03+02_idempotency plan=1 tag=REVIEW_WEBHOOK_IDEMPOTENCY -->

# Plan - REVIEW_WEBHOOK_IDEMPOTENCY

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

구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 반드시 채운다. 구현 중 사용자만 결정할 수 있는 외부 환경, secret, scope 충돌이 생기면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 증거와 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다. Finalization은 code-review-skill 전용이다.

## 배경

G06 리뷰에서 duplicate external ref task row는 DB upsert로 막히지만, work item pipeline의 existing-task 조회와 workspace provision/slot reservation이 원자적으로 묶이지 않아 동시 duplicate webhook에서 slot side effect가 새어 나갈 수 있음이 확인됐다. 또한 storage/query 멱등성과 handler duplicate 테스트가 계획의 검증 강도에 못 미쳤다. 이 follow-up은 같은 Milestone Task의 멱등성 요구를 닫기 위한 최소 보완이다.

## 사용자 리뷰 요청 흐름

구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 직접 사용자 프롬프트는 금지하며, code-review가 USER_REVIEW 작성 여부를 판단한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
  - `loop-idempotency`: duplicate webhook, provider retry, NomadCode self actor event를 중복 creation task 없이 처리한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-roadmap/current.md`
- `agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log`
- `agent-task/m-plane-work-item-webhook-intake/03+02_idempotency/plan_cloud_G06_0.log`
- `agent-task/m-plane-work-item-webhook-intake/03+02_idempotency/code_review_cloud_G06_0.log`
- `services/core/internal/workitempipeline/service.go`
- `services/core/internal/workitempipeline/service_test.go`
- `services/core/internal/http/plane_webhook_test.go`
- `services/core/internal/storage/store.go`
- `services/core/internal/storage/workspace_slots_test.go`
- `services/core/internal/workflow/service.go`
- `services/core/internal/workflow/service_test.go`
- `services/core/queries/tasks.sql`
- `services/core/queries/workspace_slots.sql`
- `services/core/internal/db/tasks.sql.go`
- `services/core/internal/db/workspace_slots.sql.go`
- `services/core/migrations/00008_add_task_external_ref_unique.sql`

### 테스트 환경 규칙

- `test_env=local`로 적용한다.
- `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다.
- core 변경의 필수 baseline은 `cd services/core && go test ./...`이고, fresh 실행이 필요하므로 `-count=1`을 사용한다.
- standard remote runner 기준으로 `ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'`도 최종 검증에 포함한다.
- sqlc generated code가 바뀌면 `cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate`를 사용한다.
- `<확인 필요>` 값은 없다.

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

- `services/core/internal/workitempipeline/service.go:92`의 existing-task 선조회는 sequential duplicate에는 동작하지만 concurrent duplicate에는 같은 external ref 작업을 직렬화하지 않는다.
- `services/core/internal/workitempipeline/service_test.go:636`과 `:687`은 이미 존재하는 task를 반환하는 경로만 검증하고, 두 요청이 동시에 `pgx.ErrNoRows`를 본 뒤 slot을 두 번 예약하는 실패 모드를 검증하지 않는다.
- `services/core/queries/tasks.sql:4`의 upsert와 `services/core/internal/storage/store.go:60`의 lookup에 대응하는 task storage/query test가 없다.
- `services/core/internal/http/plane_webhook_test.go:526`의 fake는 같은 task를 무조건 반환하므로 duplicate webhook이 duplicate side effect를 만들지 않는다는 증거가 아니다.

### 심볼 참조

- renamed/removed symbol 없음.
- `workitempipeline.TaskCreator` call site는 `services/core/internal/http/handlers.go`에서 `workflow.Service`를 주입하는 path와 `services/core/internal/workitempipeline/service_test.go`의 fake 구현이다.
- `GetTaskByExternalRef` 참조는 `services/core/internal/workflow/service.go`, `services/core/internal/storage/store.go`, `services/core/internal/workitempipeline/service.go`, 관련 tests에 있다.

### 분할 판단

- shared task group: `m-plane-work-item-webhook-intake`
- selected subtask: `03+02_idempotency`
- predecessor `02`는 `agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log`로 충족됐다.
- code-review follow-up은 기존 실패 지점만 보완하므로 같은 subtask 디렉터리에서 plan=1로 이어간다.

### 범위 결정 근거

- historical duplicate data backfill이나 운영 DB 수동 정리는 이 follow-up 범위가 아니다.
- roadmap 문서 갱신은 code-review PASS completion event 이후 런타임 책임이므로 이 plan에서 수정하지 않는다.
- Plane live smoke는 sibling `04+01,02,03_live_smoke` 범위다. 여기서는 deterministic unit/storage tests와 remote core baseline만 닫는다.

### 빌드 등급

- `cloud-G07`: storage/migration idempotency와 concurrent side-effect prevention이 결합된 correctness failure이며, 기존 증거가 테스트 통과에도 놓친 동시성 edge를 다룬다.

## 의존 관계 및 구현 순서

- `02+01_trigger_dispatch` 완료 근거: `agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log`.
- 먼저 provider/id scoped atomic boundary를 구현하고, 그 다음 storage/http/pipeline tests를 보강한다.

## 구현 체크리스트

- [ ] 동일 external provider/id의 work item creation path를 원자화하여 concurrent duplicate webhook에서도 workspace provision과 slot reservation이 한 번만 발생하게 한다. 검증: 같은 provider/work item을 동시에 처리해도 동일 task를 반환하고 `ReserveWorkspaceSlot` 호출이 1회다.
- [ ] storage/query duplicate external ref test와 meaningful duplicate webhook/pipeline tests를 추가하여 `CreateTask` upsert, `GetTaskByExternalRef`, duplicate webhook side-effect 방지를 검증한다.
- [ ] sqlc/gofmt/focused tests/local full core/remote full core 검증을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [REVIEW_WEBHOOK_IDEMPOTENCY-1] Atomic External Ref Side-Effect Boundary

### 문제

`services/core/internal/workitempipeline/service.go:92`는 existing task를 먼저 조회하지만, `services/core/internal/workitempipeline/service.go:127`부터 workspace provision과 slot reservation을 수행한 뒤 `services/core/internal/workitempipeline/service.go:155`에서 task create/upsert를 호출한다. 동일 external ref 요청 2개가 동시에 들어오면 둘 다 no-row를 보고 각각 slot을 예약할 수 있다.

### 해결 방법

Provider/id가 있는 work item creation path를 provider/id scoped lock 또는 원자적 claim으로 감싼다. 권장 구현은 `workflow.Service`/`storage.Store` 쪽에 external ref lock helper를 추가하고, `workitempipeline.Service.CreateTaskFromWorkItem`에서 같은 external ref에 대해 아래 흐름 전체를 직렬화하는 것이다.

```go
// services/core/internal/workitempipeline/service.go:91
if input.Ref.Provider != "" && input.Ref.ID != "" {
    return s.tasks.WithExternalRefLock(ctx, string(input.Ref.Provider), input.Ref.ID, func(ctx context.Context) (storage.Task, error) {
        existing, err := s.tasks.GetTaskByExternalRef(ctx, string(input.Ref.Provider), input.Ref.ID)
        if err == nil {
            return existing, nil
        }
        if !errors.Is(err, pgx.ErrNoRows) {
            return storage.Task{}, err
        }
        return s.createTaskFromWorkItemUnlocked(ctx, input)
    })
}
return s.createTaskFromWorkItemUnlocked(ctx, input)
```

Lock 구현은 Postgres advisory lock처럼 process 간에도 같은 provider/id를 직렬화하는 방식을 우선한다. session lock을 쓰면 dedicated connection을 acquire하고 `defer`로 unlock/release를 보장한다. in-process mutex만 쓰는 경우 multi-instance core에서 같은 문제가 남으므로, 명확한 이유와 남은 위험을 review stub에 기록해야 한다.

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

- [ ] `services/core/internal/storage/store.go`: provider/id scoped lock 또는 atomic claim helper를 expose한다.
- [ ] `services/core/internal/workflow/service.go`: pipeline이 사용할 lock/atomic helper를 validation 포함으로 연결한다.
- [ ] `services/core/internal/workitempipeline/service.go`: external ref가 있을 때 locked section 안에서 second lookup, trigger evaluation, workspace provision, slot reservation, create를 수행한다.
- [ ] `services/core/internal/workitempipeline/service_test.go`: concurrent duplicate test를 추가한다.

### 테스트 작성

- Required: `TestCreateTaskFromWorkItemConcurrentDuplicateExternalRefReservesOneSlot`.
- 두 goroutine이 같은 `workitem.Ref{Provider:"plane", ID:"work-1"}`로 진입하도록 만들고, 첫 번째 create 이후 두 번째는 existing task를 반환해야 한다.
- assertion: returned task IDs are equal, `ReserveWorkspaceSlot` count is exactly 1, `CreateTask` count is exactly 1.

### 중간 검증

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

Expected: concurrent duplicate regression test를 포함해 package가 통과한다.

## [REVIEW_WEBHOOK_IDEMPOTENCY-2] Storage And Webhook Evidence

### 문제

`services/core/queries/tasks.sql:4`와 `services/core/internal/storage/store.go:60`의 storage/query 멱등성은 production behavior의 핵심인데 task storage test가 없다. `services/core/internal/http/plane_webhook_test.go:526`의 duplicate fake는 같은 task를 무조건 반환하므로 duplicate side effect 방지 증거가 되지 않는다.

### 해결 방법

`services/core/internal/storage/workspace_slots_test.go`의 mock DBTX 패턴을 재사용해 task query shape와 arguments를 검증하는 storage test를 추가한다. 실제 DB harness가 없으면 최소한 generated query가 `ON CONFLICT (external_provider, external_id)`와 partial predicate를 포함하고, `GetTaskByExternalRef`가 provider/id args를 그대로 전달함을 확인한다.

HTTP duplicate test는 무조건 같은 task를 반환하는 fake 대신 stateful fake를 사용한다. 첫 번째 호출은 create side effect를 기록하고, 두 번째 동일 ref 호출은 existing task 반환만 기록하게 해 duplicate create count가 1임을 검증한다. 더 적합하면 handler-level test는 stable dispatch ref 검증으로 좁히고, side effect count는 pipeline concurrent test에서 강하게 검증한다.

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

- [ ] `services/core/internal/storage/task_store_test.go`: `CreateTask` upsert query shape, args, default metadata/external metadata, `GetTaskByExternalRef` args를 검증한다.
- [ ] `services/core/internal/http/plane_webhook_test.go`: duplicate test가 fake의 무조건 동일 반환에 의존하지 않도록 수정한다.
- [ ] `services/core/internal/workitempipeline/service_test.go`: side effect 방지 assertion을 concurrent case와 연결한다.
- [ ] `services/core/internal/db/tasks.sql.go`: sqlc regenerate 결과가 `queries/tasks.sql`과 일치하는지 확인한다.

### 테스트 작성

- Required: `TestCreateTask_UsesExternalRefUpsert`.
- Required: `TestGetTaskByExternalRef_PassesProviderAndID`.
- Required: duplicate webhook test update. 이름은 유지해도 되지만, assertion은 duplicate create side effect count가 1임을 증명해야 한다.

### 중간 검증

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

Expected: focused packages가 통과한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/storage/store.go` | REVIEW_WEBHOOK_IDEMPOTENCY-1 |
| `services/core/internal/workflow/service.go` | REVIEW_WEBHOOK_IDEMPOTENCY-1 |
| `services/core/internal/workitempipeline/service.go` | REVIEW_WEBHOOK_IDEMPOTENCY-1 |
| `services/core/internal/workitempipeline/service_test.go` | REVIEW_WEBHOOK_IDEMPOTENCY-1, REVIEW_WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/storage/task_store_test.go` | REVIEW_WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/http/plane_webhook_test.go` | REVIEW_WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/db/tasks.sql.go` | REVIEW_WEBHOOK_IDEMPOTENCY-2 |

## 최종 검증

```bash
cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
cd services/core && gofmt -w internal/storage/store.go internal/workflow/service.go internal/workitempipeline/service.go internal/workitempipeline/service_test.go internal/storage/task_store_test.go internal/http/plane_webhook_test.go internal/db/tasks.sql.go
cd services/core && go test -count=1 ./internal/storage ./internal/workflow ./internal/workitempipeline ./internal/http
cd services/core && go test -count=1 ./...
tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
```

Expected: sqlc generation succeeds, focused tests pass, local full core tests pass, remote full core tests pass.

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