# Ticket Dispatcher 模块设计

## 1. 模块边界

Dispatch 实现在 `services/ticket-manager/internal/services/dispatch`，遵循 Service、Behavior、Repo、Converter 的现有分层。

补充设计：[Dispatch Triage Action 设计](./2026-07-14-dispatch-triage-action-design.md)。
Triage Action、triage 字段及相关执行语义以补充设计为准。

通知补充设计：[Dispatch Assignee Notification 设计](./2026-08-12-dispatch-assignee-notification-design.md)。
该文档描述 Assignee/L2 通知、ACK、page retry、Leader escalation、Trigger 收敛和长执行恢复的
实现语义。该功能由配置开关保护，默认关闭。

```text
internal/services/dispatch/
  api/                  # 内部 Service contract
  behavior/
    execution/          # Execution 状态机
      evaluator/        # Condition evaluator
      executor/         # ActionExecutor 与具体 Action
      issuefield/       # Issue field 读取
    policy/             # Policy 生命周期
  repo/                 # Policy、Context、Execution persistence
  service.go            # 依赖注入与组件组装
  trigger.go            # Trigger outbox 与 live/dry-run orchestration
  execution.go          # Execution query/run/recovery
```

Issue 读取复用 `internal/services/ticket/issueloader`；assign 的写操作复用 Ticket Service；Engineer、Core group 和 Oncall 通过各自接口访问，不跨 Service 直接导入业务实现。

## 2. 内部 Service API

```go
type DispatchTriggerService interface {
    Dispatch(ctx context.Context, param DispatchParam) (*dispatch.DispatchTrigger, error)
    GetDispatchTrigger(ctx context.Context, id uint64) (*dispatch.DispatchTrigger, error)
}

type ExecutionService interface {
    GetExecution(ctx context.Context, id uint64) (*dispatch.Execution, error)
    ListExecutions(ctx context.Context, param ListExecutionsParam) ([]*dispatch.Execution, int64, error)
}
```

`api.Service` 组合 Policy、DispatchContext 和 Execution 接口。

`DispatchParam` 包含 Source、IssueKey，以及可选的 PolicyID 和 `DispatchDryrunParam`。未指定 PolicyID 时按 Trigger 条件批量匹配 Policy；指定 PolicyID 时直接运行该 Policy。设置 `DispatchDryrunParam` 表示 dry-run；未指定 PolicyID 时会为所有匹配的 Policy 分别创建 dry-run Execution。

## 3. 依赖组装

`NewService` 注入：

- `transaction.Provider`；
- Jira client，用于构造 IssueLoader；
- Engineer Service；
- Ticket Service；
- Oncall Client；
- 可选时钟函数，仅用于 DispatchTrigger/Execution 生命周期与审计时间；Executor 的业务时间由 ExecutionContext 直接解析 OverwriteData 或系统时间。

Service 初始化 PolicyRepo、ContextRepo、ExecutionRepo 和 Executor Registry。Registry 以 Action type 查找具体 Executor。Service 同时作为 JobService 注册 `RunDispatchExecution` EventJob，通过内存 channel 接收 Execution ID。

## 4. Execution Orchestration

### 4.1 live

`Dispatch` 和 `RunDispatchTrigger` 的职责：

1. `Dispatch` 校验参数并持久化 pending DispatchTrigger，然后把 Trigger ID 写入 channel；
2. `RunDispatchTrigger` claim Trigger，并使用 IssueLoader 获取实时 Jira Issue；
3. 读取 DispatchContext；未指定 PolicyID 时按 project、active 和 enabled 条件匹配 Policies，指定 PolicyID 时直接读取该 Policy；
4. 在进程级 Execution 写锁内查询同一 Issue + Policy 的 `created` / `executing` live Execution；
5. 自动匹配时跳过忙碌 Policy；指定 Policy 时冲突令 Trigger failed；
6. 在短事务中创建 Execution、完成 Trigger Output；live 至少创建一条且 Context 缺失时持久化空的默认 Context；
7. 事务提交后把 `output.execution_ids` 写入 Execution channel。

`RunExecution` 从 Repo 重新加载 Execution，通过 `IssueKey` 加载实时 Jira Issue，通过 `PolicyID` 加载 Policy，再推进 Behavior loop 到终态。EventJob 使用 10 个 worker，不同 Execution 可以并行运行。

Behavior 将可预期的 Condition、Plan 或 Execute 错误转成该 Execution 的 failed 终态，因此单条业务失败不作为 fan-out 的整体取消信号。

Issue 或 Policy 加载失败不创建 Execution，并将失败原因记录在 DispatchTrigger；没有匹配 Policy 时 Trigger completed，`execution_ids` 为空且不更新 DispatchContext。

### 4.2 dry-run

指定 PolicyID 的 `Dispatch` 直接读取该 Policy、实时加载 Issue 和当前 DispatchContext，然后异步创建并运行一个 live 或 dry-run Execution。指定 Policy 不校验 Policy Trigger 或 Policy status；Context 仅用于构建 Action 初始快照。

