13 KiB
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 条件里已经被迫加了补丁分支:
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_lostforever"
进一步证据:
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 是实体,执行权是有起止时间的租约,这是两个有独立生命周期的东西。把它们塞在同一行就必然出现:
- 部分更新问题:清 lease 时必须同时清
active_run_id/checkout_run_id/execution_locked_at,任何一处漏掉就是 zombie。 - 状态组合爆炸:5 个字段理论组合数是 5! = 120 种(真实合法的只有 ~6 种),SQL 层无法约束。
- 多 writer 竞态:
interruptStaleRuns和recoverStaleLocks各自只负责一部分字段,顺序依赖靠 code review 维持。 - 补丁放大效应:
checkoutTask的 OR 分支注释里写得很清楚——"如果不补,heartbeat 就会永远循环 checkout_race_lost"。这不是"修",这是给错的数据结构打绷带。
问题的根源是:tasks 表承担了两个职责——"我是什么任务"(entity)和"我现在被谁锁着"(lease)。这两个职责必须拆开。
设计方案
新 schema
-- 主表:只保留任务本身的语义
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 强制。
单一状态迁移函数
// 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 变成:
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 计划
- 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
- 新建
- backfill(自动)
- v49 启动时一次性 SQL:根据旧 5 列反推 phase + 插入 lease 行
- 写 validation:phase = running ⇔ lease 存在;不成立直接 crash(暴露现存 orphan)
- v50 switch-read(2 周后)
- 所有读路径切到
phase+task_leasesJOIN - 确认 telemetry 没有新 404/500
- 所有读路径切到
- 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 下做状态机改动)。
具体验证:
- 契约测试:构造 30+ 种非法状态组合,验证
advanceTaskPhase全部拒绝 - 并发 fuzz:2 个 worker 同时 checkoutTask 同一个 task,只有一个成功
- 崩溃恢复:kill -9 worker,下次启动
recoverStaleLocks能清掉过期 lease - 行为 eval:相同 company 输入,v48 和 v51 产出的 run trace 在 agent 行为层一致(tool calls / state transitions 序列)
- 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):
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 完成后):
-- 原子操作 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-189checkoutTasksrc/main/database/tasks-status.tsfinishRun(释放 lease)src/main/database-base.ts:369-387interruptStaleRunssrc/main/database-base.ts:389-400recoverStaleLockssrc/main/orchestration/scheduler.ts的 wake writersrc/main/orchestration/heartbeat.tsmarkHeartbeatAt(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。