<!-- task=workflow_core/02+01_retry_timeout plan=1 tag=REVIEW_WORKFLOW -->

# Workflow Core Retry Follow-up

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

이 계획은 `code_review_cloud_G07_0.log`의 Required 항목을 닫기 위한 후속 작업이다. 구현 후 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채우고, active 파일은 그대로 둔다. 최종 판정, log rename, archive 이동은 code-review skill 전용이다.

## 배경

첫 구현은 lifecycle metadata와 worker timeout을 추가했지만, 실패 task를 즉시 `failed`로 표시한 뒤 River에 error를 반환한다. 새 lifecycle 전이 규칙은 `failed -> running`을 막기 때문에 River `MaxAttempts`가 있어도 자동 재시도가 실제 다음 attempt를 실행하지 못한다. 후속 작업은 retry 전이와 attempt 테스트를 production 코드 경로에 맞게 고친다.

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/roadmap/milestones/workflow-core.md`
- `agent-task/workflow_core/02+01_retry_timeout/plan_cloud_G07_0.log`
- `agent-task/workflow_core/02+01_retry_timeout/code_review_cloud_G07_0.log`
- `services/core/internal/workflow/model.go`
- `services/core/internal/workflow/lifecycle.go`
- `services/core/internal/workflow/service.go`
- `services/core/internal/workflow/service_test.go`
- `services/core/internal/scheduler/jobs.go`
- `services/core/internal/scheduler/jobs_test.go`
- `services/core/internal/scheduler/river.go`
- `services/core/internal/config/config.go`
- `services/core/internal/config/config_test.go`
- `services/core/cmd/server/main.go`
- `services/core/internal/storage/store.go`
- `services/core/queries/tasks.sql`
- `services/core/Makefile`
- `bin/test`
- `bin/lint`
- `services/core/bin/test`

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

- 실패 후 River가 같은 job을 재시도할 때 task 상태가 `failed`인 상태에서 다음 attempt가 실제 실행되는지 검증하는 테스트가 없다.
- `TestLifecycleMetadataStartIncrementsAttempt`는 production `StartTask`나 production helper를 호출하지 않고 테스트 내부에서 attempt 계산을 재구현한다.
- 포맷 검증은 기존 `go test`와 `go vet` 경로에 포함되어 있지 않다.

### 심볼 참조

- renamed/removed symbols: none.
- `canTransition`은 workflow package 내부 테스트와 `Lifecycle.StartTask`, `CompleteTask`, `FailTask`, `CancelTask`에서 참조된다.
- `TaskLifecycle`는 `scheduler.TaskWorker`와 scheduler tests fake lifecycle에서 사용된다.

### 분할 판단

split decision policy를 평가했다. 이번 후속은 동일한 retry 실패 흐름의 correction이며, lifecycle 전이와 scheduler regression test가 서로 같은 원인을 검증한다. 별도 subtask로 나누면 같은 active retry 계약을 두 번 열어야 하므로 기존 `workflow_core/02+01_retry_timeout` 디렉터리의 단일 follow-up plan으로 처리한다.

### 범위 결정 근거

- DB migration은 제외한다. retry 상태는 기존 `tasks.metadata`와 status 컬럼으로 닫는다.
- notification event 발행은 sibling task `03+01_notification_events` 범위다.
- River 설정값 자체 변경은 제외한다. 기존 `MaxAttempts: 3`가 실제 실행되도록 lifecycle/worker 경계를 고친다.
- 외부 A2A/OpenAI adapter 내부 retry 정책은 제외한다.

### 빌드 등급

build=`cloud-G07`, review=`cloud-G07`: scheduler retry correctness와 test 의미성 실패에 대한 후속이며, worker 실행 흐름과 lifecycle 전이를 함께 확인해야 한다.

## 구현 체크리스트

- [ ] [REVIEW_WORKFLOW-1] River retry가 failed 상태 이후에도 실제 다음 attempt를 실행하게 한다.
- [ ] [REVIEW_WORKFLOW-2] attempt 증가 테스트가 production helper/lifecycle 경로를 검증하게 고친다.
- [ ] 중간 검증과 최종 검증을 모두 실행하고 실제 출력을 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_WORKFLOW-1] River retry가 failed 상태 이후에도 실제 다음 attempt를 실행하게 한다

#### 문제

`TaskWorker.Work`는 task 실행 오류마다 실패 상태를 기록하고 error를 River에 반환한다.

```go
// services/core/internal/scheduler/jobs.go:63
result, message, err := w.runTask(runCtx, task)
if err != nil {
    w.markFailed(taskID, err)
    return err
}
```

이후 River가 `MaxAttempts: 3`으로 같은 job을 다시 실행해도 lifecycle 전이가 `failed -> running`을 허용하지 않는다.

```go
// services/core/internal/workflow/lifecycle.go:25
func canTransition(from, to TaskStatus) bool {
    switch from {
    case StatusPending, StatusQueued:
        return to == StatusRunning
```

#### 해결 방법

retry 전이를 명시적으로 허용한다. 최소 수정은 `canTransition`에서 `StatusFailed`도 `StatusRunning`으로 전이 가능하게 하는 것이다. 이 동작은 `Service.EnqueueTask`가 이미 failed task 재enqueue를 허용하는 정책과 맞춘다.

After:

```go
case StatusPending, StatusQueued, StatusFailed:
    return to == StatusRunning
```

추가로 scheduler regression test를 작성한다. fake lifecycle은 첫 `Work` 실패 후 상태를 `failed`로 두고, 두 번째 `Work` 호출에서 `StartTask`가 다시 `running`으로 바뀐 뒤 `CompleteTask`까지 호출되는지 검증한다.

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

- [ ] `services/core/internal/workflow/lifecycle.go`: `failed -> running` retry 전이를 명시한다.
- [ ] `services/core/internal/workflow/service_test.go`: `TestCanTransitionAllowsRetryFromFailedToRunning` 추가.
- [ ] `services/core/internal/scheduler/jobs_test.go`: 실패 후 두 번째 `Work`가 재실행/완료되는 regression test 추가.

#### 테스트 작성

작성한다.

- `TestCanTransitionAllowsRetryFromFailedToRunning`: `canTransition(StatusFailed, StatusRunning)`이 true인지 검증한다.
- `TestWorkRetriesAfterFailureState`: 첫 worker 실행은 fake agent error로 failed, 두 번째 실행은 dummy/model success로 completed까지 간다는 호출 순서를 검증한다.

#### 중간 검증

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

기대 결과: workflow와 scheduler package tests pass. Go test cache output은 허용하지 않는다.

### [REVIEW_WORKFLOW-2] attempt 증가 테스트가 production helper/lifecycle 경로를 검증하게 고친다

#### 문제

현재 테스트는 attempt 증가 계산을 production 코드가 아니라 테스트 내부에서 다시 구현한다.

```go
// services/core/internal/workflow/service_test.go:156
current2 := json.RawMessage(`{"attempt":2}`)
var currentMap map[string]any
json.Unmarshal(current2, &currentMap)
var attempt int = 1
```

이 테스트는 `Lifecycle.StartTask`의 attempt 증가 로직이 깨져도 통과할 수 있다.

#### 해결 방법

`Lifecycle.StartTask`가 사용하는 attempt 계산을 작은 production helper로 추출하고, 테스트가 그 helper를 호출하게 한다. DB fixture를 만들지 않는 한 concrete `storage.Store`를 직접 fake하기 어렵기 때문에, 테스트 내부 재구현 대신 production helper를 검증하는 방식으로 의미성을 회복한다.

After:

```go
func nextTaskAttempt(metadata json.RawMessage) (int, error)
```

`StartTask`는 이 helper를 사용하고, invalid metadata는 `ErrInvalidTaskInput`으로 반환한다.

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

- [ ] `services/core/internal/workflow/lifecycle.go`: `nextTaskAttempt` helper를 추가하고 `StartTask`에서 사용한다.
- [ ] `services/core/internal/workflow/service_test.go`: `TestLifecycleMetadataStartIncrementsAttempt`가 `nextTaskAttempt`를 직접 검증하게 수정한다.
- [ ] `services/core/internal/config/config.go`, `services/core/internal/config/config_test.go`, `services/core/internal/scheduler/jobs_test.go`, `services/core/internal/workflow/service.go`, `services/core/internal/workflow/service_test.go`: touched Go 파일에 `gofmt`를 적용한다.

#### 테스트 작성

작성한다.

- `TestLifecycleMetadataStartIncrementsAttempt`: `{}` 또는 no attempt는 `1`, `{"attempt":2}`는 `3`을 반환하는지 production helper로 검증한다.
- `TestLifecycleMetadataRejectsInvalidExistingMetadata`: invalid metadata가 helper 또는 merge path에서 `ErrInvalidTaskInput`으로 남는지 검증한다.

#### 중간 검증

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

기대 결과: workflow package tests pass. Go test cache output은 허용하지 않는다.

## 의존 관계 및 구현 순서

1. `REVIEW_WORKFLOW-1`로 retry 전이를 먼저 고친다.
2. `REVIEW_WORKFLOW-2`로 attempt helper/test 의미성을 고친다.
3. `gofmt`를 적용한 뒤 검증을 실행한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/workflow/lifecycle.go` | REVIEW_WORKFLOW-1, REVIEW_WORKFLOW-2 |
| `services/core/internal/workflow/service_test.go` | REVIEW_WORKFLOW-1, REVIEW_WORKFLOW-2 |
| `services/core/internal/scheduler/jobs_test.go` | REVIEW_WORKFLOW-1 |
| `services/core/internal/config/config.go` | REVIEW_WORKFLOW-2 gofmt only |
| `services/core/internal/config/config_test.go` | REVIEW_WORKFLOW-2 gofmt only |
| `services/core/internal/workflow/service.go` | REVIEW_WORKFLOW-2 gofmt only |

## 최종 검증

```bash
gofmt -l services/core/cmd/server/main.go services/core/internal/config/config.go services/core/internal/config/config_test.go services/core/internal/scheduler/jobs.go services/core/internal/scheduler/jobs_test.go services/core/internal/scheduler/river.go services/core/internal/workflow/lifecycle.go services/core/internal/workflow/service.go services/core/internal/workflow/service_test.go
```

기대 결과: 출력 없음.

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

기대 결과: all core Go tests pass. Go test cache output은 허용하지 않는다.

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