dry-run 与 live 一样持久化并进入 channel，但不参与 live Execution 互斥；同一 Issue + Policy 可以并发存在多个 dry-run。dry-run 不持久化缺失的默认 Context。Behavior 在 dry-run 中跳过 Executor 的 Execute。

## 5. Behavior 状态机

Behavior 采用 Workspace 风格：每次 `NewBehavior` deep-copy origin Execution，根据 status 返回具体行为；`Proceed` 只推进一个状态阶段。

```text
created -> behaviorCreated -> executing
executing -> behaviorExecuting -> succeeded | failed
```

Service loop 持续用上一轮返回的 Execution 构造下一 Behavior，直到 `succeeded` 或 `failed`。

### 5.1 behaviorCreated

把 status 更新为 `executing`。

### 5.2 behaviorExecuting

- Execution 创建时已按 priority、Rule ID 排序并预建全部 RuleExecution、ActionExecution 和 Action Context；
- 只对 Outcome 为 `not_evaluated` 的 Rule 执行 Condition evaluator；
- When 成功求值后立即写 `matched` 或 `not_matched` Evaluation；
- `not_matched` 将该 Rule 的 pending Actions 标记为 `skipped`；
- matched 时按顺序对每个 Action 执行 `Plan -> Execute`；
- Behavior 在 Plan 前把预建 ActionExecution 从 `pending` 更新为 `executing` 并保存；
- Plan 原地更新同一个 ActionExecutionContext，Execute 消费同一指针，Behavior 保存 Context 和终态；
- Receiver 无候选作为 Plan error，把 ActionExecution 和 Execution 都记为 `failed`；
- Plan error 时由对应 ActionExecution 和 Execution failure 记录并失败，Outcome 保持 `matched`；
- live 和 dry-run 成功后都把 ActionExecution 标成 `succeeded`；
- 失败时把当前 ActionExecution 标成 `failed`，Execution 失败，Outcome 保持 When 的 `matched`；
- 进程重启后，在原位置重新执行状态为 `executing` 的 Action，不追加重复记录；
- Rule/Action 失败、terminate 或 Execution 完成时把后续 pending Actions 标记为 `skipped`，未访问 Rule 保持 `not_evaluated`；
- 全部 Rule 完成后把 Execution 标成 `succeeded`。

已完成 Action 不回滚。

## 6. Condition Evaluator

Evaluator 是无状态组件：

```go
Evaluate(condition dispatch.ConditionExpr, issue *ticket.JiraIssue) (matched bool, reason string, err error)
```

它递归处理 `all`、`any`、`not`、`always` 和叶子表达式。字段映射只包含：

```text
project_key, environment, reporter_email, created_at,
components, component, primary_component, summary
```

集合正向操作使用 any-match；`ne` 和 `not_in` 对 any-match 取反。Issue 指针字段为 nil 或 Components 为空时视为字段缺失。

Policy Validator 与 `/dispatch/options` 使用同一白名单。Validator 同时编译 regex、加载 IANA timezone、校验 weekday 和 `start < end`，避免 active Policy 在运行期出现可预防的表达式错误。

## 7. Receiver 与 Engineer 查询

Engineer 内部查询参数增加：

```go
type ListEngineersParam struct {
    EmailIn           []string
    DispatchEnabledIs *bool
    // ...
}
```

Receiver resolver 始终传入 `DispatchEnabledIs=true`：

- user/users 设置 `EmailIn`；
- group 由 AssignExecutor 通过 Core group 获取用户，再设置 `EmailIn`；
- oncall_team 先调用 Oncall responder API，再设置 `EmailIn`。

配置 email 的返回顺序会在 resolver 中恢复并去重；最终 Strategy 自行排序选择。

Engineer dispatch state 使用单条 `UPDATE` 原子修改指定列，不做 read-modify-write，避免并发 assign 丢失其它 dispatch state 字段。

## 8. ActionExecutor

```go
type ActionExecutor interface {
    Type() dispatch.ActionType
    BuildContext(issue *ticket.JiraIssue, dispatchContext *dispatch.DispatchContext, action dispatch.Action) (*dispatch.ActionExecutionContext, error)
    Plan(ctx context.Context, execution *dispatch.Execution, action dispatch.Action, actionContext *dispatch.ActionExecutionContext) error
    Execute(ctx context.Context, execution *dispatch.Execution, action dispatch.Action, actionContext *dispatch.ActionExecutionContext) error
}
```

### 8.1 AssignExecutor

Assign 与 Assign L2 分别读取 `action.assign` 和 `action.assign_l2`，并共用 AssignExecutor。BuildContext 初始化 `context.assign`。Plan：

- resolve Receiver；
- 生成 candidate emails；
- 空列表在 Context 中保留原因并返回 error；
- 应用 first/round-robin；
- 在 Assign Context 中写 assignee 与 reason。

Execute：

- Assign 更新 Jira Issue 的标准 Assignee；
- Assign L2 按邮箱查询 Jira 用户，使用其 `accountId` 更新 `customfield_10053`；
- Jira 更新成功后调用 `Engineer.UpdateDispatchState`，同时设置 LastDispatchedAt 和 LastDispatchedIssue；
- 最后清理 Jira Issue 上的自动分配 Label，清理失败仅记录日志。

