mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
686 lines
23 KiB
Markdown
686 lines
23 KiB
Markdown
# ADR-0001:Run 状态模型与事务边界
|
||
|
||
- 状态:Proposed
|
||
- 日期:2026-07-18
|
||
- 决策者:QingLong Maintainers
|
||
- 关联 RFC:[QL-RFC-0001](../QINGLONG_3_0_ARCHITECTURE_RFC.md)
|
||
- 目标版本:QingLong 3.0
|
||
|
||
## 1. 决策摘要
|
||
|
||
QingLong 3.0 使用 `Run` 作为一次用户可见运行的聚合根,使用 `RunAttempt` 表示每次实际派发和执行尝试,使用 `StepRun` 表示 Workflow/Agent 步骤,并通过只追加的 `RunEvent` 记录状态变化。
|
||
|
||
Run 当前状态更新、乐观并发版本递增和 RunEvent 追加必须处于同一数据库事务。终态不可修改;人工重试创建新 Run,自动重试在原 Run 下创建新 RunAttempt。
|
||
|
||
`Crontab.status`、内存队列、PID、系统 crontab 和 Worker 本地状态均不是 3.0 的最终事实源。
|
||
|
||
## 2. 上下文
|
||
|
||
2.x 的状态由多个位置共同表达:
|
||
|
||
- `Crontab.status` 保存任务最近状态。
|
||
- `RunningInstance` 保存 PID、日志和实例退出结果。
|
||
- Shell 在任务开始和结束时调用 `/open/crons/status`。
|
||
- 调度器和手动运行路径分别维护进程与队列状态。
|
||
- 服务启动时将所有 Crontab 设为空闲,并将所有 running instance 设为 stopped。
|
||
|
||
当前实现存在以下边界问题:
|
||
|
||
1. `RunningInstance` 创建/完成和 `Crontab` 更新不是同一事务。
|
||
2. 回调主要依靠 Cron ID、PID 和 log path 定位实例,多实例和重复回调存在歧义。
|
||
3. 任务定义同时承载最新运行状态,无法准确表达并发 Run。
|
||
4. 服务重启通过批量重置状态恢复,不能区分实际仍在运行、已经失败或失联。
|
||
5. 自动重试、人工重试、Workflow Step 和远程 Worker 缺少统一语义。
|
||
6. 文本日志可以说明发生了什么,但不能可靠重建状态转换顺序。
|
||
|
||
3.0 需要在 SQLite edge/standalone 与 PostgreSQL cluster-control 上保持相同领域语义。
|
||
|
||
## 3. 术语
|
||
|
||
### 3.1 Run
|
||
|
||
一次用户、Trigger、API、Workflow 或 Agent 发起的端到端运行。Run 代表用户意图和最终结果。
|
||
|
||
### 3.2 RunAttempt
|
||
|
||
Run 的一次实际执行尝试。每次调度到 Executor/Worker 都创建新的 Attempt。Attempt 保存 executor handle、PID、lease、exit code 和日志引用。
|
||
|
||
### 3.3 StepRun
|
||
|
||
Workflow 或 Agent 内的一个逻辑步骤。简单 Script/Command Task 可以没有显式 StepRun。
|
||
|
||
### 3.4 RunEvent
|
||
|
||
Run 聚合发生的不可变事实。RunEvent 用于审计、实时事件、恢复判断和投影,但 3.0 首版不采用完整 Event Sourcing。
|
||
|
||
### 3.5 Projection
|
||
|
||
由 Run/Attempt/Event 派生的兼容或查询状态,例如 `Crontab.status`、Dashboard 计数和日统计。
|
||
|
||
## 4. 聚合关系
|
||
|
||
```mermaid
|
||
erDiagram
|
||
TASK_DEFINITION ||--o{ RUN : starts
|
||
RUN ||--o{ RUN_ATTEMPT : executes
|
||
RUN ||--o{ STEP_RUN : contains
|
||
RUN ||--o{ RUN_EVENT : records
|
||
STEP_RUN ||--o{ RUN_ATTEMPT : may_execute
|
||
RUN_ATTEMPT ||--o{ ARTIFACT : produces
|
||
|
||
RUN {
|
||
string id PK
|
||
string status
|
||
integer version
|
||
string task_id
|
||
integer task_revision
|
||
string trigger_type
|
||
string retry_of_run_id
|
||
}
|
||
|
||
RUN_ATTEMPT {
|
||
string id PK
|
||
string run_id FK
|
||
integer attempt
|
||
string status
|
||
string executor_type
|
||
string worker_id
|
||
string executor_handle
|
||
string lease_token
|
||
}
|
||
|
||
RUN_EVENT {
|
||
string id PK
|
||
string run_id FK
|
||
integer sequence
|
||
string type
|
||
string dedupe_key
|
||
json payload
|
||
}
|
||
```
|
||
|
||
## 5. 标识与时间
|
||
|
||
### 5.1 标识
|
||
|
||
- Run、RunAttempt、StepRun、RunEvent 使用 UUIDv7 字符串 ID。
|
||
- 采用现有 `uuid` 依赖提供的 v7 实现,避免新增仅用于 ID 的运行时依赖。
|
||
- ID 不承载授权、Project 或业务语义。
|
||
- API 将 ID 作为不透明字符串处理。
|
||
|
||
UUIDv7 提供时间排序友好性,但业务排序仍使用显式时间和 sequence,不能依赖 ID 作为唯一顺序来源。
|
||
|
||
### 5.2 时间
|
||
|
||
- 数据库存储 UTC 毫秒时间戳。
|
||
- API 对外返回 RFC 3339 字符串,并在需要时提供 duration。
|
||
- 运行时长使用单调时钟测量后写入结果,不能仅依赖墙上时钟相减。
|
||
- 2.x 秒级时间迁移时显式乘以 1000,禁止混合单位。
|
||
|
||
## 6. Run 状态机
|
||
|
||
### 6.1 状态
|
||
|
||
```text
|
||
created
|
||
queued
|
||
dispatching
|
||
running
|
||
waiting_approval
|
||
retry_wait
|
||
succeeded
|
||
failed
|
||
cancelled
|
||
timed_out
|
||
lost
|
||
```
|
||
|
||
终态:
|
||
|
||
```text
|
||
succeeded
|
||
failed
|
||
cancelled
|
||
timed_out
|
||
```
|
||
|
||
`lost` 是需要协调的非终态。协调器必须根据幂等性、RetryPolicy 和 Executor 检查结果将其转换为 `queued`、`failed` 或 `cancelled`。
|
||
|
||
### 6.2 合法转换
|
||
|
||
| 当前状态 | 允许目标状态 |
|
||
| --- | --- |
|
||
| created | queued、cancelled |
|
||
| queued | dispatching、cancelled、timed_out |
|
||
| dispatching | running、retry_wait、failed、cancelled、lost |
|
||
| running | waiting_approval、retry_wait、succeeded、failed、cancelled、timed_out、lost |
|
||
| waiting_approval | running、cancelled、timed_out |
|
||
| retry_wait | queued、cancelled、timed_out |
|
||
| lost | queued、failed、cancelled |
|
||
| succeeded | 无 |
|
||
| failed | 无 |
|
||
| cancelled | 无 |
|
||
| timed_out | 无 |
|
||
|
||
### 6.3 终态规则
|
||
|
||
- 终态不可转换为其他状态。
|
||
- 用户对失败/取消/超时 Run 执行“重试”时创建新 Run,并设置 `retry_of_run_id`。
|
||
- 自动重试不结束顶层 Run;失败 Attempt 后 Run 进入 `retry_wait`,随后创建下一 Attempt。
|
||
- 简单任务的 Run 在成功 Attempt 后进入 `succeeded`。
|
||
- Workflow Run 由 Workflow Runtime 根据必需 StepRun 的结果决定终态。
|
||
|
||
## 7. RunAttempt 状态机
|
||
|
||
### 7.1 状态
|
||
|
||
```text
|
||
claimed
|
||
starting
|
||
running
|
||
succeeded
|
||
failed
|
||
cancelled
|
||
timed_out
|
||
lost
|
||
```
|
||
|
||
所有 `succeeded`、`failed`、`cancelled`、`timed_out`、`lost` Attempt 都是不可变终态。Run 可以在 lost Attempt 后创建新的 Attempt。
|
||
|
||
### 7.2 合法转换
|
||
|
||
| 当前状态 | 允许目标状态 |
|
||
| --- | --- |
|
||
| claimed | starting、cancelled、lost |
|
||
| starting | running、failed、cancelled、timed_out、lost |
|
||
| running | succeeded、failed、cancelled、timed_out、lost |
|
||
|
||
Attempt 终态不允许被迟到回调覆盖。例如 Attempt 已标记 `cancelled` 后收到 exit code 0,只追加 `attempt.late_callback_ignored` 事件,不将其改为 succeeded。
|
||
|
||
## 8. 持久化模型
|
||
|
||
以下字段是 ADR 级概念约束,具体数据库 schema、类型映射和 migration 由实现 PR 确定。领域服务不得依赖 Sequelize Model API;SQLite 与 PostgreSQL 的持久化适配由后续 Repository ADR 决定。
|
||
|
||
### 8.1 Run
|
||
|
||
必要字段:
|
||
|
||
```text
|
||
id
|
||
project_id
|
||
task_id
|
||
task_revision
|
||
legacy_cron_id nullable
|
||
parent_run_id nullable
|
||
retry_of_run_id nullable
|
||
trigger_id nullable
|
||
trigger_type
|
||
status
|
||
version
|
||
priority
|
||
idempotency_key nullable
|
||
input_ref nullable
|
||
output_ref nullable
|
||
created_at_ms
|
||
queued_at_ms nullable
|
||
started_at_ms nullable
|
||
finished_at_ms nullable
|
||
cancel_requested_at_ms nullable
|
||
cancel_reason nullable
|
||
error_code nullable
|
||
error_summary nullable
|
||
```
|
||
|
||
索引和约束:
|
||
|
||
- 主键 `id`。
|
||
- `(project_id, created_at_ms)` 查询索引。
|
||
- `(task_id, created_at_ms)` 查询索引。
|
||
- `(status, queued_at_ms)` 调度/恢复索引。
|
||
- `(status, cancel_requested_at_ms)` 用于有界恢复尚未完成的取消请求。
|
||
- `(project_id, idempotency_key)` 条件唯一索引;无 key 时不限制。
|
||
- `version >= 0`。
|
||
|
||
### 8.2 RunAttempt
|
||
|
||
必要字段:
|
||
|
||
```text
|
||
id
|
||
run_id
|
||
step_run_id nullable
|
||
attempt
|
||
status
|
||
executor_type
|
||
worker_id nullable
|
||
executor_handle nullable
|
||
pid nullable
|
||
log_artifact_id nullable
|
||
lease_token nullable
|
||
lease_expires_at_ms nullable
|
||
deadline_at_ms nullable
|
||
callback_token_hash nullable
|
||
callback_sequence
|
||
created_at_ms
|
||
started_at_ms nullable
|
||
finished_at_ms nullable
|
||
exit_code nullable
|
||
error_code nullable
|
||
error_summary nullable
|
||
```
|
||
|
||
索引和约束:
|
||
|
||
- `(run_id, attempt)` 唯一。
|
||
- `(status, created_at_ms)` 用于发现已 claim 但未 start 的陈旧 Attempt。
|
||
- `(status, deadline_at_ms, id)` 用于有界扫描跨重启仍未完成的 timeout;null 表示该 Attempt 没有 deadline。
|
||
- `attempt >= 1`,`callback_sequence >= 0`。
|
||
- `lease_token` 不作为 Secret,但必须不可预测且每次 claim 更新。
|
||
- 不使用 PID 作为跨节点唯一标识。
|
||
|
||
### 8.3 RunEvent
|
||
|
||
必要字段:
|
||
|
||
```text
|
||
id
|
||
run_id
|
||
sequence
|
||
type
|
||
dedupe_key nullable
|
||
actor_type
|
||
actor_id nullable
|
||
attempt_id nullable
|
||
step_run_id nullable
|
||
payload
|
||
created_at_ms
|
||
```
|
||
|
||
索引和约束:
|
||
|
||
- `(run_id, sequence)` 唯一。
|
||
- `(run_id, dedupe_key)` 条件唯一。
|
||
- payload 有大小上限,不存完整日志、Prompt、模型结果或 Secret。
|
||
- 大内容写入 ArtifactStore,Event 仅保存引用和摘要。
|
||
|
||
### 8.4 状态字符串
|
||
|
||
状态以小写字符串持久化,不沿用 2.x 数字枚举。原因:
|
||
|
||
- 数据库和审计记录可直接阅读。
|
||
- 新状态演进不依赖枚举数字顺序。
|
||
- SQLite 与 PostgreSQL 保持一致;首版不使用数据库专有 enum。
|
||
|
||
## 9. 事务边界
|
||
|
||
### 9.1 核心规则
|
||
|
||
以下操作必须在同一事务完成:
|
||
|
||
1. 读取并验证 Run 当前 status/version。
|
||
2. 验证状态转换是否合法。
|
||
3. 同时递增 Run `version` 和 `event_sequence`。
|
||
4. 以 `id + expected version` compare-and-set 更新 Run。
|
||
5. 使用新 `event_sequence` 追加 RunEvent。
|
||
6. Attempt 转换还需以 `id + expected status + expected callback_sequence` compare-and-set 更新 RunAttempt。
|
||
|
||
不允许先发布 WebSocket/SSE、再提交数据库。Event Stream 只能发送已提交的 RunEvent。
|
||
|
||
### 9.2 转换伪代码
|
||
|
||
```ts
|
||
async function transitionRun(command: TransitionRunCommand): Promise<Run> {
|
||
return runRepository.transaction(async (transaction) => {
|
||
const current = await transaction.findRunById(command.runId);
|
||
|
||
if (!current) throw new RunNotFound(command.runId);
|
||
const decision = stateMachine.transitionRun(current, command);
|
||
const updated = await transaction.compareAndSetRun(
|
||
decision.run,
|
||
command.expectedVersion,
|
||
);
|
||
|
||
if (!updated) throw new RunConflict(command.runId, command.expectedVersion);
|
||
|
||
await transaction.appendEvent({
|
||
id: uuidV7(),
|
||
runId: command.runId,
|
||
sequence: decision.event.sequence,
|
||
type: decision.event.type,
|
||
dedupeKey: command.dedupeKey ??
|
||
`run-transition:${command.expectedVersion}:${command.to}`,
|
||
actorType: command.actor.type,
|
||
payload: decision.event.payload,
|
||
createdAtMs: command.atMs,
|
||
});
|
||
|
||
return decision.run;
|
||
});
|
||
}
|
||
```
|
||
|
||
RunAttempt 转换遵循同一聚合事务:先 CAS Run 的 `version/event_sequence`,再 CAS RunAttempt,最后追加 Event;任一步失败均回滚整个事务。Repository 只提供原子存储语义,Controller、Scheduler、Worker 和兼容层不得自行拼接状态更新。
|
||
|
||
### 9.3 SQLite
|
||
|
||
- 使用现有 `IMMEDIATE` transaction 避免开始写入后才升级锁。
|
||
- 事务必须短,不在事务中 spawn、调用模型、写日志文件或请求外部服务。
|
||
- 发生 `SQLITE_BUSY` 时使用现有有界 retry 策略。
|
||
- CAS 更新数量为 0 时返回领域冲突,不盲目重试未知命令。
|
||
|
||
### 9.4 PostgreSQL
|
||
|
||
- 使用相同的 version compare-and-set 领域语义。
|
||
- 可以使用行锁优化,但不能让 PostgreSQL 实现产生不同状态规则。
|
||
- claim/lease 的具体锁策略由 Remote Worker ADR 决定。
|
||
|
||
### 9.5 外部副作用
|
||
|
||
spawn、容器创建、MCP Tool 和通知等外部副作用不能与数据库事务原子提交。采用 command/event 协调:
|
||
|
||
1. 事务提交 `run.dispatching` 和待执行命令标识。
|
||
2. Dispatcher 执行外部副作用。
|
||
3. 使用 dedupe key 回写 `attempt.starting/running` 或失败事件。
|
||
4. Reconciler 查找长期停留在 dispatching 的 Run 并检查/补偿。
|
||
|
||
首版可以使用数据库表作为可靠命令来源,不强制引入外部消息队列。
|
||
|
||
## 10. 幂等性与重复回调
|
||
|
||
### 10.1 创建 Run
|
||
|
||
- API/Trigger 可以提供 idempotency key。
|
||
- 同一 Project 下相同 key 返回已有 Run,不创建重复 Run。
|
||
- Trigger 推荐使用 `trigger_id + scheduled_fire_time` 生成稳定 key。
|
||
|
||
### 10.2 状态命令
|
||
|
||
- 每个状态命令可以携带 dedupe key。
|
||
- Executor 回调使用 `attempt_id + callback_type + executor_event_id`。
|
||
- 同一 dedupe key 已提交时返回当前状态,不追加重复 Event。
|
||
- 不同命令同时更新同一 version 时只有一个成功,另一个收到 RunConflict 并重新读取。
|
||
|
||
### 10.3 Shell 回调
|
||
|
||
迁移完成后,Shell 环境必须获得:
|
||
|
||
```text
|
||
QL_RUN_ID
|
||
QL_ATTEMPT_ID
|
||
QL_CALLBACK_TOKEN
|
||
```
|
||
|
||
状态 API 根据 Run/Attempt ID 和短期 callback token 鉴权。Cron ID、PID 和 log path 仅用于兼容和诊断,不能作为最终关联键。
|
||
|
||
Shadow 阶段允许通过 `legacy_cron_id + pid` 辅助关联,但存在歧义时只记录兼容告警,不猜测并更新错误 Run。
|
||
|
||
## 11. Event 命名
|
||
|
||
首批事件:
|
||
|
||
```text
|
||
run.created
|
||
run.queued
|
||
run.dispatching
|
||
run.running
|
||
run.cancel_requested
|
||
run.retry_wait
|
||
run.waiting_approval
|
||
run.succeeded
|
||
run.failed
|
||
run.cancelled
|
||
run.timed_out
|
||
run.lost
|
||
|
||
attempt.claimed
|
||
attempt.starting
|
||
attempt.running
|
||
attempt.succeeded
|
||
attempt.failed
|
||
attempt.cancelled
|
||
attempt.timed_out
|
||
attempt.lost
|
||
attempt.late_callback_ignored
|
||
|
||
run.legacy_projection_mismatch
|
||
run.reconciled
|
||
```
|
||
|
||
命名规则:
|
||
|
||
- 使用 `aggregate.past_tense`。
|
||
- Event 表示已发生事实,不使用命令式名称。
|
||
- Event schema 有版本;破坏性 payload 变更创建新版本或新事件类型。
|
||
- payload 仅包含消费者所需的稳定数据和引用。
|
||
|
||
## 12. Actor 与错误
|
||
|
||
### 12.1 Actor
|
||
|
||
每个转换记录 Actor:
|
||
|
||
```text
|
||
user
|
||
api_app
|
||
trigger
|
||
agent
|
||
mcp_client
|
||
worker
|
||
executor
|
||
system
|
||
legacy_shell
|
||
scheduler
|
||
reconciler
|
||
compatibility
|
||
```
|
||
|
||
System/Reconciler 行为也必须有 Actor,不能生成来源不明的状态变化。
|
||
|
||
### 12.2 错误
|
||
|
||
Run/Attempt 保存稳定 `error_code` 和脱敏 `error_summary`。完整堆栈、stderr 和外部响应写入受保留策略控制的 Artifact。
|
||
|
||
错误分类至少包含:
|
||
|
||
```text
|
||
validation_error
|
||
dispatch_error
|
||
executor_start_error
|
||
process_exit_error
|
||
cancelled_by_user
|
||
timeout
|
||
worker_lost
|
||
policy_denied
|
||
approval_rejected
|
||
internal_error
|
||
```
|
||
|
||
## 13. 恢复与协调
|
||
|
||
### 13.1 启动恢复
|
||
|
||
3.0 不允许在启动时无条件把所有 Run 设为 idle/stopped。
|
||
|
||
Reconciler 对非终态 Run/Attempt:
|
||
|
||
1. 根据 executor type 查找 Executor。
|
||
2. 使用 executor handle/lease 调用 inspect。
|
||
3. 可以确认运行时恢复为 running 并追加 reconciled 事件。
|
||
4. 可以确认结束时写入对应终态。
|
||
5. 无法确认且 lease 超时时标记 Attempt lost。
|
||
6. 根据 RetryPolicy 和幂等性决定 Run queued 或 failed。
|
||
|
||
### 13.2 LocalProcess 限制
|
||
|
||
仅凭 PID 不足以证明进程身份,PID 可能被复用。LocalExecutor handle 至少包含 PID、进程启动时间和平台可用的命令/进程组信息。
|
||
|
||
如果重启后无法可靠证明进程身份,保守标记 Attempt lost,不向未知 PID 发送 signal。
|
||
|
||
当前 PR-5 孵化实现对 Linux 使用 `/proc/sys/kernel/random/boot_id` 与 `/proc/<pid>/stat` 的 start ticks、process group 共同形成有界 durable handle,并单独核对 Attempt.pid。handle 不保存命令、环境、工作目录或 Secret。任一字段不匹配、token 无效、平台不支持或证据缺失时都不能认领或终止该 PID;非 Linux 平台当前保守进入 lost,而不是降级成 PID-only 恢复。
|
||
|
||
### 13.3 延迟回调
|
||
|
||
Attempt 终态后到达的回调不改变状态。系统追加低敏摘要事件并记录指标,供诊断 Shell/Worker 重复或乱序回调。
|
||
|
||
### 13.4 Durable cancellation
|
||
|
||
取消命令以 Run 为 CAS 序列化边界,并遵循“持久化先于 signal”:
|
||
|
||
1. 事务读取 Run 与目标 Attempt;任一已经终态时返回 already-terminal,不追加事件、不调用 Executor.stop。
|
||
2. 首次请求递增 Run version/event sequence,写入 `cancel_requested_at_ms`、受限 `cancel_reason`,追加 `run.cancel_requested`;Run/Attempt 此时仍表达已观测到的实际执行状态。
|
||
3. 事务提交后才调用 Executor.stop。提交失败时外部副作用必须为零;stop 失败时保留请求供 Reconciler 重试。
|
||
4. 重复请求返回 already-requested,不再次递增 version 或追加请求事件,但允许对同一 handle 幂等重试 stop。
|
||
5. 取消请求先提交时,后到 Executor success/failure 统一收敛为 cancelled;Attempt 终态先提交时,后到取消不得再发送 signal。
|
||
|
||
当前 `0004-run-cancellation-request` 以 nullable 增量列和恢复索引实现取消意图,既有 Run 保持 null;`0005-run-cancellation-dispatch` 增加每 Run 唯一、绑定 Attempt 的 dispatch lease/backoff/fencing 状态。孵化实现已有有界 cross-worker source、原子 claim/result Repository、低敏结果事件、指数退避和单周期 supervisor,以及对 Linux durable handle 重新核验 PID、boot ID、start ticks、process group 后才发 TERM/KILL 的 controller。HTTP worker 已通过默认关闭的 manual-only manifest bootstrap 接入 production cadence;只有 accepted 且全部 gate 通过时才在 startup reconciliation 后启动 cancel lifecycle,失败或 shutdown 时有界停止。取消派发协议见 ADR-0005;尚未接入的 completion/log 恢复协议见 ADR-0007。
|
||
|
||
## 14. Legacy 投影
|
||
|
||
迁移期:
|
||
|
||
- Run/Attempt/Event 先以 shadow 模式写入。
|
||
- `Crontab.status` 仍供旧 API/UI 使用。
|
||
- Projection 更新失败不回滚已经提交的 Run 事实,但必须重试并告警。
|
||
- 对账任务比较 Crontab、RunningInstance 与 Run/Attempt。
|
||
- 旧 `/open/crons/status` 继续可用,但逐步注入 Run/Attempt ID。
|
||
|
||
完成切换后:
|
||
|
||
- Run/Attempt/Event 成为唯一事实源。
|
||
- `Crontab.status` 作为可重建兼容投影或被新的查询模型替代。
|
||
- `RunningInstance` 停止接收新数据,历史记录保留或迁移为 Attempt。
|
||
- Dashboard 和详情页从 Run 查询模型读取。
|
||
|
||
## 15. API 语义
|
||
|
||
- 创建 Run 返回 201 和 Run 资源;命中幂等 key 可以返回 200。
|
||
- 状态冲突返回 409 和稳定错误码 `RUN_VERSION_CONFLICT` 或 `INVALID_RUN_TRANSITION`。
|
||
- 取消是异步命令,接受后返回携带 durable cancel request 的 Run 当前状态,不承诺进程已立即退出。
|
||
- Run Event API 按 sequence 分页。
|
||
- API 不允许客户端直接写任意 status;客户端提交 start/cancel/retry/approve 等命令。
|
||
- 管理员修复状态使用独立审计命令,不能复用普通更新 API。
|
||
|
||
## 16. 监控指标
|
||
|
||
至少记录:
|
||
|
||
```text
|
||
run_transition_total{from,to,result}
|
||
run_transition_conflict_total{command}
|
||
run_event_append_total{type}
|
||
run_reconcile_total{executor,result}
|
||
run_non_terminal_age_seconds{status}
|
||
run_projection_mismatch_total{projection}
|
||
attempt_late_callback_total{executor,type}
|
||
```
|
||
|
||
edge 模式限制 label 基数,不将 run_id、task_id 或 error message 放入指标 label。
|
||
|
||
## 17. 安全与隐私
|
||
|
||
- Event payload 经过字段 allowlist 和大小限制。
|
||
- callback token 与 Run/Attempt 绑定、短期有效并可撤销。
|
||
- error summary 在写入前脱敏。
|
||
- Tool 参数、Prompt、Secret 和完整日志不进入 RunEvent。
|
||
- Actor 必须经过认证;legacy shell 使用专用内部身份。
|
||
- 外部 API 只能查看当前 Project 有权限的 Run 和 Event。
|
||
|
||
## 18. 不采用的方案
|
||
|
||
### 18.1 继续使用 Crontab.status 作为事实源
|
||
|
||
不能表达并发 Run、重试 Attempt、Workflow Step 和远程 Worker,拒绝。
|
||
|
||
### 18.2 只扩展 RunningInstance
|
||
|
||
RunningInstance 偏向本地进程,缺少用户运行意图、Trigger、重试、Actor 和事件语义,拒绝。
|
||
|
||
### 18.3 完整 Event Sourcing
|
||
|
||
会显著增加查询、migration、调试和 edge 资源复杂度。3.0 使用当前状态表 + append-only Event,拒绝首版完整 Event Sourcing。
|
||
|
||
### 18.4 使用 PID 作为 Attempt ID
|
||
|
||
PID 仅在单机短时间范围内有效且可能复用,拒绝。
|
||
|
||
### 18.5 终态 Run 原地重试
|
||
|
||
会破坏历史结果和审计语义。人工重试创建新 Run,拒绝终态回退。
|
||
|
||
### 18.6 在事务中执行外部副作用
|
||
|
||
数据库事务无法与进程、容器和远程 API 原子提交,会造成长锁和不可靠假象,拒绝。
|
||
|
||
## 19. 影响
|
||
|
||
### 正面
|
||
|
||
- 并发任务和多实例具有稳定身份。
|
||
- 自动/人工重试语义清晰。
|
||
- 可以支持 Workflow、Agent、MCP、Remote Worker 和恢复。
|
||
- SQLite 与 PostgreSQL 共享领域规则。
|
||
- 状态历史可审计且能驱动实时 UI。
|
||
|
||
### 代价
|
||
|
||
- 每次状态变化增加一次 Event 写入。
|
||
- 需要 migration、对账和 Legacy Projection。
|
||
- Shell、Executor 和 Worker 协议需要传递 Run/Attempt ID。
|
||
- 开发者必须使用 RunService,不能直接更新状态列。
|
||
- 需要处理外部副作用与数据库提交之间的协调窗口。
|
||
|
||
## 20. 实施顺序
|
||
|
||
1. 建立 migration runner 和 2.x 执行契约测试。
|
||
2. 新增 Run、RunAttempt、RunEvent Schema 和 Repository。
|
||
3. 实现纯领域状态转换测试。
|
||
4. 实现事务性 RunService 和冲突测试。
|
||
5. 影子记录现有手动任务生命周期。
|
||
6. 注入 QL_RUN_ID/QL_ATTEMPT_ID 并兼容旧回调。
|
||
7. 上线对账指标和只读 v3 Run API。
|
||
8. Feature Flag 切换手动执行路径。
|
||
9. 扩展到定时、秒级、Subscription 和其他执行路径。
|
||
10. 最终将 Crontab.status 降级为 Projection。
|
||
|
||
## 21. 验证场景
|
||
|
||
ADR 接受和实现完成需要覆盖:
|
||
|
||
1. 同一 Trigger 重复投递只创建一个 Run。
|
||
2. 两个并发转换只有一个成功,另一个收到 409/RunConflict。
|
||
3. Run 更新失败时不产生孤立 Event。
|
||
4. Event 追加失败时 Run 状态不提交。
|
||
5. Attempt 成功回调重复两次只产生一次终态。
|
||
6. cancel 与 exit code 0 并发时结果符合获胜命令,迟到回调不覆盖终态。
|
||
7. 自动重试创建新 Attempt,保持同一 Run。
|
||
8. 人工重试创建新 Run,并引用原 Run。
|
||
9. 服务重启后通过 inspect 恢复或标记 lost,不批量设 idle。
|
||
10. Shadow Run 与 Crontab 状态差异能够被发现和定位。
|
||
11. SQLite busy retry 有界且不造成重复 Event。
|
||
12. Event payload 不包含已知 Secret 和完整日志。
|
||
13. edge 长任务日志不会导致 Event 表或内存随日志量线性增长。
|
||
14. PostgreSQL 实现通过与 SQLite 相同的 RunService contract suite。
|
||
|
||
## 22. 待确认项
|
||
|
||
以下内容在 ADR 接受前确认,但不改变核心模型:
|
||
|
||
- UUIDv7 是否在所有承诺 Node 版本和架构上通过 smoke test。
|
||
- SQLite 条件唯一索引的 Sequelize migration 表达方式。
|
||
- RunEvent payload 的默认和最大字节数。
|
||
- callback token 的签发、刷新和撤销实现。
|
||
- LocalExecutor handle 的跨平台进程身份字段。
|
||
- Shadow Run 对账告警默认级别和保留时间。
|
||
|
||
## 23. 接受标准
|
||
|
||
本 ADR 从 Proposed 进入 Accepted 需要:
|
||
|
||
- Maintainers 接受 Run/Attempt/Step/Event 的职责划分。
|
||
- 接受终态不可变和人工重试新建 Run。
|
||
- 接受状态更新与 Event 追加的同事务要求。
|
||
- 接受 version CAS、sequence 和 dedupe key 规则。
|
||
- 接受外部副作用在事务外通过协调恢复。
|
||
- 接受 3.0 最终回调必须携带 Run/Attempt ID。
|
||
- SQLite edge 基准证明额外 Event 写入处于资源预算内。
|