Files
AgentCompany/docs/proposals/B2-tasks-state-machine.md
T
ZacharyZhang-NY 720387b6ed B2 proposal: record Phase 2 entry criteria and trigger vs explicit dual-write trade-off
Append a "Phase 2 considerations" section capturing the two
unresolved architecture debates Linus and Jobs surfaced in T1
review:

- Linus: drop the status->phase TRIGGER in favour of explicit
  dual-write at every call site. Deferred to Phase 2 with explicit
  rationale (existing two-layer guard + 5-commit blast radius).
- Jobs: flip reads from legacy columns to phase / task_leases.
  Deferred until B5 eval harness can baseline agent behaviour.

Also records the four entry criteria for Phase 2 and the four gates
for Phase 3 drop-old-columns, so the next iteration has a concrete
checklist rather than a re-argument.
2026-04-20 13:52:51 -04:00

312 lines
18 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。
## Phase 2 considerations
> 追加时间:2026-04-20(Roundtable T1 Linus/Jobs review 之后)
> 状态:Phase 1(步骤 1/5–5/5)已完成并加了一轮 follow-up 修补(advanceTaskStatus lease 清理 + interruptStaleRuns scoping + ESLint 规则加固)。下面这组问题在 Phase 1 明确不动。
### Phase 1 实际形态
- v50 migration 加了 `tasks.phase` 列 + `task_leases` 表 + status→phase TRIGGER。
- 所有 status 写路径走 `advanceTaskStatus` / `checkoutTask` / `releaseTaskLock` / `renewTaskLease` / `saveTask` / `recoverStaleLocks` / `interruptStaleRuns`。每处都显式写 lease(INSERT / UPDATE / DELETE)并由 TRIGGER 镜像 status→phase。
- Dev-mode 启动时跑 `verifyPhaseConsistency` 报告任何 legacy 列 vs 新 phase/lease 之间的分歧行。
- ESLint `no-restricted-syntax` 规则现在抓 `UPDATE tasks SET ...status =` 的所有字面量变体以及 `UPDATE tasks SET ${...}` 的 template head;所有 5 处灰度写点都带 rationale 注释。
- 读路径**没有切**到 phase / task_leases(Round 2 共识"only dual-write, no switch-read")。
### 未决的架构争论
**Linus ask:删掉 status→phase TRIGGER,改为显式双写。**
论点:TRIGGER 是隐式耦合,任何新写路径都"顺带"改 phase,容易让人忘记也要处理 lease。显式双写把"我在改 status + 改 phase + 改 lease"三件事放在同一段代码里,review 时一眼能看全。
反论点:现状是 TRIGGER + consistency probe 双层护栏。TRIGGER 保证 `phase` 永远跟 `status` 同步(SQL 层强制),probe 在 dev 里暴露 `phase` vs `lease` 分歧。如果在 Phase 1 window 里删 TRIGGER,就要在 aba6fa3 往后的 5 个 commit 里把每个写点都加一遍显式 phase 更新——改动面大、易漏、测试信心低。Round 2 共识是 Phase 1 只做 dual-write,TRIGGER 留到 Phase 2 专门评估。
**决定**:Phase 1 不动 TRIGGER。Phase 2 开始时重新评估,届时已有 fixture + 行为基线,可以用明确的 test coverage 证伪"删 TRIGGER 会引入 regression"。
**Jobs ask:Phase 2 把读路径切到 phase / task_leases。**
论点:Dual-write 只是过渡态;只要读路径还看 legacy `status` / `active_run_id` / `checkout_run_id` 三列,phase 和 lease 就只是"给自己看的影子",不会暴露业务错误。
反论点:切读之前必须有 eval 基线能证明"同一 company 输入下 v50 dual-write 模式 和 v51 read-from-phase 模式的 agent 行为序列一致"。B5 eval harness 是 blocking dependency(见顶部),在 fixture 录好之前切读是盲切。
**决定**:Phase 2 前 B5 eval harness 必须就绪。
### Phase 2 entry criteria
以下全部满足才进 Phase 2:
1. **B5 eval harness 能录一批 run trace 作为 baseline**:选 3 个 company 模板,每个跑一轮完整 sprint,记录 tool call 序列 + state transition 序列。
2. **Phase 1 dev-mode probe 连续 7 天 0 divergence**:启动时 `verifyPhaseConsistency` 的 `inconsistent` 字段必须持续为 0。如果还有分歧,说明 Phase 1 还没收敛,不该往前走。
3. **Phase 1 所有 grandfathered 写点都有 test 覆盖**:`advanceTaskStatus` lease 清理、`checkoutTask` lease 写入、`interruptStaleRuns` scoping、`recoverStaleLocks` lease 清理。T1 follow-up 已补齐 `advanceTaskStatus`,其余在 Phase 2 开工前补齐。
4. **TRIGGER vs 显式双写定案**:Phase 2 第一个 commit 要么保留 TRIGGER 并写 contract test 钉死 `status ⇔ phase` 不变式,要么删 TRIGGER 并把显式 phase 更新加到所有写点。两条路径都是可验证的;Phase 2 开工时用 review 投票选一条。
### Phase 3 drop-old-columns gate
Phase 2 完成(所有读路径切到 phase / task_leases)并运行 14 天、`checkout_race_lost` 计数连续为 0 之后才进 Phase 3:
1. **静态扫描确认没有新的 legacy 列 reader**:`status IN ('todo', 'backlog')`、`checkout_run_id`、`execution_locked_at`、`active_run_id` 全站搜索 0 命中(migrations 除外)。
2. **ESLint 规则升级为 `error`**:任何新增的 legacy 列引用直接 fail build。
3. **灰度 1 个 company 7 天**:观察 `sqlite_busy` 不增、`checkout_race_lost` 不增、renderer 没有新 404/500。
4. **v51 migration 删列**:`active_run_id`、`checkout_run_id`、`execution_locked_at`、`active_session_id`、`status`(如保留则降为 derived view)。删列前自动备份一次。
### 明确不在 Phase 2 scope
- **M7 Phase 2 call-site switch**:Round 2 共识 M7 只做 Phase 1。Phase 2 开工时如需重开,走独立 proposal。
- **B4 single-writer actor**:互补 proposal,独立推进。
- **IPC 契约改动**:renderer 看到的仍是 `TaskRecord.status`,由 map 层从 phase/lease 合成。