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

13 KiB
Raw Blame History

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_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

-- 主表:只保留任务本身的语义
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 计划

  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):

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-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。