<!-- task=workflow_core/02+01_retry_timeout plan=0 tag=WORKFLOW -->

# Workflow Core Retry And Timeout

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

`01_lifecycle_foundation`이 PASS되어 `complete.log`가 생긴 뒤 시작한다. `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션 작성은 필수이며, 구현 후 검증 출력과 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 둔다. 최종 판정과 archive 이동은 code-review skill 전용이다.

## 배경

Workflow Core는 NomadCode task의 retry/timeout envelope를 소유하고 외부 IOP/A2A/model runtime 내부 정책과 분리해야 한다. 현재 River job `MaxAttempts`는 있지만 NomadCode task metadata에는 attempt, heartbeat, timeout reason이 일관되게 남지 않는다. 이 작업은 lifecycle API 위에 retry/timeout metadata와 worker timeout을 얹는다.

## 분석 결과

### 읽은 파일

- `agent-ops/roadmap/current.md`
- `agent-ops/roadmap/milestones/workflow-core.md`
- `services/core/internal/workflow/model.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/cmd/server/main.go`
- `services/core/internal/model/model.go`
- `services/core/Makefile`
- `services/core/go.mod`
- `services/core/bin/test`
- `bin/test`

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

- attempt/heartbeat/status_reason metadata update는 기존 테스트가 없다.
- worker-level timeout은 기존 테스트가 없다. `runTask`는 context를 받지만 `TaskWorker.Work`는 별도 timeout envelope를 만들지 않는다.
- River `MaxAttempts` 설정은 `scheduler/river.go:42`에 있지만 task metadata attempt와 연결되지 않는다.

### 심볼 참조

- renamed/removed symbols: none.
- 새 call site: `workflow.Lifecycle.StartTask`, `CompleteTask`, `FailTask`, `CancelTask` 내부 metadata update path.

### 분할 판단

split decision policy를 먼저 평가했다. 이 계획은 `workflow_core/02+01_retry_timeout`이며 `workflow_core/01_lifecycle_foundation`에 의존한다. lifecycle API가 확정되어야 metadata update 위치와 timeout failure path를 안정적으로 붙일 수 있다. notification event 발행은 `03+01_notification_events`로 분리한다.

### 범위 결정 근거

- provider comment/status projection은 제외한다. 이 작업은 canonical task metadata와 worker timeout까지만 다룬다.
- DB migration은 제외한다. `tasks.metadata jsonb`가 이미 있고 retry/timeout snapshot은 여기에 저장한다.
- external runtime 내부 retry 정책은 제외한다. NomadCode는 외부 호출 envelope와 task 상태만 소유한다.

### 빌드 등급

build=`cloud-G07`, review=`cloud-G07`: timeout/retry는 scheduler 실행 안정성과 metadata 계약에 영향을 주며 기존 테스트가 부족하다.

## 구현 체크리스트

- [ ] [WORKFLOW-1] lifecycle metadata update로 attempt, heartbeat, status reason을 기록한다.
- [ ] [WORKFLOW-2] scheduler worker에 task run timeout envelope를 추가한다.
- [ ] 중간 검증과 최종 검증을 모두 실행하고 실제 출력을 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [WORKFLOW-1] lifecycle metadata update로 attempt, heartbeat, status reason을 기록한다

#### 문제

`services/core/internal/workflow/model.go:31-38`에는 `attempt`, `last_heartbeat_at`, `status_reason` metadata key가 정의되어 있지만, `services/core/internal/workflow/service.go:139-144`의 metadata update는 raw replacement뿐이고 lifecycle 상태 변화와 연결되어 있지 않다.

#### 해결 방법

`01_lifecycle_foundation`에서 만든 lifecycle API에 metadata merge helper를 붙인다.

Before (`services/core/internal/workflow/service.go:139-144`):

```go
func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata json.RawMessage) (storage.Task, error) {
    normalized, err := NormalizeTaskMetadata(metadata)
    if err != nil {
        return storage.Task{}, err
    }
    return s.store.UpdateMetadata(ctx, id, normalized)
}
```

After:

```go
func mergeTaskMetadata(current json.RawMessage, updates map[string]any) (json.RawMessage, error)
```

Lifecycle behavior:

- `StartTask`: increments `attempt`, sets `agent_run_state=running`, `last_heartbeat_at=<RFC3339 UTC>`.
- `CompleteTask`: sets `agent_run_state=completed`, clears `wait_type`, sets `status_reason` only when a non-empty summary exists.
- `FailTask`: sets `agent_run_state=failed`, `status_reason=<message>`.
- `CancelTask`: sets `agent_run_state=canceled`, `status_reason=<message>`.

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

- [ ] `services/core/internal/workflow/model.go`: add any missing metadata constants or helper comments.
- [ ] `services/core/internal/workflow/lifecycle.go`: merge metadata during lifecycle transitions.
- [ ] `services/core/internal/workflow/service_test.go`: add table tests for metadata merge and attempt increment.

#### 테스트 작성

작성한다.

- `TestMergeTaskMetadataPreservesExistingKeys`: existing `plan_ref` remains when attempt/status update occurs.
- `TestLifecycleMetadataStartIncrementsAttempt`: missing attempt becomes `1`, existing numeric attempt increments.
- `TestLifecycleMetadataRejectsInvalidExistingMetadata`: invalid current JSON returns workflow invalid input error.

#### 중간 검증

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

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

### [WORKFLOW-2] scheduler worker에 task run timeout envelope를 추가한다

#### 문제

`services/core/internal/scheduler/jobs.go:38-76`의 `Work`는 River job context를 그대로 사용하며 NomadCode task-level timeout을 적용하지 않는다. `services/core/internal/config/config.go:8-29`에도 workflow task timeout 설정이 없다.

#### 해결 방법

config와 scheduler worker에 task run timeout을 추가한다.

Before (`services/core/internal/scheduler/jobs.go:49`):

```go
result, message, err := w.runTask(ctx, task)
```

After:

```go
runCtx := ctx
if w.RunTimeout > 0 {
    var cancel context.CancelFunc
    runCtx, cancel = context.WithTimeout(ctx, w.RunTimeout)
    defer cancel()
}
result, message, err := w.runTask(runCtx, task)
```

Config:

- Add `WorkflowTaskTimeoutSec int` to `config.Config`.
- Load from `WORKFLOW_TASK_TIMEOUT_SEC`, default `300`.
- Wire `time.Duration(cfg.WorkflowTaskTimeoutSec) * time.Second` into `scheduler.New`.

Failure mapping:

- If `errors.Is(err, context.DeadlineExceeded)`, call lifecycle `FailTask` with `status_reason=timeout`.
- Other errors keep their error message.

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

- [ ] `services/core/internal/config/config.go`: add `WorkflowTaskTimeoutSec`.
- [ ] `services/core/Makefile`: add local default `WORKFLOW_TASK_TIMEOUT_SEC ?= 300`.
- [ ] `services/core/internal/scheduler/river.go`: pass timeout into `TaskWorker`.
- [ ] `services/core/internal/scheduler/jobs.go`: apply timeout context and map deadline exceeded to timeout failure reason.
- [ ] `services/core/cmd/server/main.go`: wire config timeout to scheduler.
- [ ] `services/core/internal/scheduler/jobs_test.go`: add timeout behavior test with a blocking fake model client.

#### 테스트 작성

작성한다.

- `TestWorkMarksTimeoutFailure`: fake model blocks until ctx done, worker has tiny `RunTimeout`, fake lifecycle records `FailTask` message containing `timeout`.
- `TestConfigLoadsWorkflowTaskTimeout`: config loader returns default and env override.

#### 중간 검증

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

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

## 의존 관계 및 구현 순서

이 subtask directory는 `02+01_retry_timeout`이므로 `agent-task/workflow_core/01_lifecycle_foundation/complete.log`가 있어야 시작한다.

1. `WORKFLOW-1` metadata merge를 lifecycle API에 붙인다.
2. `WORKFLOW-2` timeout config와 worker timeout envelope를 붙인다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/workflow/model.go` | WORKFLOW-1 |
| `services/core/internal/workflow/lifecycle.go` | WORKFLOW-1 |
| `services/core/internal/workflow/service_test.go` | WORKFLOW-1 |
| `services/core/internal/config/config.go` | WORKFLOW-2 |
| `services/core/Makefile` | WORKFLOW-2 |
| `services/core/internal/scheduler/river.go` | WORKFLOW-2 |
| `services/core/internal/scheduler/jobs.go` | WORKFLOW-2 |
| `services/core/internal/scheduler/jobs_test.go` | WORKFLOW-2 |
| `services/core/cmd/server/main.go` | WORKFLOW-2 |

## 최종 검증

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

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

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