# AI Job 状态机与任务调度设计

## 1. Job 类型

| jobType | 说明 | 输入 | 输出 |
|---------|------|------|------|
| `learning_state_analysis` | 学习状态分析 | Snapshot | AiLearningAnalysis |
| `weak_point_analysis` | 薄弱点分析 | Snapshot | WeakPointCandidate[] |
| `next_action_planning` | 下一步建议 | Snapshot | NextActionRecommendation[] |
| `quiz_generation` | 题目候选生成 | Snapshot + params | QuizQuestion[] |
| `flashcard_generation` | 卡片候选生成 | Snapshot + params | Flashcard[] |

## 2. 状态定义

| 状态 | 含义 | 进入条件 | 退出条件 |
|------|------|----------|----------|
| `pending` | 等待消费 | API 创建 or retryable fail 回退 | 被 Runtime lock |
| `locked` | 已被 Runtime 获取 | Runtime POST /lock 成功 | lockUntil 超时 → expired / Runtime 开始执行 |
| `running` | 正在执行 | Runtime 开始执行（heartbeat 或隐式） | 执行完成 → succeeded/failed |
| `succeeded` | 执行成功 | API POST /result 处理完毕 | 终态 |
| `failed` | 执行失败 | non-retryable 错误 or 超过 maxRetryCount | 终态（除非 Admin 重跑） |
| `cancelled` | 已取消 | 用户/Admin 取消 pending job | 终态 |
| `expired` | 超时 | lockUntil 超时未 heartbeat or 执行超时 | 可被 Runtime 重新 poll（retryable） |

## 3. 状态流转

```
                     ┌──────────┐
                     │  pending │ ←──────────────────────┐
                     └────┬─────┘                        │
                          │                              │
                    POST /lock                           │
                          │                              │
                     ┌────▼─────┐                        │
                ┌───→│  locked   │──→ expired ───────────┘
                │    └────┬─────┘     (lockUntil 超时)
                │         │
                │    heartbeat
                │         │
                │    ┌────▼─────┐
                │    │  running  │──→ expired ───────────┘
                │    └────┬─────┘     (timeoutSeconds 超时)
                │         │
        ┌───────┼─────────┼──────────┐
        │       │         │          │
   succeeded  failed   failed    cancelled
   (result)   (non-    (retry-    (用户/Admin
              retry)   able       取消pending)
                       │
                       └──→ pending (retryCount++)
```

## 4. 数据库字段

```prisma
model AiRuntimeJob {
  id                  String   @id @default(cuid())
  userId              String
  jobType             String   // learning_state_analysis | weak_point_analysis | next_action_planning | quiz_generation | flashcard_generation
  targetType          String   // user | material | knowledge_point
  targetId            String
  snapshotId          String?
  status              String   @default("pending") // pending | locked | running | succeeded | failed | cancelled | expired
  priority            Int      @default(0) // 0=最高
  idempotencyKey      String?  @unique
  apiKeyMode          String   @default("platform_key") // platform_key | user_deepseek_key
  credentialId        String?
  modelProvider       String   @default("deepseek")
  modelName           String   @default("deepseek-chat")
  promptVersion       String?
  outputSchemaVersion String?
  attemptNo           Int      @default(0)
  retriedFromJobId    String?

  // 锁定
  lockedBy            String?  // runtimeInstanceId
  lockedAt            DateTime?
  lockUntil           DateTime?

  // 时间
  startedAt           DateTime?
  finishedAt          DateTime?

  // 重试
  retryCount          Int      @default(0)
  maxRetryCount       Int      @default(3)
  timeoutSeconds      Int      @default(120)

  // 错误
  errorCode           String?
  errorMessage        String?

  createdAt           DateTime @default(now())
  updatedAt           DateTime @updatedAt

  result              AiRuntimeResult?

  @@index([status])
  @@index([jobType])
  @@index([userId])
  @@index([targetType, targetId])
  @@index([lockUntil])
}
```

## 5. 锁定机制

### 5.1 Lock 流程

```
Runtime POST /internal/runtime/jobs/{jobId}/lock
  → API 检查 job.status === pending
  → API 检查 job.lockUntil < now (未被其他 Runtime 持有)
  → API 设置 lockedBy, lockedAt, lockUntil=now+60s, status=locked
  → 返回 lockUntil
```

### 5.2 防并发

