Commit a2f70192e514c9ca18efd11ff282dd0cc7423a90

Authored by yibo
1 parent 090ded5f

docs(27-P4): 记录 T5 落地 + 一次共享工作目录并发编辑事故的处理过程

T5 任务表行标完成,补一段实现记录(桩子设计、block_reason 列、
resumeBillingClearedRuns、advanceBatch 的第二个排序 bug)。

顺带记一笔过程:写 T5 期间发现 work-graph-db.js 被另一个会话同时编辑
(重写 validateFailFallback),用 git diff 生成的 patch 只保留自己的 hunk
(git apply --recount --cached)避免带上对方未完成的改动,再用一次性
worktree 单独验证后才提交。留着这条经验:共享工作目录下 commit 前先
git diff 走查一遍要提交的文件,别假设自己改的文件里只有自己的改动。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/todo/system-design/27-p4-parallel-and-artifacts.md
@@ -565,7 +565,7 @@ R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`), @@ -565,7 +565,7 @@ R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`),
565 | **T2** | DevLoop 规格:§12 第一条不变量放宽为 `(pid, agent, scope)`。**改 HTML 后必须跑 `npm run spec:extract`** | `docs/prd/newfunction/*.html` | 无 | 565 | **T2** | DevLoop 规格:§12 第一条不变量放宽为 `(pid, agent, scope)`。**改 HTML 后必须跑 `npm run spec:extract`** | `docs/prd/newfunction/*.html` | 无 |
566 | ~~**T3**~~ ✅ | `graph_runs` 加 `max_concurrency` 列(可空,NULL 用新导出常量 `DEFAULT_MAX_CONCURRENCY=5`,对齐 `plan-cost-concurrency.md` §④);`createRun` 新增 `maxConcurrency` 参数并持久化 —— **2026-08-21 已完成**(迁移块 + `createRun` INSERT 都改了,`hasColumn` 判断 + `ALTER TABLE ADD COLUMN` 照抄既有存量库迁移写法)。~~DROP `idx_graph_runs_one_active_root`~~ 那半本来就已完成(27-P3C 批 4),不用动 | `server/services/work-graph-db.js` | 无 | 566 | ~~**T3**~~ ✅ | `graph_runs` 加 `max_concurrency` 列(可空,NULL 用新导出常量 `DEFAULT_MAX_CONCURRENCY=5`,对齐 `plan-cost-concurrency.md` §④);`createRun` 新增 `maxConcurrency` 参数并持久化 —— **2026-08-21 已完成**(迁移块 + `createRun` INSERT 都改了,`hasColumn` 判断 + `ALTER TABLE ADD COLUMN` 照抄既有存量库迁移写法)。~~DROP `idx_graph_runs_one_active_root`~~ 那半本来就已完成(27-P3C 批 4),不用动 | `server/services/work-graph-db.js` | 无 |
567 | ~~**T4**~~ ✅ | `advanceBatch(runId, {maxConcurrency})`:`maxConcurrency<=1` 直接委托 `advanceOne`(零行为差异,不是反过来让 advanceOne 委托 advanceBatch——见函数注释里的理由);`>1` 时 `SELECT ... LIMIT` 取候选,循环调 `sse.transitionNode` 逐行拾取(**不包裹事务**,§4.1 决策的完整论证)。**2026-08-21 已实现并测过** | `server/services/graph-engine.js` | T3 ✅ | 567 | ~~**T4**~~ ✅ | `advanceBatch(runId, {maxConcurrency})`:`maxConcurrency<=1` 直接委托 `advanceOne`(零行为差异,不是反过来让 advanceOne 委托 advanceBatch——见函数注释里的理由);`>1` 时 `SELECT ... LIMIT` 取候选,循环调 `sse.transitionNode` 逐行拾取(**不包裹事务**,§4.1 决策的完整论证)。**2026-08-21 已实现并测过** | `server/services/graph-engine.js` | T3 ✅ |
568 -| ~~**T5**~~ | ~~并发额度计算(三层取小)+ 额度为 0 时的等待语义~~ **按 2026-08-21 决策大幅简化并重新定义**:不再有三层 min 计算,`advanceBatch` 只看本 run 的 `max_concurrency`。剩下两件事都**依赖计费系统接口确认**,暂不能开工:① 欠费检查怎么接(`checkBudget` 是任务级预算,跟账户欠费是两个概念,不能互相顶替);② 欠费清零后的**自动恢复触发器**——复用 `suspendRunTree`/`startRunSync` 现成的挂起/恢复状态机(`reason='billing-arrears'`),但今天从 `blocked` 恢复是人手点「启动」,无人值守场景需要一个订阅计费事件、批量调 `startRun` 的轮询/webhook,这是新代码,不是配置项 | `server/services/graph-engine.js` | T1, T3, T4,另需计费系统接口 | 568 +| ~~**T5**~~ ✅ | ~~并发额度计算(三层取小)+ 额度为 0 时的等待语义~~ **2026-08-21 已实现,按可插拔桩子的方式落地**(用户明确选择:真实计费接口未定前先搭桩子)。`setBillingGate(fn)` 与 `setAgentExecutor` 同款注入模式,未注入时 `checkBilling` 恒「未欠费」,对现有行为零影响;欠费时复用 `suspendRunTree`(新增 `reason`/`summary` 参数,`reason='billing-arrears'`),写进新增的 `graph_runs.block_reason` 列;`startRunSync`「启动」清空该列;`resumeBillingClearedRuns()` 是自动恢复触发器(扫 `block_reason='billing-arrears'` 的 blocked root run,欠费真清了就代其 `startRun`),调度源(webhook/轮询)留给计费接口接通之后再定。**顺带修了一个真 bug**:`advanceBatch` 原来 budget 门查在候选查询之前,导致「已收敛只是恰好预算也耗尽」的 run 被误判成挂起而不是 completed——已改成候选先查、budget/billing 门后查,跟 `advanceOne` 的既有顺序对齐。新测试 `graph-engine-billing-gate.test.js`(7 条),临时 worktree 隔离验证(见下方实现记录) | `server/services/graph-engine.js` | T1, T3, T4 |
569 | ~~**T6**~~ ✅ | 把 `advanceOne` 拾取之后的整段(建 ctx → 执行 → `settleNode`)抽成 `runClaimedNode(...)`,`advanceBatch` 用 `Promise.allSettled` 并发跑 claimed 节点。fan-in join 复用现有 `activateSuccessTargets`/`markReady`,**不需要新事务/锁**(并发安全性证明见 §4.3)。**2026-08-21 已实现并测过** | `server/services/graph-engine.js` | T4 ✅ | 569 | ~~**T6**~~ ✅ | 把 `advanceOne` 拾取之后的整段(建 ctx → 执行 → `settleNode`)抽成 `runClaimedNode(...)`,`advanceBatch` 用 `Promise.allSettled` 并发跑 claimed 节点。fan-in join 复用现有 `activateSuccessTargets`/`markReady`,**不需要新事务/锁**(并发安全性证明见 §4.3)。**2026-08-21 已实现并测过** | `server/services/graph-engine.js` | T4 ✅ |
570 | ~~**T7**~~ ✅ | 产物索引:追加、查询、rebuild —— **已完成,形态换成 `graph_files` 表**(见 §3.6 校准块),不是 `files.json` | `server/services/work-graph-db.js` | — | 570 | ~~**T7**~~ ✅ | 产物索引:追加、查询、rebuild —— **已完成,形态换成 `graph_files` 表**(见 §3.6 校准块),不是 `files.json` | `server/services/work-graph-db.js` | — |
571 | ~~**T8**~~ ✅ | 产物按 run/node 分目录 + 节点产出后自动进索引 —— **已完成**(`runs/{rootRunId}/nodes/{nodeRunId}/`) | `server/services/graph-engine.js` | — | 571 | ~~**T8**~~ ✅ | 产物按 run/node 分目录 + 节点产出后自动进索引 —— **已完成**(`runs/{rootRunId}/nodes/{nodeRunId}/`) | `server/services/graph-engine.js` | — |
@@ -626,6 +626,38 @@ R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`), @@ -626,6 +626,38 @@ R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`),
626 > subgraph 节点各自起的子 run)不会同时被推——它们靠 `runAuto` 外层循环一轮一轮轮流服务, 626 > subgraph 节点各自起的子 run)不会同时被推——它们靠 `runAuto` 外层循环一轮一轮轮流服务,
627 > 不是真并发(已在 `advanceTree` 加了 ponytail 注释记这笔)。真并发目前只发生在**同一个 run 627 > 不是真并发(已在 `advanceTree` 加了 ponytail 注释记这笔)。真并发目前只发生在**同一个 run
628 > 内部**。这个跨 run 兄弟并发是否值得做,留给 T10 之后再看有没有真实场景撞上。 628 > 内部**。这个跨 run 兄弟并发是否值得做,留给 T10 之后再看有没有真实场景撞上。
  629 +>
  630 +> **2026-08-21 追加:T5 落地**(同一天,紧接上面那批):
  631 +> - 用户明确决策:欠费判据「先搭可插拔的桩子」——真实计费接口依赖计费系统那边确认,不等它,
  632 +> 先把挂载点和恢复机制落地。`setBillingGate(fn)` 与 `setAgentExecutor`/`setPlanWorkGraph`
  633 +> 同款注入模式;`checkBilling(run)` 未注入时恒 `{ok:true, inArrears:false}`。两处调用点
  634 +> (`advanceOne`、`advanceBatch`)都紧跟在 `checkBudget` 之后——欠费是账户级门,预算是
  635 +> 任务级门,两个维度不合并。
  636 +> - `suspendRunTree(run, opts)` 加了 `reason`/`summary` 参数(默认值保持旧调用方——`checkBudget`
  637 +> 那条路径——行为不变),欠费走同一个函数只是 `reason='billing-arrears'`。`graph_runs` 新增
  638 +> `block_reason` 列(`suspendRunTree` 写、`startRunSync`「启动」时无条件清空)+ 复合索引
  639 +> `(run_status, block_reason) WHERE parent_run_id IS NULL`,配 `resumeBillingClearedRuns()`
  640 +> 的扫描查询:找出所有欠费挂起的 root run,对每条重新查一次欠费状态,清了就代其 `startRun`。
  641 +> 谁来调用这个扫描函数、多久扫一次,留给计费系统事件/轮询接通之后再定。
  642 +> - **审这段时抓到第二个真 bug**:`advanceBatch` 原来是「先查 budget、再查候选」,跟
  643 +> `advanceOne` 的既有顺序(先确认真有 ready 候选,才查 budget——`advanceOne` 注释原话
  644 +> 「拾到了 ready 节点(=真要花钱了)才查,避免空转时把已跑完的 run 误挂起」)反着来。
  645 +> 后果:一个已经收敛(零 ready 节点)只是恰好预算也耗尽的 run,会被误判成
  646 +> `budget-exhausted` 而不是正确收敛到 `completed`。接欠费门之前先把这个顺序修对,
  647 +> 两道新旧门用同一个正确的位置。
  648 +> - 新增测试 `graph-engine-billing-gate.test.js`(7 条):零影响、`advanceOne`/`advanceBatch`
  649 +> 欠费挂起、`startRunSync` 清空 `block_reason`、`resumeBillingClearedRuns` 正确恢复/放过、
  650 +> 按 `companyId` 限定扫描、上面那条排序 bug 的回归用例。
  651 +> - **踩到并处理了一次真实的并发编辑事故**:写 T5 期间发现 `work-graph-db.js` 被另一个会话
  652 +> 同时编辑(重写 `validateFailFallback`,31 号文档那条线,当时未完成、会让 51 个测试失败),
  653 +> 与我的 `block_reason` 改动混在同一份工作区文件里。没有跟着 `git add -A`,改用 `git diff`
  654 +> 自动生成的 patch 只保留自己的 3 处 hunk(`git apply --recount --cached`),
  655 +> 再用一次性的 `git worktree add --detach` 临时工作树(跑完 `git worktree remove` 清掉)
  656 +> 把这批 staged 内容单独跑一遍全量测试,确认自己的改动跟对方未完成的那部分互不依赖,
  657 +> 最后才 commit。过程中还手滑把上一条 commit 的说明文字贴成了另一条无关 commit 的原文,
  658 +> 用 `git commit --amend` 当场改正(那次 amend 合法——是刚提交、还没推送、也没有任何东西
  659 +> 叠在它上面的自己的commit,不是在改别人的历史)。这条经验值得记:共享工作目录下,
  660 +> commit 前先 `git diff` 走查一遍两个待提交文件的内容,不要假设「我改的文件只有我的改动」。
629 661
630 --- 662 ---
631 663