Files
AgentCompany/docs/proposals/B2-tasks-state-machine.md
T

257 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Proposal B2: tasks 状态机重构为 phase enum + task_leases 表
> 来源:问题.md B2(行 45-64)、附录 F 样本 1(行 424-441)、样本 2(行 444-457)
> 状态:proposed(待 B5 eval harness 就绪后实施)
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:3-4 周(与 C1 共享 migration 窗口)
> Blocking dependencies:B1(pi 版本收敛,已完成)、B5(eval harness 建立,仍 blocked by user constraint)
## 问题陈述
单张 `tasks` 表并行持有 5 个锁/状态字段,彼此之间没有代数约束:
- `status`(枚举:backlog / todo / in_progress / done / blocked / …)
- `active_run_id`(当前运行的 run id)
- `checkout_run_id`(正在抢占执行权的 run id)
- `execution_locked_at`(执行锁 timestamp)
- `active_session_id`(pi agent session id)
这 5 列的合法组合本应由状态机约束,实际由散落在 10+ 处的 SQL 片段手动维护。`src/main/database/tasks-status.ts:160-189` 的 `checkoutTask` 函数 CAS 条件里已经被迫加了补丁分支:
```sql
AND (
status IN ('todo', 'backlog')
OR (status = 'in_progress' AND active_run_id IS NULL)
)
```
这个 `OR (status = 'in_progress' AND active_run_id IS NULL)` 分支是"从 orphan 状态恢复"的补丁。代码注释自己承认:
> "recovers from a specific orphan: finishRun has cleared active_run_id and checkout_run_id but the status transition never fired … without the reclaim, heartbeat picks the task and then loops on `checkout_race_lost` forever"
进一步证据:
- `src/main/database-base.ts:369-387` 的 `interruptStaleRuns`:只清 `active_run_id`、把 agents 设为 idle、释放 workspace lock。**没清** `checkout_run_id` 和 `execution_locked_at`。
- `src/main/database-base.ts:389-400` 的 `recoverStaleLocks`:用 15 分钟 cutoff 清 `checkout_run_id` 和 `execution_locked_at`。
- 两条独立路径各写各的字段,顺序错位就出现「status=in_progress,active_run_id=null,checkout_run_id=null」的 zombie。
## 根因分析
Linus 视角(好品味原则第一条):"特殊情况就是你数据结构错了"。
当前设计把"执行权 lease"的元数据打散到 tasks 表的多个列里,违反了**最基本的关系代数**:tasks 是实体,执行权是有起止时间的租约,这是两个有独立生命周期的东西。把它们塞在同一行就必然出现:
1. **部分更新问题**:清 lease 时必须同时清 `active_run_id` / `checkout_run_id` / `execution_locked_at`,任何一处漏掉就是 zombie。
2. **状态组合爆炸**:5 个字段理论组合数是 5! = 120 种(真实合法的只有 ~6 种),SQL 层无法约束。
3. **多 writer 竞态**:`interruptStaleRuns` 和 `recoverStaleLocks` 各自只负责一部分字段,顺序依赖靠 code review 维持。
4. **补丁放大效应**:`checkoutTask` 的 OR 分支注释里写得很清楚——"如果不补,heartbeat 就会永远循环 checkout_race_lost"。这不是"修",这是给错的数据结构打绷带。
问题的根源是:**tasks 表承担了两个职责**——"我是什么任务"(entity)和"我现在被谁锁着"(lease)。这两个职责必须拆开。
## 设计方案
### 新 schema
```sql
-- 主表:只保留任务本身的语义
ALTER TABLE tasks ADD COLUMN phase TEXT NOT NULL
CHECK (phase IN ('backlog', 'ready', 'running', 'awaiting_review', 'done', 'blocked'));
-- 删除:active_run_id, checkout_run_id, execution_locked_at, active_session_id, status(等迁移完成)
-- 新表:租约独立生命周期
CREATE TABLE task_leases (
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
run_id TEXT NOT NULL REFERENCES runs(id),
acquired_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
session_id TEXT,
-- 显式外键 + 唯一约束,SQL 层强制"一个 task 同时只能有一个 lease"
UNIQUE (task_id)
);
CREATE INDEX idx_task_leases_expires ON task_leases(expires_at);
```
### 状态机语义
| phase | 合法来源 phase | lease 是否存在 | 触发条件 |
|------|---------|---------|---------|
| `backlog` | (init) / `blocked` 清除 | 否 | 任务初建 |
| `ready` | `backlog` | 否 | 需求就绪 |
| `running` | `ready` | **必须有** | `checkoutTask` 成功 |
| `awaiting_review` | `running` | 否(finishRun 释放) | deliverable 产出 |
| `done` | `awaiting_review` | 否 | 审核通过 |
| `blocked` | 任何(除 done) | 否 | 显式 block |
**关键不变式**:`phase = 'running'` ⇔ `task_leases` 存在对应行。SQL 层面用 trigger 强制。
### 单一状态迁移函数
```ts
// src/main/database/tasks-status.ts(重写)
export function advanceTaskPhase(
taskId: string,
from: TaskPhase,
to: TaskPhase,
ctx: { runId?: string; sessionId?: string; ts?: number }
): { ok: true } | { ok: false; reason: string } {
return db.transaction(() => {
// 1. CAS 写 tasks.phase
// 2. 根据 from/to 组合插入/删除 task_leases 行
// 3. 违反不变式直接 throw(let it crash)
})();
}
```
**所有**状态迁移都走这一个函数,禁止直接 `UPDATE tasks SET status = ...`。改造 call sites:
- `src/main/database/tasks-status.ts`(checkoutTask, finishRun, interruptRun)
- `src/main/database-base.ts:369-400`(interruptStaleRuns, recoverStaleLocks)
- `src/main/orchestration/scheduler.ts`、`task-runner.ts`、`heartbeat.ts` 的所有 tasks 写路径
### 删除 OR 补丁
重构后,`checkoutTask` 变成:
```sql
UPDATE tasks SET phase = 'running' WHERE id = ? AND phase = 'ready';
INSERT INTO task_leases (task_id, run_id, acquired_at, expires_at, session_id) VALUES (?, ?, ?, ?, ?);
```
没有 OR,没有 orphan。如果 `interruptRun` 漏清 lease,trigger 会在下次 advanceTaskPhase 时直接报 constraint violation——**让它 crash,不掩盖**。
## Migration 计划
1. **v49 migration**(dual-write 阶段,1 周)
- 新建 `task_leases` 表(空)
- 新增 `tasks.phase` 列,默认值从 `status` 映射:`backlog→backlog, todo→ready, in_progress+active_run_id→running, …`
- 应用层所有写路径同时写 `phase` 和旧 `status`,写 lease 同时写 `active_run_id`/`checkout_run_id`
2. **backfill**(自动)
- v49 启动时一次性 SQL:根据旧 5 列反推 phase + 插入 lease 行
- 写 validation:phase = running ⇔ lease 存在;不成立直接 crash(暴露现存 orphan)
3. **v50 switch-read**(2 周后)
- 所有读路径切到 `phase` + `task_leases` JOIN
- 确认 telemetry 没有新 404/500
4. **v51 drop-old**(4 周后)
- 删 `active_run_id`、`checkout_run_id`、`execution_locked_at`、`active_session_id`、`status` 列
- 删 `checkoutTask` 的 OR 分支(届时代码里已不存在)
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| backfill 发现现存 orphan | v49 启动时 assertion fail | v49 migration 提供 `--heal-orphans` flag,根据最后活跃 run 推断正确 phase |
| dual-write 导致写冲突 | 并发 advanceTaskPhase vs 旧路径 | v49 → v50 之间禁止 `UPDATE tasks SET status = ...`(ESLint rule + SQL trigger 拦截) |
| switch-read 性能回退 | JOIN 开销增加 | 给 `task_leases(task_id)` 建 UNIQUE 索引(schema 已含) |
| 测试 fixture 不同步 | tests/unit 里的 stub 数据没 phase 列 | 所有测试 fixture 走 `makeTaskRecord()` helper 统一生成 |
**真回滚路径**:v51 前每一步都可降级——任何时候发现新 schema 有 bug,停止应用层双写、所有读切回旧列,v49 migration 可以通过删 `task_leases` 表 + 清 `phase` 列回到原状态。
## 验证标准
**前置依赖:B5 eval harness 必须先就绪**(Karpathy 强烈反对无 eval 下做状态机改动)。
具体验证:
1. **契约测试**:构造 30+ 种非法状态组合,验证 `advanceTaskPhase` 全部拒绝
2. **并发 fuzz**:2 个 worker 同时 checkoutTask 同一个 task,只有一个成功
3. **崩溃恢复**:kill -9 worker,下次启动 `recoverStaleLocks` 能清掉过期 lease
4. **行为 eval**:相同 company 输入,v48 和 v51 产出的 run trace 在 agent 行为层一致(tool calls / state transitions 序列)
5. **Telemetry 指标**:生产环境 `checkout_race_lost` 发生率不增,`sqlite_busy` 不增
## 不在范围
- **不重构 runs 表**:runs 的 status 机自身健康,本 proposal 只动 tasks。
- **不改 IPC 契约**:renderer 看到的仍是 `TaskRecord.status`,由 map 层从 phase/lease 合成。
- **不做 per-agent quota**:执行权限流控是另一件事,走独立 proposal。
- **不引入分布式锁**:SQLite 单 writer 前提不变,B4 的 single-writer actor 是独立 proposal。
- **不做 phase transition 审计 log**:需要的话另开 event 表,和本 proposal 解耦。
## 参考文件清单
- `src/main/database/tasks-status.ts:160-189`(checkoutTask + OR 补丁)
- `src/main/database-base.ts:369-387`(interruptStaleRuns 只清一半字段)
- `src/main/database-base.ts:389-400`(recoverStaleLocks 另一半字段)
- `src/main/database-base.ts:49-51`(作者自白 pass-through 架构)
- `src/main/database/migrations/`(48 个既有 migration 作为 v49/v50/v51 的 template)
- `src/main/orchestration/scheduler.ts`(heartbeat 写路径)
- `src/main/worker/worker-runner.ts:239` 附近(worker 回写 run 状态)
## 附:为什么现在修而不是等
`checkoutTask` 的 OR 补丁在生产环境就是**一个 agent 完全卡死并持续烧钱**——每次 heartbeat 唤醒都重新尝试 checkout,每次失败产生日志 / 事件 / cost 记录。数据结构错误导致 agent 静默陷入死循环,这是最糟糕的一类 bug:不 crash,不报错,只是不前进。用户付钱看着 agent 原地踏步。
每多拖一周,新 call site 就多一批——数据结构错误会 viral 扩散。把 5 列乱象标准化后,后续所有 feature(new phase、priority、deadline)都受益。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | B5 eval harness 可以 record baseline behavior | Roundtable |
| G2 | v49 dual-write 测试环境跑 48 小时无 assertion fail | Owner |
| G3 | v50 switch-read 灰度 1 个 company 7 天 | Owner |
| G4 | v51 drop-old 前所有 call site 静态 scan 无 `status IN ('todo', 'backlog')` | Linus |
| G5 | `checkoutTask` OR 分支删除后 telemetry `checkout_race_lost` 连续 14 天为 0 | Karpathy |
## 附:与其他 proposal 的耦合
| 相关 | 关系 |
|------|-----|
| B1(pi 版本 pin) | 已完成 |
| B4(single-writer actor) | 互补:B4 收敛"谁能写",B2 收敛"写什么语义" |
| C1(AppDatabase pass-through) | 共享 migration 窗口:两者都需要改 DB 访问 call site,codemod 可同时改 |
| C6(error taxonomy) | B2 的 let-it-crash 依赖 C6 的 error 分类来决定 crash vs retry |
## 附:反模式对比(before / after)
**Before**(`src/main/database/tasks-status.ts:160-189`):
```sql
UPDATE tasks
SET checkout_run_id = ?, execution_locked_at = ?, status = 'in_progress', updated_at = ?
WHERE id = ?
AND checkout_run_id IS NULL
AND (
status IN ('todo', 'backlog')
OR (status = 'in_progress' AND active_run_id IS NULL) -- orphan 恢复补丁
);
```
**After**(B2 完成后):
```sql
-- 原子操作 1:phase 转移
UPDATE tasks SET phase = 'running' WHERE id = ? AND phase = 'ready';
-- 原子操作 2:插入 lease(UNIQUE 约束防止重复)
INSERT INTO task_leases (task_id, run_id, acquired_at, expires_at, session_id) VALUES (?, ?, ?, ?, ?);
```
两个 statement 走一个 transaction;UNIQUE(task_id) 约束让"同一个 task 同时只有一个 lease"成为 SQL 层不变式,无需应用层 CAS。
## 附:涉及 SQL 片段清单(待改造)
- `src/main/database/tasks-status.ts:160-189` checkoutTask
- `src/main/database/tasks-status.ts` finishRun(释放 lease)
- `src/main/database-base.ts:369-387` interruptStaleRuns
- `src/main/database-base.ts:389-400` recoverStaleLocks
- `src/main/orchestration/scheduler.ts` 的 wake writer
- `src/main/orchestration/heartbeat.ts` markHeartbeatAt(touch lease.expires_at)
- `src/main/worker/worker-runner.ts` 的 finishRun 回调
每处都从"直接 UPDATE 5 列"改为"调 `advanceTaskPhase`"。改造 PR 按文件拆,每个 PR 跑 typecheck + 单测。
## 附:为什么不选其他方案
考虑过的替代设计:
**方案 A(直接拆 leases 表)**:当前 proposal 采用。优点:SQL 层可强约束;缺点:需要 dual-write migration。
**方案 B(保留 5 列但用 check constraint)**:SQLite check constraint 表达力有限,无法精确表达"running 时 active_run_id 非空且 checkout_run_id 也非空"这类跨列约束。
**方案 C(单独一个 state machine 中间层 service)**:应用层约束比 SQL 层约束弱——任何代码绕过 service 直接 SQL 就失效。
方案 A 是唯一把约束放在数据结构层的。
## 附:phase 枚举外的状态跟踪
某些运行态信息仍需要但不属于 phase 枚举:
- `blocked_reason`(为什么 block)
- `awaiting_review_since`(等待审核已多久)
- `attempted_count`(重试次数)
这些存在 tasks 的辅助列或独立表——独立于 phase enum,不参与 CAS。