Jira 更新失败时不更新 Engineer；Engineer 更新失败时保留已经完成的 Jira 分配，并按 Action 失败处理。

## 9. Persistence

### 9.1 dispatch_executions

主要列：

| Column | 说明 |
| --- | --- |
| `issue_key` | Jira Issue key |
| `mode` | live / dry_run |
| `policy_id` | 执行的 Policy ID |
| `status` | created / executing / succeeded / failed |
| `failure_code` | 稳定失败码 |
| `failure_reason` | 诊断原因 |
| `completed_at` | 终态时间 |

查询索引覆盖 Issue、status，以及 `(issue_key, policy_id, mode, status)` 运行态查询。

### 9.2 dispatch_rule_executions

以 `(execution_id, sequence)` 唯一。ExecutionRepo 创建 Execution 时聚合保存全部预建 RuleExecutions。`action_executions_json` 保存有序 ActionExecution 数组，不拆 ActionExecution 表。ActionExecution 保存 index、`pending` / `executing` / `succeeded` / `failed` / `skipped` status 和嵌套 Context；Assign 与 Assign L2 的 assignee、candidates 和 reason 均位于 `context.assign`。旧数据不做运行时兼容。

Repo 在读取 Execution 列表或详情后，按 execution ID、sequence 批量附加 RuleExecutions。

### 9.3 dispatch_contexts

保留 Assignee 和 AssigneeL2；两个字段未分配时均为空字符串，AssignExecutor 不再更新这两个字段。

## 10. Transaction Boundary

只在以下位置使用显式事务：

- 一次 live fan-out 的空闲 Policy Execution 聚合创建和缺失 Context 创建；
- 单 Policy live/dry-run Execution 的聚合创建；
- 既有 Policy 写路径需要的短事务。

Behavior 的 Execution、Evaluation 更新分别是短数据库操作。Condition 计算、Core、Oncall、Jira、Ticket 和 Engineer 调用期间不持有事务。

## 11. 互斥、恢复与并发

同一进程内，Execution 写锁串行化 live 检查与创建。同一 `(issue_key, policy_id)` 最多一个状态为 `created` 或 `executing` 的 live Execution；自动匹配 Dispatch 跳过忙碌 Policy，指定 Policy 的 live Dispatch 冲突时将 Trigger 标记为 failed。dry-run 不参与互斥。

EventJob 的 Dedupe key 是 Execution ID，同一进程内相同 ID 在处理中时不并发重复运行；不同 ID 最多 10 路并发。每条 Execution 独立重新加载 Issue 和 Policy，一个 Execution 的失败不 cancel 其他 Execution。

channel 和 Dedupe 都是进程内机制。服务启动时按创建时间顺序查询全部 `created` / `executing` Execution（包括 dry-run），并异步重新写入 channel；恢复查询失败会阻止服务启动。v1 不协调不同 Policy 的 Jira assignment 写入，也不提供周期 Retry、跨副本 claim、child key 或 Action-level idempotency。

## 12. Handler 与 Contract

外部 contract 位于：

- `packages/sdk-go/contracts/entity/ticket-manager/dispatch`；
- `packages/sdk-go/contracts/api/ticket-manager/dispatch`。

Handler 注册：

```text
POST /dispatch/triggers
GET  /dispatch/triggers/:id
GET  /dispatch/executions
GET  /dispatch/executions/:id
```

Trigger POST 请求包含 `source`、`input.issue_key`，并可携带 `policy_id` 和 `dryrun`，response 是 pending DispatchTrigger。dry-run 可通过 `dryrun.overwrite_data.now` 提供 RFC3339 时间；创建 Execution 时会冻结并持久化该业务运行时钟，供异步执行和恢复流程复用。调用方查询 Trigger，completed 后从 `output.execution_ids` 获取 Execution ID。Execution 响应返回 OverwriteData 以及预建的 Rule/Action 快照；List 可按 IssueKey、status 过滤及分页。

Swagger 从 Handler annotation 生成，TypeScript SDK 从 ticket-manager Swagger 生成，不维护手写重复类型。

## 13. 验证

测试分层：

- evaluator：字段、Operator、嵌套、集合、缺失字段、regex、timezone boundary；
- executor：四 Receiver、候选排序、BuildContext 快照和 Plan 原地更新；
- behavior：状态转换、not_evaluated、pending/skipped、错误停止、Context 持久化、恢复和不补偿；
- service：Context 默认值、Policy project 匹配、预建排序、事务回滚、创建后按序入队、EventJob 并发、live 互斥、dry-run 和启动恢复；
- repo/migrate：聚合 JSON round-trip、Evaluation 顺序和 `input_json` 删除；
- handler：binding 与 response；
- contract：Swagger 生成、TypeScript typecheck。

测试从 `services/ticket-manager` 以 `APP_ENV=local` 和独立 `GOCACHE` 运行。