基于数据库行级写操作保证只有一个 Runtime 锁定成功：
- `UPDATE ... WHERE status='pending' AND (lockUntil IS NULL OR lockUntil < NOW())`
- 影响行数 = 0 则锁定失败（JOB_ALREADY_LOCKED）

### 5.3 Heartbeat

```
Runtime POST /internal/runtime/jobs/{jobId}/heartbeat
  → API 检查 lockedBy === runtimeInstanceId
  → API 延长 lockUntil = now + 60s
  → 204 No Content
```

### 5.4 超时释放

`lockUntil` 超时后：
- 原 Runtime 的 lock 失效
- job 状态变为 `expired`
- 其他 Runtime poll 时可重新获取（retryable）
- 如 retryCount < maxRetryCount，job 自动回到 `pending`

## 6. 重试策略

### 6.1 重试触发

| 场景 | 处理 |
|------|------|
| Runtime 提交 retryable fail | job → pending, retryCount++ |
| Runtime lock 后无 heartbeat 超时 | job → expired → pending, retryCount++ |
| Runtime 执行超时 | job → expired → pending, retryCount++ |

### 6.2 重试上限

- `retryCount >= maxRetryCount`：job → failed（终态）
- `maxRetryCount` 默认 3，可配置
- Admin 可手动重跑 failed job（创建新 job，记录 retriedFromJobId）

### 6.3 retryable vs non-retryable

| 错误类型 | retryable | 示例 |
|---------|-----------|------|
| MODEL_TIMEOUT | true | DeepSeek 超时 |
| MODEL_RATE_LIMIT | true | 限流 |
| NETWORK_ERROR | true | 网络中断 |
| TEMPORARY_PROVIDER_ERROR | true | 5xx |
| INVALID_SNAPSHOT | false | 快照结构错 |
| INVALID_SCHEMA | false | 输出 schema 错 |
| INVALID_CREDENTIAL | false | Key 无效 |
| JOB_TIMEOUT | true | 执行超时 |

## 7. 超时

| 超时类型 | 默认值 | 说明 |
|---------|--------|------|
| lockUntil | 60s | lock 后未 heartbeat 自动释放 |
| timeoutSeconds | 120s | 总执行超时 |
| heartbeat 间隔 | Runtime 自行决定 | 建议 15-30s |

## 8. 幂等

### 8.1 Job 创建幂等

`idempotencyKey` 唯一索引：相同 `userId + jobType + targetType + targetId + idempotencyKey` 的 job 不重复创建。如果没有传 idempotencyKey，则允许重复创建。

### 8.2 Result 提交幂等

```
resultIdempotencyKey = jobId + ":" + attemptNo + ":" + outputHash
```

- 相同 key 重复提交：返回 200（幂等，不重复落库）
- 已有 succeeded result 但 outputHash 不同：返回 409 RESULT_ALREADY_EXISTS

### 8.3 Admin 重跑

Admin 重跑创建新 job，记录 `retriedFromJobId`，不复用旧 job。

## 9. Cancelled / Expired

| 状态 | 能否被 Runtime 消费 | 处理 |
|------|-------------------|------|
| cancelled | 否 | API 直接设置，不进入 poll 结果 |
| expired | 是（如 retryable） | lockUntil 超时后自动变为 expired，retryable 时回到 pending |

用户关闭 AI 授权时：
- 所有 pending job → cancelled
- 所有 running job → cancelRequested（Runtime 下次 heartbeat 获知）

## 10. 任务调度

### Poll 规则

```
POST /internal/runtime/jobs/poll
  → 返回 status=pending 的 job
  → 按 priority ASC, createdAt ASC 排序
  → 只返回 Runtime capabilities 支持的 jobType
  → limit 最大 50
```

### 无可用 job 时

返回空数组。Runtime 按 pollIntervalMs 等待后重试。

## 11. 验收清单

- [x] 输出 Job 状态机设计文档
- [x] 明确每个状态的进入条件和退出条件
- [x] 明确 Runtime 如何锁定任务（DB 行级写 + lockUntil）
- [x] 明确 lockUntil 超时后如何释放
- [x] 明确 retryCount / maxRetryCount 规则
- [x] 明确 idempotencyKey 防重复
- [x] 明确 Admin 可重跑 failed job
- [x] 明确 cancelled / expired 不应被 Runtime 再次消费
