Commit 4258706715861693acf93967b57f16aa488ab81b

Authored by yibo
1 parent 401840b6

feat(27-P3C): 批 4 并行执行 + 批 3 复核遗留四条

## 批 4:解除「一家公司同时只有一个 active root run」
- DROP idx_graph_runs_one_active_root(建表块删掉 + 迁移块显式 DROP,
  CREATE ... IF NOT EXISTS 对存量库不生效)
- max_parallel_tasks 从 company.json 调用时读(不是模块加载时快照,
  否则改配置要重启才生效且不报错);缺省 1、非法回落 1 并留声、硬上限 20
- 准入判据「从活跃集外进入活跃集」按额度计数

**生死线用 8 个真进程并发抢坑实测**(不是纸面论证):max=1/2/3/5 四组,
每组恰好抢到 max 个、库里活跃数 = max,落选进程各吃 40 次 RunSlotBusyError,
零 other 错误。唯一索引没了之后,准入事务是真守卫。

## 批 3 对抗复核的四条遗留
- N1 🔴 cancelQueuedRun 的前提被批 3 自己的 requeueBlockedRootRun 改没了:
  queued 的 run 现在可以是跑过一半的。探针实测取消后子 run 残留
  paused/stop_requested=0 —— 那条 running 的子 run 继续调 agent 烧 token。
  改为无条件走 engine.stopRun
- L1 ⚠️ 恢复路径对 manual 只兑现一半:注释写着「T1 永远回不来这个后果因此
  消失」,但调度器故意不碰 manual → 退回队列后没人接。定案:「manual 不自动
  出队」约束的是首次起跑的授权,已被人亲手起过的不算新授权。
  判据用 attempt>0 而不是 started_at —— 后者会被 requeue 清成 NULL,
  恰好在需要它的那一刻是空的(实测踩过)
- N2 🟠「退回队列」对用户完全不可见:200 + ok:true + 按钮全灰 + 图消失 +
  一句「节点状态一律 pending」的假提示。加 notice 明说
- N3 🟠 isSlotRaceError 把 InvalidTransitionError 当竞态吞掉,而探针证伪了
  那条注释:被推走时条件更新返回 0 不抛,真正抛它的只有「调用方声明了非法
  转换」。删掉该分支,让它吵起来

四条里三条是同一个模式:注释承诺的前提被本轮自己的改动改没了,注释还留着。
文档 §十三.3 / §十四.4 已就这个模式立过两次案,这是第三、四、五次。

## 文档
§十五 实施记录,含 §15.4 一次流程事故:87b776c 由第三方会话在对抗复核完成
前误提,提交说明的「1201 用例全绿」压在一份尚未通过复核的代码上。

全量:128 文件 / 1240 测试全绿,web:build 通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p3c-goal-task-model.md
@@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
2 2
3 > **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 **P3 收尾**(P3C),直接续 [27-P3/P4 执行控制台](./27-p3-p4-work-graph-console.md) §四.B 与 [27-P3B 真跑跑道](./27-p3b-real-run-runway.md)。 3 > **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 **P3 收尾**(P3C),直接续 [27-P3/P4 执行控制台](./27-p3-p4-work-graph-console.md) §四.B 与 [27-P3B 真跑跑道](./27-p3b-real-run-runway.md)。
4 > **日期**: 2026-08-18 4 > **日期**: 2026-08-18
5 -> **状态**: **批 1、批 2 已实施并全绿**(2026-08-18,见 §十三 / §十四 实施记录)。批 3-5 未开工。开放问题已全部关闭。 5 +> **状态**: **批 1-4 已实施并全绿**(见 §十三 / §十四 / §十五 实施记录)。批 5 未开工,预研方案已就绪。开放问题已全部关闭。
6 > **⚠️ 批次与 phase 的对应不是一一对齐的**,判断「这份能不能搬进 done」时按批看,别按文档名看: 6 > **⚠️ 批次与 phase 的对应不是一一对齐的**,判断「这份能不能搬进 done」时按批看,别按文档名看:
7 > · **批 1-3 = 真正的 P3 收尾**(goal/task 两级模型 + `task-planning` + 排队),本文档的主体; 7 > · **批 1-3 = 真正的 P3 收尾**(goal/task 两级模型 + `task-planning` + 排队),本文档的主体;
8 > · **批 4(并行)归 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3**,那边是设计权威,本文档只服从(见 §五.2 的警示块); 8 > · **批 4(并行)归 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3**,那边是设计权威,本文档只服从(见 §五.2 的警示块);
@@ -983,3 +983,85 @@ grep 确认 `reportRunOutcome` 无其它调用方(连它自己那行 `module.e @@ -983,3 +983,85 @@ grep 确认 `reportRunOutcome` 无其它调用方(连它自己那行 `module.e
983 2. **汇报语义是 at-least-once**:exactly-once 要 `/api/agent/task/report` 认幂等键(goal id + 收敛时间),属接收侧改动。 983 2. **汇报语义是 at-least-once**:exactly-once 要 `/api/agent/task/report` 认幂等键(goal id + 收敛时间),属接收侧改动。
984 3. **拆分超时只在本进程存活期间成立**:进程中途被杀会留下 `status='planning'` 的 goal,需要启动时扫描重推(与批 3 的中断恢复一起做)。 984 3. **拆分超时只在本进程存活期间成立**:进程中途被杀会留下 `status='planning'` 的 goal,需要启动时扫描重推(与批 3 的中断恢复一起做)。
985 4. `io-model.check.js` 仍是零调用方(同款前科,批 2 已把 `goal-task-model.check.js` 转成真测试)。 985 4. `io-model.check.js` 仍是零调用方(同款前科,批 2 已把 `goal-task-model.check.js` 转成真测试)。
  986 +
  987 +---
  988 +
  989 +## 十五、批 3 + 批 4 实施记录(2026-08-19)
  990 +
  991 +> 体例同 §十三 / §十四:**设计正文不改写,只在这里对账;冲突处以本节为准。**
  992 +
  993 +### 15.1 落地了什么
  994 +
  995 +**批 3 · 排队**(commit `87b776c`,**该提交由第三方会话在对抗复核完成前误提,见 §15.4**)
  996 +`run_status='queued'` + `RUN_TRANSITIONS` + `syncRunNodesToDef` + `task-scheduler.js`(tick / sweep /
  997 +依赖推导)+ 入队/取消/rerun 端点 + 控制台队列显示与三处可见性修复。
  998 +
  999 +**批 4 · 并行**(本次)
  1000 +`idx_graph_runs_one_active_root` **已 DROP**;`max_parallel_tasks` 从 `company.json` **调用时读**
  1001 +(缺省 1、非法回落 1 并留声、硬上限 20);准入判据「从活跃集外进入活跃集」按额度计数。
  1002 +
  1003 +全量:**128 文件 / 1240 测试全绿**,`npm run web:build` 通过。
  1004 +
  1005 +### 15.2 批 4 的生死线:超发(真进程实跑,不是纸面论证)
  1006 +
  1007 +删掉唯一索引 = 删掉最后一道 DB 兜底,此后全靠 `updateRunStatus` 的准入事务。**用 8 个真 node 进程
  1008 +并发抢坑实测**(每进程最多重试 40 次),四组:
  1009 +
  1010 +| max | 抢到坑的进程 | 库里实际活跃 root run | 判定 |
  1011 +|---|---|---|---|
  1012 +| 1 | 1 | 1 | ✅ |
  1013 +| 2 | 2 | 2 | ✅ |
  1014 +| 3 | 3 | 3 | ✅ |
  1015 +| 5 | 5 | 5 | ✅ |
  1016 +
  1017 +落选进程全部吃 `RunSlotBusyError`(各 40 次全被拒),零 `other` 错误。准入的四步缺一不可:
  1018 +`BEGIN IMMEDIATE` → 事务内重读 → 事务内计数 → 同事务条件更新。
  1019 +
  1020 +### 15.3 批 3 复核的 4 条遗留(本次修完)
  1021 +
  1022 +**N1 🔴 `cancelQueuedRun` 的前提被批 3 自己改没了**
  1023 +它的注释说「对一条还没开跑、没有子 run 的排队 run,`stopRun` 是多余的一圈」——
  1024 +而 `requeueBlockedRootRun` 落地后,`queued` 的 run **可以是跑过一半的**(有 success 节点、
  1025 +有活着的子 run;预算撞顶时引擎只把 root 置 blocked,子 run 要到节点边界才落 paused)。
  1026 +探针实测:取消后节点残留 `ready`+`success`、子 run 残留 `paused / stop_requested=0` ——
  1027 +**那条 running 的子 run 继续调 agent 烧 token**。改为无条件走 `engine.stopRun`。
  1028 +
  1029 +**L1 ⚠️ 恢复路径对 manual 只兑现一半**
  1030 +`graph-engine.js` 写着「『T1 永远回不来』这个后果因此消失」,但调度器 `filter(execution_mode === 'auto')`
  1031 +故意不碰 manual → 人点「启动」→ 退回队列 → 坑空了 → **没人接**。
  1032 +定案:**「manual 不自动出队」约束的是首次起跑的授权**;一条已被人亲手起过、因坑满被退回队列的
  1033 +manual,授权早就给过了,让它自己接回来是**兑现人刚才那次点击**,不是新授权。
  1034 +判据用 **`attempt > 0`** 而不是 `started_at` —— 后者看着更贴切,但 `requeueBlockedRootRun`
  1035 +退回队列时会把它清成 NULL,**恰好在需要它的那一刻是空的**(实测踩过)。
  1036 +
  1037 +**N2 🟠「退回队列」对用户完全不可见**
  1038 +`startRunSync` 早返回 200 + `{ok:true, requeued:true}`,而 `GraphRunPane.act()` 只在 `!r.ok` 时写
  1039 +`err` → 界面零提示;随后按钮全灰、run 面板换成定义视图、提示语说「节点状态一律 pending」(**假的**)。
  1040 +`requeued` 字段全前端零处读取。现加 `notice` 一行明说「已退回队列排队,坑一空会自动接着跑」。
  1041 +
  1042 +**N3 🟠 `isSlotRaceError` 的判据被探针证伪**
  1043 +注释称 `InvalidTransitionError` = 「已被别人从 queued 推走」。实测:被推走时条件更新
  1044 +**返回 0、不抛**;真正抛它的只有「**调用方声明了非法转换**」。那一支把调用方的 bug 当竞态吞掉
  1045 +且不留日志。已删除该分支(与批 4 删 UNIQUE 那支同一理由:让它吵起来)。
  1046 +
  1047 +> **4 条里有 3 条是同一个模式:注释承诺的前提被本轮自己的改动改没了,注释还留着。**
  1048 +> §十三.3 / §十四.4 已经就这个模式立过两次案,这是第三、四、五次。
  1049 +
  1050 +### 15.4 一次流程事故:代码在复核完成前被第三方提交
  1051 +
  1052 +`87b776c` 由另一个会话(`lemonboss-68`)在 16:56 提交,**而批 3 的对抗复核 17:00 才出结果**
  1053 +(就是 §15.3 那 4 条)。提交说明里的「1201 用例全绿」准确描述了当时那次测试运行,但它压在一份
  1054 +尚未通过复核的代码上,读起来像质量背书。该会话已写交接文档 `27-p3c-batch3-handover.md` 并更正记录。
  1055 +
  1056 +**教训**:多会话共用同一个 worktree 时,「工作区里的改动」不等于「可以提交的改动」——
  1057 +未提交本身可能就是一种状态声明(这里是「在等复核」)。提交前问一句的成本,远低于把一份
  1058 +已知有缺陷的代码写进 main 的成本。
  1059 +
  1060 +### 15.5 留给下一轮
  1061 +
  1062 +1. **边界守卫会误判并行会话的改动**:它比对整仓 `git status` 差集,那里面**没有「谁写的」这一维**,
  1063 + 多会话共用 worktree 时必然把开发会话的改动算到当时在跑的图节点头上(实测已导致一条 run 被判
  1064 + `boundary_escape`)。批 3 修的是这条日志的**可见性**(列出文件清单),**误判本身没修**。
  1065 +2. **机器级闸门仍然不存在**:`SessionPool` 没有 `maxSessions`,`task-queue.js` 另有个 `MAX_CONCURRENT=3`
  1066 + 抢同一台机器。批 4 只放开了公司级额度(硬上限 20),机器压不压得住没有任何守卫。
  1067 +3. 批 5(两套任务体系统一)未开工,预研方案已就绪。
server/routes/company.js
@@ -1207,11 +1207,13 @@ router.post(&#39;/:companyId/org-graph/replan&#39;, (req, res) =&gt; { @@ -1207,11 +1207,13 @@ router.post(&#39;/:companyId/org-graph/replan&#39;, (req, res) =&gt; {
1207 // 但只在 **goal 粒度**复用一次,见 `services/task-settle.js` 与下方缺陷 3 那段注释。 1207 // 但只在 **goal 粒度**复用一次,见 `services/task-settle.js` 与下方缺陷 3 那段注释。
1208 1208
1209 /** 1209 /**
1210 - * v1 约束:一家公司同时只有一个 active root run(27-P3P4 §一非目标)。 1210 + * 「这条 root run 占着执行位」的取值域(27-P3P4 §一非目标;批 4 起同时能占几个由
  1211 + * `company.json.max_parallel_tasks` 定,缺省 1 —— 取值域本身没变)。
1211 * 1212 *
1212 - * **取值域只有一个持有者**:`work-graph-db.ACTIVE_ROOT_RUN_STATUS` —— 它同时是唯一索引  
1213 - * `idx_graph_runs_one_active_root` 的 WHERE 子句、是准入事务数坑的判据、是调度器判「坑空了没」  
1214 - * 的判据。本文件以前抄了一份字面量在这里,而 work-graph-db 的注释白纸黑字写着「这里是全仓库 1213 + * **取值域只有一个持有者**:`work-graph-db.ACTIVE_ROOT_RUN_STATUS` —— 它是准入事务数位子的
  1214 + * 判据、是调度器判「还有没有空位」的判据(批 4 之前它还兼任唯一索引
  1215 + * `idx_graph_runs_one_active_root` 的 WHERE,那条索引已 DROP,判据只剩准入事务这一处)。
  1216 + * 本文件以前抄了一份字面量在这里,而 work-graph-db 的注释白纸黑字写着「这里是全仓库
1215 * 唯一的定义处」—— **注释是对的、代码没兑现**,正是 §十四.3 第 3 条记着的那个形状。 1217 * 唯一的定义处」—— **注释是对的、代码没兑现**,正是 §十四.3 第 3 条记着的那个形状。
1216 * 现在改成取过来。惰性取值(模块加载期 require 会与路由的加载顺序纠缠)。 1218 * 现在改成取过来。惰性取值(模块加载期 require 会与路由的加载顺序纠缠)。
1217 */ 1219 */
@@ -1223,9 +1225,9 @@ const activeRootRunStatus = () =&gt; require(&#39;../services/work-graph-db&#39;).ACTIVE_RO @@ -1223,9 +1225,9 @@ const activeRootRunStatus = () =&gt; require(&#39;../services/work-graph-db&#39;).ACTIVE_RO
1223 * = 上面那个活跃集 **+ `blocked`**,而且必须由它派生、不许再抄一份字面量:§九.1 记着的教训就是 1225 * = 上面那个活跃集 **+ `blocked`**,而且必须由它派生、不许再抄一份字面量:§九.1 记着的教训就是
1224 * `ACTIVE_ROOT_RUN_STATUS` 在两个文件里逐字重复,改一处漏一处。 1226 * `ACTIVE_ROOT_RUN_STATUS` 在两个文件里逐字重复,改一处漏一处。
1225 * 1227 *
1226 - * 为什么 `blocked` 不直接并进 `ACTIVE_ROOT_RUN_STATUS`:那个常量是  
1227 - * `idx_graph_runs_one_active_root` 这条唯一索引的镜像(索引里就没有 blocked),  
1228 - * 动它等于悄悄改「一家公司同时只能有一个 active root run」的口径。 1228 + * 为什么 `blocked` 不直接并进 `ACTIVE_ROOT_RUN_STATUS`:那个常量是**准入事务数位子**的判据
  1229 + * (批 4 之前还兼任唯一索引 `idx_graph_runs_one_active_root` 的 WHERE,索引里就没有 blocked),
  1230 + * 动它等于悄悄改「一条 run 占不占执行位」的口径 —— blocked 的 run 不占位,可以再起别的任务。
1229 * 但对归档来说 blocked 恰恰**不能**放行:run 转 blocked 会把 task 也写成 blocked, 1231 * 但对归档来说 blocked 恰恰**不能**放行:run 转 blocked 会把 task 也写成 blocked,
1230 * 人加完预算 `POST /api/graph-runs/:id/start` 就接着烧钱,而 task 行仍停在 blocked —— 1232 * 人加完预算 `POST /api/graph-runs/:id/start` 就接着烧钱,而 task 行仍停在 blocked ——
1231 * 只看 `task.status === 'running'` 的旧守卫会放行,用户就把一条正在跑的 run 从界面上藏掉了。 1233 * 只看 `task.status === 'running'` 的旧守卫会放行,用户就把一条正在跑的 run 从界面上藏掉了。
@@ -1345,9 +1347,10 @@ function writeTaskGoalArtifact(companyId, rootRunId, payload) { @@ -1345,9 +1347,10 @@ function writeTaskGoalArtifact(companyId, rootRunId, payload) {
1345 // 1347 //
1346 // 本批(批 1)只落地同步的那一半:建行 / 改行 / 归档 / 下达即起 run。 1348 // 本批(批 1)只落地同步的那一半:建行 / 改行 / 归档 / 下达即起 run。
1347 // **排队不在本批**(run_status='queued'、调度器、depends_on 的出队判据都是批 3), 1349 // **排队不在本批**(run_status='queued'、调度器、depends_on 的出队判据都是批 3),
1348 -// **自动拆分也不在**(task-planning 是批 2)。所以这里一次只起得来一条 run,起不来就是 409 ——  
1349 -// 与既有唯一索引 idx_graph_runs_one_active_root 的语义保持一致,绝不假装「已排上队」: 1350 +// **自动拆分也不在**(task-planning 是批 2)。所以批 1 一次只起得来一条 run,起不来就是 409 ——
  1351 +// 与当时那条唯一索引 idx_graph_runs_one_active_root 的语义一致,绝不假装「已排上队」:
1350 // 仓库里还没有出队的 worker,排进去的任务不会被消费,那是一条会静默丢任务的路。 1352 // 仓库里还没有出队的 worker,排进去的任务不会被消费,那是一条会静默丢任务的路。
  1353 +// (批 3 有了调度器之后下达改成入队、不再 409;批 4 DROP 了那条索引,执行位数改为 per-company。)
1351 1354
1352 /** 1355 /**
1353 * 数据层的两个专有错误 → HTTP(§四.4 描述冻结 / §六.3 依赖校验)。 1356 * 数据层的两个专有错误 → HTTP(§四.4 描述冻结 / §六.3 依赖校验)。
@@ -1412,14 +1415,36 @@ function deriveTitle(text) { @@ -1412,14 +1415,36 @@ function deriveTitle(text) {
1412 return t.length > 60 ? `${t.slice(0, 60)}…` : t; 1415 return t.length > 60 ? `${t.slice(0, 60)}…` : t;
1413 } 1416 }
1414 1417
1415 -/** 当前占着坑的那条 root run;没有返回 null */  
1416 -function activeRootRun(companyId) {  
1417 - const { listCompanyRuns } = require('../services/work-graph-db');  
1418 - return (  
1419 - listCompanyRuns(companyId, { rootOnly: true }).find((r) =>  
1420 - activeRootRunStatus().includes(r.run_status)  
1421 - ) || null  
1422 - ); 1418 +/**
  1419 + * 这家公司此刻的执行位:`{ max, used, free, holders }`。
  1420 + *
  1421 + * **形状与判据都不在这里**,取 `routes/graph-run.js` 那唯一一份(它同时供 409 用)。
  1422 + * 批 4 之前这里是 `activeRootRun(companyId)`:`listCompanyRuns(...).find(活跃)`,
  1423 + * 两个调用方各自误用它的一半,max 一放开两处都错:
  1424 + * · `startNextIfSlotFree` 拿它当「满没满」——它的语义是「有没有」,
  1425 + * 3 个位空着 2 个时 manual 任务永远起不来,端点还照常回「排队中」(写不进去还不报错);
  1426 + * · `/tasks/:id/start` 的 409 拿它当「谁挡着我」—— 它取的是 created_at **DESC** 的第一条,
  1427 + * 而 `RunSlotBusyError.heldBy` 是 **ASC**,同一次拒绝的两个字段会指向不同的占位者。
  1428 + */
  1429 +function slotsOf(companyId) {
  1430 + return require('./graph-run').slotState(companyId);
  1431 +}
  1432 +
  1433 +/**
  1434 + * 列表端点上给界面的那份执行位快照:`{ max, used, free }`。
  1435 + *
  1436 + * 为什么落在 `GET /goals` 与 `GET /tasks` 上而不是 `GET /graph-runs`:控制台就是靠这两个
  1437 + * 端点轮询左栏的,多一个请求就多一个「两份数据不同时刻」的窗口;而 graph-runs 那边只是列 run,
  1438 + * 在它上面再数一遍 root run 就是第三个持有者(批 4 把它的 canDispatch 删掉正是为此)。
  1439 + *
  1440 + * **holders 不给**:列表页只回答「还能不能下达 / 要不要等」,不需要点名谁占着;
  1441 + * 真被拒绝时 409 会带着 holders 回来(同一个 `slotState` 产出,形状逐字相同)。
  1442 + * 前端 `goal-task-model.readSlots` 也只读这三个数,且**不自己做 `max - used`** ——
  1443 + * free 带着「max 被改小时截断到 0」的语义,减法算不出来。
  1444 + */
  1445 +function slotsView(companyId) {
  1446 + const { max, used, free } = slotsOf(companyId);
  1447 + return { max, used, free };
1423 } 1448 }
1424 1449
1425 /** goal 行 → 对外形状。库里是 snake_case,API 一律 camelCase(同 getRunState 口径) */ 1450 /** goal 行 → 对外形状。库里是 snake_case,API 一律 camelCase(同 getRunState 口径) */
@@ -1564,8 +1589,8 @@ function isSlotRace(err) { @@ -1564,8 +1589,8 @@ function isSlotRace(err) {
1564 * 并把 task 推到 `queued`(27-P3C 批 3 §五.1)。 1589 * 并把 task 推到 `queued`(27-P3C 批 3 §五.1)。
1565 * 1590 *
1566 * 与批 1/批 2 的 `launchTaskRun` 的差别就是「不起跑」: 1591 * 与批 1/批 2 的 `launchTaskRun` 的差别就是「不起跑」:
1567 - * · `createRun({runStatus:'queued'})` **不占** `idx_graph_runs_one_active_root` 的坑  
1568 - * (索引的 WHERE 只认 ready/running/paused),所以队列可以任意长,入队永不 409; 1592 + * · `createRun({runStatus:'queued'})` **不占执行位**(占位的取值域只认 ready/running/paused,
  1593 + * 见 `activeRootRunStatus`),所以队列可以任意长,入队永不 409;
1569 * · 起跑那一跳(queued → ready → running)只有两个执行者:auto 归 `task-scheduler.tick`, 1594 * · 起跑那一跳(queued → ready → running)只有两个执行者:auto 归 `task-scheduler.tick`,
1570 * manual 归 `promoteQueuedTask`(人点了「下达 / 入队 / 重跑」才走)。 1595 * manual 归 `promoteQueuedTask`(人点了「下达 / 入队 / 重跑」才走)。
1571 * 1596 *
@@ -1725,36 +1750,65 @@ function abandonRun(runId, why) { @@ -1725,36 +1750,65 @@ function abandonRun(runId, why) {
1725 * 候选顺序取自 `scheduler.listQueue` —— 队列位次(priority → seq → created_at)全仓库 1750 * 候选顺序取自 `scheduler.listQueue` —— 队列位次(priority → seq → created_at)全仓库
1726 * 只有那一个持有者,这里再排一遍就是第二个持有者(canDispatch 那次事故的形状)。 1751 * 只有那一个持有者,这里再排一遍就是第二个持有者(canDispatch 那次事故的形状)。
1727 * 队首可能属于**别的 goal**:队列是公司级的,「我下达的是 goal A,起跑的却是 goal B 的活」 1752 * 队首可能属于**别的 goal**:队列是公司级的,「我下达的是 goal A,起跑的却是 goal B 的活」
1728 - * 正是排队本来的含义,响应里的 started 会如实点名是哪一条。 1753 + * 正是排队本来的含义,响应里的 started 会如实点名是哪几条。
  1754 + *
  1755 + * 批 4:一次可能起跑**不止一条**(公司有 N 个执行位),所以返回数组。批 3 那两处
  1756 + * 「只报第一条 / 抢不到就不再试下一条」在 max=1 时等价,max>1 时一个漏说、一个漏起。
1729 * 1757 *
1730 - * @returns {{runId, run, task}|null} 本次真起跑了哪条;null = 没有(坑被占 / 队里没人够格) 1758 + * @returns {Array<{runId, run, task}>} 本次真起跑了哪几条(空数组 = 一条都没起)
1731 */ 1759 */
1732 function startNextIfSlotFree(companyId) { 1760 function startNextIfSlotFree(companyId) {
1733 const scheduler = require('../services/task-scheduler'); 1761 const scheduler = require('../services/task-scheduler');
1734 const wdb = require('../services/work-graph-db'); 1762 const wdb = require('../services/work-graph-db');
1735 const ticked = scheduler.tick(companyId); 1763 const ticked = scheduler.tick(companyId);
  1764 + const started = [];
1736 1765
1737 - // **tick 起走的那条同样要报出来。** 少了这一段,auto 模式下 dispatch 明明起跑了一条任务, 1766 + // **tick 起走的那几条同样要报出来。** 少了这一段,auto 模式下 dispatch 明明起跑了任务,
1738 // 响应里却没有 `started` —— 前端 `dispatchedTaskId` 于是退回 `queued[0]`,选中态跳到 1767 // 响应里却没有 `started` —— 前端 `dispatchedTaskId` 于是退回 `queued[0]`,选中态跳到
1739 // 一条还在排队的任务上,用户点了「全部下达」却看不到正在跑的那张图,而接口回的是 201。 1768 // 一条还在排队的任务上,用户点了「全部下达」却看不到正在跑的那张图,而接口回的是 201。
1740 // 「做了但不说」和「说了没做」是同一类撒谎,都在本批要消灭的名单上。 1769 // 「做了但不说」和「说了没做」是同一类撒谎,都在本批要消灭的名单上。
1741 - if (ticked.started && ticked.started.length) {  
1742 - const s = ticked.started[0]; 1770 + // 批 4 从 `started[0]` 改成全部:一次 tick 就能填满剩余的 N 个位,只报第一条是同一句谎。
  1771 + for (const s of ticked.started || []) {
1743 const task = wdb.getTask(s.taskId); 1772 const task = wdb.getTask(s.taskId);
1744 - if (task) return { runId: s.runId, run: wdb.getRun(s.runId) || {}, task }; 1773 + if (task) started.push({ runId: s.runId, run: wdb.getRun(s.runId) || {}, task });
1745 } 1774 }
1746 1775
1747 - // 坑被占着就到此为止:v1 一次只跑一条(MAX_ACTIVE_ROOT_RUNS)  
1748 - if (activeRootRun(companyId)) return null;  
1749 -  
1750 for (const t of scheduler.listQueue(companyId)) { 1776 for (const t of scheduler.listQueue(companyId)) {
1751 // auto 归调度器:这里再抢一次会绕开它「起不来就把 task 收回可见终态」的整套善后 1777 // auto 归调度器:这里再抢一次会绕开它「起不来就把 task 收回可见终态」的整套善后
1752 if (t.execution_mode !== 'manual') continue; 1778 if (t.execution_mode !== 'manual') continue;
1753 if (blockersOf(t).length) continue; // 上游没做完,依赖是硬约束 1779 if (blockersOf(t).length) continue; // 上游没做完,依赖是硬约束
1754 - // 抢不到就**不再试下一条**:坑只有一个,抢不到说明它已经没了  
1755 - return promoteQueuedTask(t);  
1756 - }  
1757 - return null; 1780 + // 到这儿才问「还有位吗」,且每一轮都**重算**:刚起跑一条就少一个,而且别人也在抢。
  1781 + // 批 4 之前这里是循环外一句 `if (activeRootRun(companyId)) return null;` ——
  1782 + // 「有没有」当成「满没满」,3 个位空着 2 个时 manual 任务永远起不来,端点却照常回「排队中」。
  1783 + // ponytail: 每轮两次读(活跃 root run 一条查询 + company.json 一次 readFile)。
  1784 + // 两条便宜的过滤排在它前面,所以只有真候选才付这个钱;长队列真成问题时把 free 缓存进循环、
  1785 + // 按起跑数递减即可,别把判据挪到别处去。
  1786 + if (slotsOf(companyId).free <= 0) break;
  1787 + const promoted = promoteQueuedTask(t);
  1788 + if (promoted) started.push(promoted);
  1789 + // 没抢到**不等于没位了**:promoteQueuedTask 把「位满」「被别人推走」「认领失败」都归成 null,
  1790 + // 而后两种下一条照样起得来。真满了由上面那句 free<=0 在下一轮 break —— 判据只留一处。
  1791 + }
  1792 + return started;
  1793 +}
  1794 +
  1795 +/** 起跑了哪条 → 对外形状(三个端点此前各拼了一份,字段还不完全一样) */
  1796 +function startedView(s) {
  1797 + return { taskId: s.task.id, goalId: s.task.goal_id, runId: s.runId, runStatus: (s.run || {}).run_status };
  1798 +}
  1799 +
  1800 +/**
  1801 + * 响应里「本次起跑了什么」的两个字段。
  1802 + *
  1803 + * `started` 仍是**单数**——前端 `dispatchedTaskId` 认的就是它,形状与批 3 逐字相同,
  1804 + * 不是破坏性变更;`startedTasks` 是本次起跑的全部。
  1805 + * 两者**同源**:`started = startedTasks[0]`,不是另算一遍,所以分叉不出来。
  1806 + */
  1807 +function startedFields(list) {
  1808 + const arr = (Array.isArray(list) ? list : [list]).filter(Boolean);
  1809 + if (!arr.length) return {};
  1810 + const views = arr.map(startedView);
  1811 + return { started: views[0], startedTasks: views };
1758 } 1812 }
1759 1813
1760 /** 1814 /**
@@ -1764,9 +1818,18 @@ function startNextIfSlotFree(companyId) { @@ -1764,9 +1818,18 @@ function startNextIfSlotFree(companyId) {
1764 * `task-settle` 把 task 收成 cancelled(`RUN_TO_TASK_STATUS.stopped = 'cancelled'`)—— 1818 * `task-settle` 把 task 收成 cancelled(`RUN_TO_TASK_STATUS.stopped = 'cancelled'`)——
1765 * 于是「取消排队」这件事在库里、时间线上、界面上是同一件事,不用在这里再写一遍 task 状态。 1819 * 于是「取消排队」这件事在库里、时间线上、界面上是同一件事,不用在这里再写一遍 task 状态。
1766 * 1820 *
1767 - * 不用 `engine.stopRun`:它会先把树上所有 run 置 `stop_requested=1` 再自底向上收,  
1768 - * 对一条还没开跑、没有子 run 的排队 run 是多余的一圈;而且它转的是 `fresh.run_status → stopped`,  
1769 - * 语义上看不出「这条是从队列里撤下来的」。 1821 + * ⚠️ **这里原本写着「不用 engine.stopRun:对一条还没开跑、没有子 run 的排队 run 是多余的一圈」,
  1822 + * 那个前提被批 3 自己的 `requeueBlockedRootRun` 改没了。** 恢复类转换撞坑满会把 run 退回队列,
  1823 + * 所以 `run_status='queued'` 的 run **可以是跑过一半的**:有 success 节点、有 ready 节点、
  1824 + * 还可能挂着活着的子 run(预算撞顶时引擎只把 root 置 blocked,子 run 要到节点边界才落 paused,
  1825 + * 也就是父 blocked 时子 run 可以仍在 running)。
  1826 + *
  1827 + * 探针实测过后果:只发一次 `queued → stopped` 的话,root 落 stopped、task 显示「已取消」,
  1828 + * 而那条 running 的子 run **既没收到 stop_requested、也没被 settle,继续调 agent 烧 token**。
  1829 + *
  1830 + * 所以改成无条件走 `engine.stopRun`:它置整棵树的 `stop_requested`、自底向上收敛、
  1831 + * 把 pending/ready 节点 skip 掉。对一条真·空白的排队 run,多出来的那一圈只是几条命中 0 行的
  1832 + * 条件更新 —— **拿这点开销换「绝不留下还在烧钱的子 run」是划算的**。
1770 * 1833 *
1771 * @returns {boolean} true = 本次由我撤下的;false = 它已经不在队里了(被抢跑 / 已撤过) 1834 * @returns {boolean} true = 本次由我撤下的;false = 它已经不在队里了(被抢跑 / 已撤过)
1772 */ 1835 */
@@ -1776,10 +1839,10 @@ function cancelQueuedRun(task) { @@ -1776,10 +1839,10 @@ function cancelQueuedRun(task) {
1776 if (!runId) return false; 1839 if (!runId) return false;
1777 const run = wdb.getRun(runId); 1840 const run = wdb.getRun(runId);
1778 if (!run || run.run_status !== 'queued') return false; 1841 if (!run || run.run_status !== 'queued') return false;
1779 - return Boolean(require('../services/graph-run-sse').transitionRun(runId, 'queued', 'stopped', {}, {  
1780 - summary: `取消排队:queued → stopped(task ${task.id})`,  
1781 - task_id: task.id,  
1782 - })); 1842 + // stopRun 内部逐层走 transitionRun(同一个漏斗),所以 timeline / SSE / task-settle 的
  1843 + // 收敛回写照旧;这里只负责把「整棵树」交给它,不再自己发那一条转换。
  1844 + require('../services/graph-engine').stopRun(runId);
  1845 + return Boolean(wdb.getRun(runId) && wdb.getRun(runId).run_status === 'stopped');
1783 } 1846 }
1784 1847
1785 /** 1848 /**
@@ -1787,7 +1850,11 @@ function cancelQueuedRun(task) { @@ -1787,7 +1850,11 @@ function cancelQueuedRun(task) {
1787 * 1850 *
1788 * `started` 只在**本次请求真的把某条 task 起跑了**时出现,且它可能不是 body 里那条 1851 * `started` 只在**本次请求真的把某条 task 起跑了**时出现,且它可能不是 body 里那条
1789 * (队首属于别的 goal 时)—— 前端 `dispatchedTaskId` 认的就是它。 1852 * (队首属于别的 goal 时)—— 前端 `dispatchedTaskId` 认的就是它。
  1853 + * 批 4 起一次可能起跑多条(N 个执行位),全部在 `startedTasks` 里,`started` = 它的第一条。
1790 * `queuePosition` 一律现算(`task-scheduler.queuePosition`),起跑了的那条不在队里 → null。 1854 * `queuePosition` 一律现算(`task-scheduler.queuePosition`),起跑了的那条不在队里 → null。
  1855 + *
  1856 + * @param {Array|object|null} started `startNextIfSlotFree` 的返回值(数组),
  1857 + * 或 `promoteQueuedTask` 的单条返回值 —— `startedFields` 两种都收。
1791 */ 1858 */
1792 function queueReply(companyId, task, started) { 1859 function queueReply(companyId, task, started) {
1793 const { queuePosition } = require('../services/task-scheduler'); 1860 const { queuePosition } = require('../services/task-scheduler');
@@ -1801,9 +1868,7 @@ function queueReply(companyId, task, started) { @@ -1801,9 +1868,7 @@ function queueReply(companyId, task, started) {
1801 runId, 1868 runId,
1802 status: fresh.status, 1869 status: fresh.status,
1803 queuePosition: queuePosition(companyId, fresh.id) || null, 1870 queuePosition: queuePosition(companyId, fresh.id) || null,
1804 - ...(started  
1805 - ? { started: { taskId: started.task.id, goalId: started.task.goal_id, runId: started.runId, runStatus: started.run.run_status } }  
1806 - : {}), 1871 + ...startedFields(started),
1807 ...(runId ? { links: runLinks(runId) } : {}), 1872 ...(runId ? { links: runLinks(runId) } : {}),
1808 }; 1873 };
1809 } 1874 }
@@ -2089,7 +2154,7 @@ router.get(&#39;/:companyId/goals&#39;, (req, res) =&gt; { @@ -2089,7 +2154,7 @@ router.get(&#39;/:companyId/goals&#39;, (req, res) =&gt; {
2089 const goals = wdb.listCompanyGoals(companyId, { status: req.query.status || null, includeArchived }); 2154 const goals = wdb.listCompanyGoals(companyId, { status: req.query.status || null, includeArchived });
2090 2155
2091 if (req.query.withTasks !== '1' && req.query.withTasks !== 'true') { 2156 if (req.query.withTasks !== '1' && req.query.withTasks !== 'true') {
2092 - return res.json({ goals: goals.map((g) => goalView(g)) }); 2157 + return res.json({ goals: goals.map((g) => goalView(g)), slots: slotsView(companyId) });
2093 } 2158 }
2094 2159
2095 const tasks = wdb.listCompanyTasks(companyId, { includeArchived }); 2160 const tasks = wdb.listCompanyTasks(companyId, { includeArchived });
@@ -2111,6 +2176,7 @@ router.get(&#39;/:companyId/goals&#39;, (req, res) =&gt; { @@ -2111,6 +2176,7 @@ router.get(&#39;/:companyId/goals&#39;, (req, res) =&gt; {
2111 .map((t) => taskView(t, progress.get(t.current_run_id), positions)) 2176 .map((t) => taskView(t, progress.get(t.current_run_id), positions))
2112 ) 2177 )
2113 ), 2178 ),
  2179 + slots: slotsView(companyId),
2114 }); 2180 });
2115 } catch (err) { 2181 } catch (err) {
2116 console.error('[TaskEntry] Error:', err.message); 2182 console.error('[TaskEntry] Error:', err.message);
@@ -2323,13 +2389,14 @@ router.patch(&#39;/:companyId/goals/:goalId&#39;, (req, res) =&gt; { @@ -2323,13 +2389,14 @@ router.patch(&#39;/:companyId/goals/:goalId&#39;, (req, res) =&gt; {
2323 /** 2389 /**
2324 * POST /api/company/:companyId/goals/:goalId/dispatch — 下达这个 goal 2390 * POST /api/company/:companyId/goals/:goalId/dispatch — 下达这个 goal
2325 * 2391 *
2326 - * **批 1 的语义 = 下达即执行,不入队**:给 goal 盖上 dispatched_at(描述从此冻结),  
2327 - * 然后按 seq 起**第一条上游就绪的** draft task 的 run。其余 draft 原地不动,响应里如实列出来。 2392 + * **现在的语义(批 3 起)= 下达即入队**:给 goal 盖上 dispatched_at(描述从此冻结),
  2393 + * 把它下面**所有** draft task 入队,再推一次队伍(见下方 startNextIfSlotFree);
  2394 + * 起跑了哪几条如实写在响应的 `started` / `startedTasks` 里。
2328 * 2395 *
2329 - * 为什么不是「全部入队」:v1 一家公司同时只有一个 active root run(唯一索引  
2330 - * idx_graph_runs_one_active_root),而出队的 worker 是批 3 才有的东西。把 N 条都标成  
2331 - * 「已入队」而没有任何东西会把它们推走,就是把「静默丢任务」写进数据 —— 宁可让用户看到  
2332 - * 「还有 2 条待下达」,也不要给一个不兑现的承诺。 2396 + * 批 1 的语义是「只起第一条上游就绪的,其余原地不动」,理由是:当时一家公司同时只有一个
  2397 + * active root run(唯一索引 idx_graph_runs_one_active_root),而出队的 worker 是批 3 才有的
  2398 + * 东西 —— 把 N 条都标成「已入队」而没有任何东西会把它们推走,就是把「静默丢任务」写进数据。
  2399 + * 批 3 有了调度器,这条理由消失;批 4 DROP 了那条索引,执行位数改为 per-company。
2333 * 2400 *
2334 * **依赖是硬约束**(缺陷 F6 + 第二轮复核 缺陷 4):绝不起一条上游没跑完的 task —— 2401 * **依赖是硬约束**(缺陷 F6 + 第二轮复核 缺陷 4):绝不起一条上游没跑完的 task ——
2335 * 「接口收下了 depends_on、返回成功、随后无声地违反它」比不支持依赖更糟:用户设了 2402 * 「接口收下了 depends_on、返回成功、随后无声地违反它」比不支持依赖更糟:用户设了
@@ -2363,8 +2430,8 @@ router.post(&#39;/:companyId/goals/:goalId/dispatch&#39;, (req, res) =&gt; { @@ -2363,8 +2430,8 @@ router.post(&#39;/:companyId/goals/:goalId/dispatch&#39;, (req, res) =&gt; {
2363 2430
2364 // ⚠️ **这里以前有一道「公司已有 active root run → 409」的预检查,批 3 删掉了它** —— 2431 // ⚠️ **这里以前有一道「公司已有 active root run → 409」的预检查,批 3 删掉了它** ——
2365 // 那正是本批要消灭的东西:下达不再因为「有别的任务在跑」被拒绝,而是排队。 2432 // 那正是本批要消灭的东西:下达不再因为「有别的任务在跑」被拒绝,而是排队。
2366 - // 并发下真正的守卫仍在(`updateRunStatus` 的准入事务 + 唯一索引),只是它现在守的是  
2367 - // **出队**那一跳,不是入队。入队永不因坑满失败:queued 的 run 不占坑。 2433 + // 并发下真正的守卫仍在(`updateRunStatus` 的准入事务;批 4 DROP 掉唯一索引之后它是唯一那道),
  2434 + // 只是它守的是**出队**那一跳,不是入队。入队永不因位满失败:queued 的 run 不占执行位。
2368 // 2435 //
2369 // 同样删掉的还有「挑第一条跑得动的 draft、全都跑不动就 409」那一段。§六.2 写的是 2436 // 同样删掉的还有「挑第一条跑得动的 draft、全都跑不动就 409」那一段。§六.2 写的是
2370 // 「把该 goal 下**所有** draft task 入队」—— 有了队列,依赖没就绪不再是拒绝的理由, 2437 // 「把该 goal 下**所有** draft task 入队」—— 有了队列,依赖没就绪不再是拒绝的理由,
@@ -2396,13 +2463,12 @@ router.post(&#39;/:companyId/goals/:goalId/dispatch&#39;, (req, res) =&gt; { @@ -2396,13 +2463,12 @@ router.post(&#39;/:companyId/goals/:goalId/dispatch&#39;, (req, res) =&gt; {
2396 goalStatus: dispatchedGoal.status, 2463 goalStatus: dispatchedGoal.status,
2397 // 本次入队的全部 task 及其位次。起跑了的那条不在队里 → position 为 null(不是 0) 2464 // 本次入队的全部 task 及其位次。起跑了的那条不在队里 → position 为 null(不是 0)
2398 queued: enqueued.map((e) => ({ taskId: e.task.id, runId: e.runId, queuePosition: positions.get(e.task.id) || null })), 2465 queued: enqueued.map((e) => ({ taskId: e.task.id, runId: e.runId, queuePosition: positions.get(e.task.id) || null })),
2399 - // `started` 只在本次请求真的把某条 task 起跑了时出现,**且它未必是本次入队的那几条**:  
2400 - // 队列是公司级的,坑空时顶上来的可能是别的 goal 早就排着的活。前端 dispatchedTaskId  
2401 - // 认的就是它 —— 如实点名比假装「你下达的那条起跑了」有用。  
2402 - ...(started  
2403 - ? { started: { taskId: started.task.id, goalId: started.task.goal_id, runId: started.runId, runStatus: started.run.run_status } }  
2404 - : {}),  
2405 - ...(started ? { links: runLinks(started.runId) } : {}), 2466 + // `started` / `startedTasks` 只在本次请求真的把 task 起跑了时出现,**且未必是本次入队的那几条**:
  2467 + // 队列是公司级的,位子空时顶上来的可能是别的 goal 早就排着的活。前端 dispatchedTaskId
  2468 + // 认的就是 `started` —— 如实点名比假装「你下达的那条起跑了」有用。
  2469 + // 批 4:公司有 N 个执行位时一次能起跑多条,全在 `startedTasks` 里(`started` = 它的第一条)。
  2470 + ...startedFields(started),
  2471 + ...(started.length ? { links: runLinks(started[0].runId) } : {}),
2406 }); 2472 });
2407 } catch (err) { 2473 } catch (err) {
2408 console.error('[TaskEntry] Error:', err.message); 2474 console.error('[TaskEntry] Error:', err.message);
@@ -2511,7 +2577,10 @@ router.get(&#39;/:companyId/tasks&#39;, (req, res) =&gt; { @@ -2511,7 +2577,10 @@ router.get(&#39;/:companyId/tasks&#39;, (req, res) =&gt; {
2511 }); 2577 });
2512 const progress = progressOf(tasks); 2578 const progress = progressOf(tasks);
2513 const positions = queuePositions(req.params.companyId); 2579 const positions = queuePositions(req.params.companyId);
2514 - res.json({ tasks: tasks.map((t) => taskView(t, progress.get(t.current_run_id), positions)) }); 2580 + res.json({
  2581 + tasks: tasks.map((t) => taskView(t, progress.get(t.current_run_id), positions)),
  2582 + slots: slotsView(req.params.companyId),
  2583 + });
2515 } catch (err) { 2584 } catch (err) {
2516 console.error('[TaskEntry] Error:', err.message); 2585 console.error('[TaskEntry] Error:', err.message);
2517 res.status(500).json({ error: err.message }); 2586 res.status(500).json({ error: err.message });
@@ -2572,7 +2641,7 @@ router.post(&#39;/:companyId/tasks&#39;, (req, res) =&gt; { @@ -2572,7 +2641,7 @@ router.post(&#39;/:companyId/tasks&#39;, (req, res) =&gt; {
2572 // ⚠️ **这里以前有一道「公司已有 active root run → 409」的预检查,批 3 删掉了它。** 2641 // ⚠️ **这里以前有一道「公司已有 active root run → 409」的预检查,批 3 删掉了它。**
2573 // 这条 409 就是本批要消灭的那个东西:外部 agent 的 curl(URL 与请求体写在 tools-base.md 里, 2642 // 这条 409 就是本批要消灭的那个东西:外部 agent 的 curl(URL 与请求体写在 tools-base.md 里,
2574 // 等于所有 agent 的提示词)撞上它只能自己重试或放弃。现在改成入队 —— 建一条 2643 // 等于所有 agent 的提示词)撞上它只能自己重试或放弃。现在改成入队 —— 建一条
2575 - // `run_status='queued'` 的 root run,它**不占**唯一索引的坑,所以入队永不失败。 2644 + // `run_status='queued'` 的 root run,它**不占**执行位,所以入队永不失败。
2576 const createdBy = body.created_by || body.from || 'human'; 2645 const createdBy = body.created_by || body.from || 'human';
2577 let goal; 2646 let goal;
2578 if (body.goal_id) { 2647 if (body.goal_id) {
@@ -2601,7 +2670,7 @@ router.post(&#39;/:companyId/tasks&#39;, (req, res) =&gt; { @@ -2601,7 +2670,7 @@ router.post(&#39;/:companyId/tasks&#39;, (req, res) =&gt; {
2601 description, 2670 description,
2602 executionMode: mode, 2671 executionMode: mode,
2603 priority: body.priority || goal.priority, 2672 priority: body.priority || goal.priority,
2604 - // 落库(缺陷 F3):本次起不来 run 时(唯一索引 409)这行就是草稿,hint 让用户稍后 2673 + // 落库(缺陷 F3):本次起不来 run 时(批 3 之前是 409,现在是排队)这行就是草稿,hint 让用户稍后
2605 // dispatch —— 那时请求体早没了,不存下来 dispatch 只能用默认额度,而用户以为自己填的算数。 2674 // dispatch —— 那时请求体早没了,不存下来 dispatch 只能用默认额度,而用户以为自己填的算数。
2606 budgetJson: body.budget !== undefined ? body.budget : null, 2675 budgetJson: body.budget !== undefined ? body.budget : null,
2607 // 显式传 report_to 优先,其次继承 goal 的(数据层也会兜一次,这里传的是本次请求的意图) 2676 // 显式传 report_to 优先,其次继承 goal 的(数据层也会兜一次,这里传的是本次请求的意图)
@@ -2641,9 +2710,9 @@ router.post(&#39;/:companyId/tasks&#39;, (req, res) =&gt; { @@ -2641,9 +2710,9 @@ router.post(&#39;/:companyId/tasks&#39;, (req, res) =&gt; {
2641 task: description, 2710 task: description,
2642 taskGoalArtifact: path.relative(REPO_ROOT, launched.artifact), 2711 taskGoalArtifact: path.relative(REPO_ROOT, launched.artifact),
2643 budget: body.budget || DEFAULT_TASK_BUDGET, 2712 budget: body.budget || DEFAULT_TASK_BUDGET,
2644 - ...(started && started.task.id !== taskRow.id  
2645 - ? { started: { taskId: started.task.id, goalId: started.task.goal_id, runId: started.runId } }  
2646 - : {}), 2713 + // 本次这条已经在上面逐字报过了(runId / status / runStatus),这里只报**别人**:
  2714 + // 顶上来的可能是别的 goal 早就排着的活,一次可能不止一条(批 4 的 N 个执行位)。
  2715 + ...startedFields(started.filter((s) => s.task.id !== taskRow.id)),
2647 links: runLinks(launched.runId), 2716 links: runLinks(launched.runId),
2648 }); 2717 });
2649 } catch (err) { 2718 } catch (err) {
@@ -2825,20 +2894,24 @@ router.post(&#39;/:companyId/tasks/:taskId/start&#39;, (req, res) =&gt; { @@ -2825,20 +2894,24 @@ router.post(&#39;/:companyId/tasks/:taskId/start&#39;, (req, res) =&gt; {
2825 // 别回 200 让界面显示「已开始」而实际什么都没跑。 2894 // 别回 200 让界面显示「已开始」而实际什么都没跑。
2826 const started = promoteQueuedTask(task); 2895 const started = promoteQueuedTask(task);
2827 if (!started) { 2896 if (!started) {
2828 - const active = activeRootRun(companyId); 2897 + // 「位子满了」和「这一瞬间被别人推走了」是两件事,答复也不同。
  2898 + // 判据与形状都取 graph-run.js 的 slotState(= 409 run-slot-busy 那一份):
  2899 + // 批 4 之前这里自己 `.find()` 一条 active run + 三个单数字段,
  2900 + // N 个执行位时只点名一条,用户停掉它之后发现还是起不来(其余的位仍占着)。
  2901 + const slots = slotsOf(companyId);
  2902 + const busy = slots.free <= 0;
  2903 + const { slotBusyHint } = require('./graph-run');
2829 return res.status(409).json({ 2904 return res.status(409).json({
2830 - error: active  
2831 - ? `公司 ${companyId} 的执行坑被 run ${active.id} 占着,这条还得排着` 2905 + error: busy
  2906 + ? `公司 ${companyId} 的执行位已满(${slots.used}/${slots.max}),这条还得排着`
2832 : `task ${task.id} 这一瞬间被别人推走了,没抢到`, 2907 : `task ${task.id} 这一瞬间被别人推走了,没抢到`,
  2908 + // 与 graph-run.js 的 409 逐字同形(reason / active / max / holders),
  2909 + // 免得同一件事在两个端点上有两套字段名
  2910 + ...(busy ? { reason: 'run-slot-busy', active: slots.used, max: slots.max, holders: slots.holders } : {}),
2833 queuePosition: require('../services/task-scheduler').queuePosition(companyId, task.id) || null, 2911 queuePosition: require('../services/task-scheduler').queuePosition(companyId, task.id) || null,
2834 - ...(active ? { activeRunId: active.id, activeTaskId: active.task_id, activeRunStatus: active.run_status } : {}),  
2835 - // 「先停掉」这句只在它此刻真停得掉时给:占坑的那条可能还在 ready,  
2836 - // 而 /stop 的可用条件里没有 ready —— 照着做就是一个 400(判据取自 graph-run.js 那一份)  
2837 - hint: active  
2838 - ? (require('./graph-run').STOPPABLE_RUN_STATUS.includes(active.run_status)  
2839 - ? `等它收敛,或先停掉:POST /api/graph-runs/${active.id}/stop`  
2840 - : `等它收敛(它现在是 ${active.run_status},还停不掉)`)  
2841 - : '刷新后重试', 2912 + // 「先停掉」这句只在它此刻真停得掉时给:占位的那条可能还在 ready,
  2913 + // 而 /stop 的可用条件里没有 ready —— 照着做就是一个 400(判据在 slotBusyHint 里,只此一份)
  2914 + hint: busy ? slotBusyHint(slots) : '刷新后重试',
2842 }); 2915 });
2843 } 2916 }
2844 res.status(201).json(queueReply(companyId, started.task, started)); 2917 res.status(201).json(queueReply(companyId, started.task, started));
server/routes/graph-run.js
@@ -18,7 +18,7 @@ @@ -18,7 +18,7 @@
18 * 400 { error, op, runStatus, allowed } 操作在当前 run_status 下不可用(非法状态转换,明确说清) 18 * 400 { error, op, runStatus, allowed } 操作在当前 run_status 下不可用(非法状态转换,明确说清)
19 * 400 { error, scope, from, to } 引擎抛 InvalidTransitionError 19 * 400 { error, scope, from, to } 引擎抛 InvalidTransitionError
20 * 400 { error } 入参不合法 20 * 400 { error } 入参不合法
21 - * 409 { error, reason:'run-slot-busy', holders, hint } 公司的执行坑被别人占着(引擎抛 RunSlotBusyError) 21 + * 409 { error, reason:'run-slot-busy', active, max, holders, hint } 公司的执行位已满(引擎抛 RunSlotBusyError)
22 * 500 { error } 其它 22 * 500 { error } 其它
23 */ 23 */
24 24
@@ -35,6 +35,8 @@ const { @@ -35,6 +35,8 @@ const {
35 listLoopRecords, 35 listLoopRecords,
36 edgeContracts, 36 edgeContracts,
37 findFiles, 37 findFiles,
  38 + listActiveRootRunIds,
  39 + maxActiveRootRuns,
38 InvalidTransitionError, 40 InvalidTransitionError,
39 RunSlotBusyError, 41 RunSlotBusyError,
40 } = require('../services/work-graph-db'); 42 } = require('../services/work-graph-db');
@@ -82,27 +84,19 @@ function sendError(res, err) { @@ -82,27 +84,19 @@ function sendError(res, err) {
82 // (「导出了、零人使用」是这个仓库反复立案的惯犯)。全仓 work-graph-db 只有相对路径 84 // (「导出了、零人使用」是这个仓库反复立案的惯犯)。全仓 work-graph-db 只有相对路径
83 // require,同一个模块实例,instanceof 成立。 85 // require,同一个模块实例,instanceof 成立。
84 if (err instanceof RunSlotBusyError) { 86 if (err instanceof RunSlotBusyError) {
85 - const holders = slotHolders(err);  
86 - const first = holders[0] || null; 87 + const slots = slotState(err.companyId, err);
87 return res.status(409).json({ 88 return res.status(409).json({
88 error: err.message, 89 error: err.message,
89 reason: 'run-slot-busy', 90 reason: 'run-slot-busy',
90 companyId: err.companyId, 91 companyId: err.companyId,
91 runId: err.runId, // 想进活跃集却被挡住的那条(= 本次操作的对象) 92 runId: err.runId, // 想进活跃集却被挡住的那条(= 本次操作的对象)
92 - active: err.active,  
93 - max: err.max, 93 + active: slots.used,
  94 + max: slots.max,
94 // 「挡我的是谁」:没有它,用户只知道「坑满」,无从判断该等谁、该停谁。 95 // 「挡我的是谁」:没有它,用户只知道「坑满」,无从判断该等谁、该停谁。
95 - // 数组而不是单数:max 今天恒为 1,批 4 放开并行之后这里会有多条,  
96 - // 那时改形状就是又一次前端跟着改的破坏性变更。  
97 - holders,  
98 - // 「先停掉它」这句只在**它此刻真的停得掉**时才给:ready 的 run 不在 STOPPABLE_RUN_STATUS  
99 - // 里,对它写 /stop 就是又一条指向不存在行为的提示(打过去 400)。  
100 - hint: first  
101 - ? `坑被 run ${first.runId}${first.taskId ? `(task ${first.taskId})` : ''} 占着,等它收敛后重试;`  
102 - + (STOPPABLE_RUN_STATUS.includes(first.runStatus)  
103 - ? `不想等就先停掉它:POST /api/graph-runs/${first.runId}/stop`  
104 - : `它现在是 ${first.runStatus},还停不掉(/stop 只认 ${STOPPABLE_RUN_STATUS.join(' / ')}),马上就会开跑`)  
105 - : '坑刚刚才空出来(占坑的那条已经收敛),直接重试本次操作即可', 96 + // 数组而不是单数:批 4 放开并行之后这里真的会有多条(max = 该公司的
  97 + // company.json.max_parallel_tasks),形状从批 3 起就按多条写好了,这次没动。
  98 + holders: slots.holders,
  99 + hint: slotBusyHint(slots),
106 }); 100 });
107 } 101 }
108 102
@@ -110,22 +104,57 @@ function sendError(res, err) { @@ -110,22 +104,57 @@ function sendError(res, err) {
110 } 104 }
111 105
112 /** 106 /**
113 - * 「谁占着坑」→ 可读的行。 107 + * 「这家公司此刻的执行位」→ `{ max, used, free, holders }`。**这个形状全仓库只有这一份**,
  108 + * `routes/company.js` 的 409 也从这里取(它以前自己 `.find()` 一条 active run 再拼三个
  109 + * 单数字段,那就是第二个持有者:批 4 之后两处会各自指向不同的占位者,因为
  110 + * `activeRootRun` 取 created_at **DESC** 的第一条、准入事务给的 heldBy 是 **ASC**)。
114 * 111 *
115 - * 首选 `err.heldBy`:那是准入事务在 BEGIN IMMEDIATE 里**当场**数出来的 run id,  
116 - * 比事后再查一次准(等 HTTP 响应写出去时,占坑的那条可能已经换人了)。  
117 - * 只有 heldBy 为空 / 那几行已经不在时才回落到查询,取值域用本文件的 ACTIVE_ROOT_RUN_STATUS  
118 - * (与唯一索引逐字一致的那一份),不在这里再写一遍 ——  
119 - * canDispatch 那次事故就是取值域被第二个持有者复述了一遍。 112 + * 三样东西各有唯一来源,这里一样都不重算:
  113 + * · 取值域(哪些 run_status 算占位)= `work-graph-db.listActiveRootRunIds` 那条 WHERE。
  114 + * 本文件以前抄过一份 `new Set([...])` 做回落查询 —— 那正是 canDispatch 事故的形状,已删;
  115 + * · 额度 = `work-graph-db.maxActiveRootRuns(companyId)`(读 company.json 的
  116 + * max_parallel_tasks,缺省 1)。**不在这里写第二个默认值**;
  117 + * · 拒绝当下的实况优先用 `err.heldBy / err.active / err.max` —— 那是准入事务在
  118 + * BEGIN IMMEDIATE 里**当场**数出来的,比事后再查一次准(等 HTTP 响应写出去时,
  119 + * 占位的那条可能已经换人了)。只有 heldBy 空 / 那几行已经不在时才回落到查询,
  120 + * 于是「执行位刚刚才空出来」这条 hint 仍然给得出来。
  121 + *
  122 + * @param {string} companyId
  123 + * @param {RunSlotBusyError} [err] 有它就用它当场测的那一份
120 */ 124 */
121 -function slotHolders(err) {  
122 - const view = (r) => ({ runId: r.id, taskId: r.task_id, goalId: r.goal_id, runStatus: r.run_status });  
123 - const named = (Array.isArray(err.heldBy) ? err.heldBy : []).map((id) => getRun(id)).filter(Boolean);  
124 - if (named.length) return named.map(view);  
125 - if (!err.companyId) return [];  
126 - return listCompanyRuns(err.companyId, { rootOnly: true })  
127 - .filter((r) => ACTIVE_ROOT_RUN_STATUS.has(r.run_status))  
128 - .map(view); 125 +function slotState(companyId, err = null) {
  126 + const view = (id) => {
  127 + const r = getRun(id);
  128 + return r ? { runId: r.id, taskId: r.task_id, goalId: r.goal_id, runStatus: r.run_status } : null;
  129 + };
  130 + const named = (err && Array.isArray(err.heldBy) ? err.heldBy : []).map(view).filter(Boolean);
  131 + const holders = named.length || !companyId
  132 + ? named
  133 + : listActiveRootRunIds(companyId).map(view).filter(Boolean);
  134 + const max = err && Number.isInteger(err.max) ? err.max : maxActiveRootRuns(companyId);
  135 + const used = err && Number.isInteger(err.active) ? err.active : holders.length;
  136 + return { max, used, free: Math.max(0, max - used), holders };
  137 +}
  138 +
  139 +/**
  140 + * 执行位满时那句「现在该干什么」。
  141 + *
  142 + * 两条规矩没变,只是从「唯一那条占位者」推广到 N 条:
  143 + * · 「先停掉它」只在**它此刻真的停得掉**时才给 —— ready 的 run 不在 STOPPABLE_RUN_STATUS 里,
  144 + * 对它写 /stop 就是又一条指向不存在行为的提示(打过去 400);
  145 + * · 一条都停不掉时明说原因,别让人照着一条必然失败的路走一遍。
  146 + * N 条时列出**所有**停得掉的那几条:只点名第一条,用户停掉它发现还是起不来(其余仍占着位)。
  147 + */
  148 +function slotBusyHint({ used, max, holders }) {
  149 + if (!holders.length) return '执行位刚刚才空出来(占位的那条已经收敛),直接重试本次操作即可';
  150 + const who = holders
  151 + .map((h) => `run ${h.runId}${h.taskId ? `(task ${h.taskId})` : ''}`)
  152 + .join('、');
  153 + const stoppable = holders.filter((h) => STOPPABLE_RUN_STATUS.includes(h.runStatus));
  154 + const head = `执行位已满(${used}/${max}),占位的是 ${who},等其中任一条收敛后重试;`;
  155 + return head + (stoppable.length
  156 + ? `不想等就先停掉其中一条:${stoppable.map((h) => `POST /api/graph-runs/${h.runId}/stop`).join(' 或 ')}`
  157 + : `它们现在都还停不掉(/stop 只认 ${STOPPABLE_RUN_STATUS.join(' / ')}),马上就会开跑`);
129 } 158 }
130 159
131 /** 取 run,不存在直接回 404 并返回 null(调用方 return 掉即可) */ 160 /** 取 run,不存在直接回 404 并返回 null(调用方 return 掉即可) */
@@ -428,34 +457,25 @@ router.get(&#39;/:id/stream&#39;, (req, res) =&gt; { @@ -428,34 +457,25 @@ router.get(&#39;/:id/stream&#39;, (req, res) =&gt; {
428 457
429 /** 458 /**
430 * GET /api/company/:companyId/graph-runs — 某公司的 run 列表 459 * GET /api/company/:companyId/graph-runs — 某公司的 run 列表
431 - * query: rootOnly=1 只列 root run(v1 一家公司同时只有一个 active root run,见 §一非目标) 460 + * query: rootOnly=1 只列 root run
432 * 公司无 run 时返回空数组,不报错(与 GET /api/company/:cid/org-graph 的处理一致)。 461 * 公司无 run 时返回空数组,不报错(与 GET /api/company/:cid/org-graph 的处理一致)。
433 - */  
434 -/**  
435 - * 「同一 company 只允许一个 active root run」的取值域。  
436 * 462 *
437 - * **不在这里写字面量**:唯一持有者是 `work-graph-db.ACTIVE_ROOT_RUN_STATUS` —— 它同时是  
438 - * 唯一索引 `idx_graph_runs_one_active_root` 的 WHERE、是准入事务数坑的判据、是调度器判  
439 - * 「坑空了没」的判据。本文件以前抄了一份 `new Set([...])`,那就是第二个持有者:  
440 - * canDispatch 那次事故(前端复述取值域还多写一个 blocked,公司失败一次后再也下不了任务)  
441 - * 就是这个形状。**注意 blocked 不在里面** —— blocked 的 run 不占坑,可以再下一个任务。 463 + * ⚠️ **批 4 删掉了 `canDispatch` / `activeRootRunId` 两个字段**,没有换成 `slots`。
  464 + * 理由(§九.4quater 的规矩:零消费方的字段要么接上要么删掉):
  465 + * · 两个字段自批 2 起就**零消费方** —— 全仓只剩注释在引用它们当反面教材,
  466 + * `GraphConsole.vue` 的 loadRuns 只取 `j.runs`,用例一条都没断言过它们;
  467 + * · 它们的判据在批 4 之后必然**算错**:`canDispatch: !activeRoot` 的含义是「一个都不许有」,
  468 + * 而 max=3 的公司占了 1 个位照样能下达。留着 = 一个没人读、还回错值的字段;
  469 + * · 换成 `slots:{max,used,free}` 就是把同一个事实**第三次**摊到 HTTP 上
  470 + * (409 的 `{active,max,holders}` 已经是权威形状),而且同样零消费方。
  471 + * 真要给界面画并发,落点是随 taskView 一起给的那一份(与 409 同源,见 slotState),
  472 + * 不是在这个「列 run」的端点上把 root run 再数一遍。
442 */ 473 */
443 -const ACTIVE_ROOT_RUN_STATUS = new Set(  
444 - require('../services/work-graph-db').ACTIVE_ROOT_RUN_STATUS  
445 -);  
446 -  
447 companyRouter.get('/:companyId/graph-runs', h((req, res) => { 474 companyRouter.get('/:companyId/graph-runs', h((req, res) => {
448 const rootOnly = req.query.rootOnly === '1' || req.query.rootOnly === 'true'; 475 const rootOnly = req.query.rootOnly === '1' || req.query.rootOnly === 'true';
449 - const runs = listCompanyRuns(req.params.companyId, { rootOnly });  
450 - // canDispatch 由**服务端**算:前端此前自己复述了一份取值域并且多写了一个 blocked,  
451 - // 于是一家公司只要失败过一次,控制台上就再也下不了第二个任务(后端其实放行)。  
452 - // 规则只留一处,前端照抄结果即可(P3B 第三次真跑发现)。  
453 - const activeRoot = runs.find((r) => !r.parent_run_id && ACTIVE_ROOT_RUN_STATUS.has(r.run_status));  
454 res.json({ 476 res.json({
455 companyId: req.params.companyId, 477 companyId: req.params.companyId,
456 - runs,  
457 - canDispatch: !activeRoot,  
458 - activeRootRunId: activeRoot ? activeRoot.id : null, 478 + runs: listCompanyRuns(req.params.companyId, { rootOnly }),
459 }); 479 });
460 })); 480 }));
461 481
@@ -463,3 +483,8 @@ module.exports = router; @@ -463,3 +483,8 @@ module.exports = router;
463 module.exports.companyRouter = companyRouter; 483 module.exports.companyRouter = companyRouter;
464 // company.js 的 409 hint 要照它判「这条 run 现在停不停得掉」(理由见常量注释) 484 // company.js 的 409 hint 要照它判「这条 run 现在停不停得掉」(理由见常量注释)
465 module.exports.STOPPABLE_RUN_STATUS = STOPPABLE_RUN_STATUS; 485 module.exports.STOPPABLE_RUN_STATUS = STOPPABLE_RUN_STATUS;
  486 +// 「执行位」的形状与那句下一步:company.js 的 POST /tasks/:id/start 与 startNextIfSlotFree
  487 +// 都用这两个,别在那边再写一份(批 4 之前那边是 `.find(active)` + 三个单数字段,
  488 +// 与这里的 holders[] 各说各话)。
  489 +module.exports.slotState = slotState;
  490 +module.exports.slotBusyHint = slotBusyHint;
server/services/task-scheduler.js
@@ -5,12 +5,13 @@ @@ -5,12 +5,13 @@
5 * 5 *
6 * 它把 v1 那条硬约束从「拒绝」改成「排队」: 6 * 它把 v1 那条硬约束从「拒绝」改成「排队」:
7 * 下达任务时不再因为「公司已有 active root run」吃 409,而是建一条 `run_status='queued'` 7 * 下达任务时不再因为「公司已有 active root run」吃 409,而是建一条 `run_status='queued'`
8 - * 的 run(不占唯一索引的坑)+ 一条 `status='queued'` 的 task;坑一空,这里把它拉起来。 8 + * 的 run(不占执行坑)+ 一条 `status='queued'` 的 task;坑一空,这里把它拉起来。
9 * 9 *
10 * 三件事必须一起成立,少一件排队就是「静默丢任务」(正是当初否决排队的那条理由): 10 * 三件事必须一起成立,少一件排队就是「静默丢任务」(正是当初否决排队的那条理由):
11 * 1. **出队是原子的** —— `updateRunStatus(runId,'queued','ready')` 一条 CAS,且「数坑 + 占坑」 11 * 1. **出队是原子的** —— `updateRunStatus(runId,'queued','ready')` 一条 CAS,且「数坑 + 占坑」
12 * 整段包在 `BEGIN IMMEDIATE` 里(守卫在 `updateRunStatus`,不在这里 —— 能把 root run 12 * 整段包在 `BEGIN IMMEDIATE` 里(守卫在 `updateRunStatus`,不在这里 —— 能把 root run
13 - * 推进活跃集的路径有 6 条,枚举一次漏一次);`idx_graph_runs_one_active_root` 留作兜底; 13 + * 推进活跃集的路径有 6 条,枚举一次漏一次)。批 4 DROP 掉了 `idx_graph_runs_one_active_root`
  14 + * 那道 DB 兜底,那个事务从此是**唯一**的守卫;
14 * 2. **触发不靠枚举** —— 挂在 `graph-run-sse.transitionRun` 这个唯一漏斗上 15 * 2. **触发不靠枚举** —— 挂在 `graph-run-sse.transitionRun` 这个唯一漏斗上
15 * (`stopRun` 与「预算耗尽 → blocked」都不走 `settleRun`,枚举一次漏一次); 16 * (`stopRun` 与「预算耗尽 → blocked」都不走 `settleRun`,枚举一次漏一次);
16 * 3. **躺在队里的任务不许假装在排队** —— 依赖已不可能满足的,就地推导成 blocked 并写明原因。 17 * 3. **躺在队里的任务不许假装在排队** —— 依赖已不可能满足的,就地推导成 blocked 并写明原因。
@@ -19,11 +20,13 @@ @@ -19,11 +20,13 @@
19 */ 20 */
20 21
21 /** 22 /**
22 - * 并行度上限的唯一持有者在 `work-graph-db.MAX_ACTIVE_ROOT_RUNS`(挨着唯一索引与准入事务)。  
23 - * 这里**不重新写一个 1** —— 两个持有者迟早分叉,那正是 `canDispatch` 那次事故的形状。 23 + * 并行度上限的唯一持有者在 `work-graph-db.maxActiveRootRuns(companyId)`(挨着准入事务)。
  24 + * 这里**不重新写一个默认值、也不缓存** —— 两个持有者迟早分叉,那正是 `canDispatch`
  25 + * 那次事故的形状。批 4 起它是 per-company 的(读 company.json.max_parallel_tasks),
  26 + * 所以签名必须带 companyId:一个无参的 getter 表达不了 per-company,只会变成又一个假上限。
24 * 惰性取值(模块加载期 require work-graph-db 会成环)。 27 * 惰性取值(模块加载期 require work-graph-db 会成环)。
25 */ 28 */
26 -const maxParallelTasks = () => wdb().MAX_ACTIVE_ROOT_RUNS; 29 +const maxParallelTasks = (companyId) => wdb().maxActiveRootRuns(companyId);
27 30
28 /** 31 /**
29 * 依赖「已经死了」的上游状态。 32 * 依赖「已经死了」的上游状态。
@@ -127,7 +130,7 @@ function queuePosition(companyId, taskId) { @@ -127,7 +130,7 @@ function queuePosition(companyId, taskId) {
127 /** 130 /**
128 * 当前占着坑的 root run 数。 131 * 当前占着坑的 root run 数。
129 * 132 *
130 - * 计数 SQL 本身在 `work-graph-db.countActiveRootRuns`(与唯一索引、与准入事务同源), 133 + * 计数 SQL 本身在 `work-graph-db.countActiveRootRuns`(与准入事务的判据同源),
131 * 这里只是取来用 —— 调度器这一份**只是给界面看的快照**,不是准入判据: 134 * 这里只是取来用 —— 调度器这一份**只是给界面看的快照**,不是准入判据:
132 * 真正决胜的是 `updateRunStatus` 里那个 BEGIN IMMEDIATE 的事务(见下方第 3 步注释)。 135 * 真正决胜的是 `updateRunStatus` 里那个 BEGIN IMMEDIATE 的事务(见下方第 3 步注释)。
133 */ 136 */
@@ -208,7 +211,7 @@ function abandonRun(runId, why) { @@ -208,7 +211,7 @@ function abandonRun(runId, why) {
208 * 调度一次(§五.1 六步)。**绝不 throw**:它挂在状态转换的尾巴上,抛出去会把一次正常的 211 * 调度一次(§五.1 六步)。**绝不 throw**:它挂在状态转换的尾巴上,抛出去会把一次正常的
209 * run 收敛也带崩(调用方拿到异常,但状态和 SSE 事件已经发出去了)。 212 * run 收敛也带崩(调用方拿到异常,但状态和 SSE 事件已经发出去了)。
210 * 213 *
211 - * @returns {{companyId:string, skipped?:string, started:object[], blocked:object[], requeued:string[], active?:number}} 214 + * @returns {{companyId:string, skipped?:string, started:object[], blocked:object[], requeued:string[], active?:number, max?:number}}
212 */ 215 */
213 function tick(companyId) { 216 function tick(companyId) {
214 if (!companyId) return { companyId, skipped: 'no-company', started: [], blocked: [], requeued: [] }; 217 if (!companyId) return { companyId, skipped: 'no-company', started: [], blocked: [], requeued: [] };
@@ -240,23 +243,41 @@ function tickInner(companyId) { @@ -240,23 +243,41 @@ function tickInner(companyId) {
240 // 队列可以变(另一个进程/另一次 tick),check-then-act 在这里天然不成立。 243 // 队列可以变(另一个进程/另一次 tick),check-then-act 在这里天然不成立。
241 // 权威判据在 `updateRunStatus` 的准入事务里(BEGIN IMMEDIATE 内数坑 + 占坑一步做完), 244 // 权威判据在 `updateRunStatus` 的准入事务里(BEGIN IMMEDIATE 内数坑 + 占坑一步做完),
242 // 坑被抢走时它抛 RunSlotBusyError,由 5c 的 catch + isRaceError 当成「这轮没抢到」。 245 // 坑被抢走时它抛 RunSlotBusyError,由 5c 的 catch + isRaceError 当成「这轮没抢到」。
243 - // ponytail: max 恒为 1(批 3 只做排队);读 company.json.max_parallel_tasks 与  
244 - // DROP 唯一索引一起归批 4 / 27-P4。  
245 - const max = maxParallelTasks(); 246 + // 批 4 起 max 是 per-company 的(company.json.max_parallel_tasks,缺省 1)。
  247 + const max = maxParallelTasks(companyId);
246 const active = activeRootRunCount(companyId); 248 const active = activeRootRunCount(companyId);
247 const started = []; 249 const started = [];
248 - if (active >= max) return { companyId, skipped: 'no-slot', active, started, blocked, requeued }; 250 + if (active >= max) return { companyId, skipped: 'no-slot', active, max, started, blocked, requeued };
249 251
250 // 4. 选候选:queued + 依赖全 completed + auto。 252 // 4. 选候选:queued + 依赖全 completed + auto。
251 // manual **不参与自动出队**(§五.2bis):startRun(manual) 只点亮入口节点就停下等 /next, 253 // manual **不参与自动出队**(§五.2bis):startRun(manual) 只点亮入口节点就停下等 /next,
252 // 而那时 run 已经是 running、占着坑 —— 没人点「下一步」它就把后面所有任务无限期堵死, 254 // 而那时 run 已经是 running、占着坑 —— 没人点「下一步」它就把后面所有任务无限期堵死,
253 // 而队列界面上看不出任何异常。manual 只能由人在控制台上显式点「开始」。 255 // 而队列界面上看不出任何异常。manual 只能由人在控制台上显式点「开始」。
  256 + //
  257 + // **不 slice**:批 3 这里是 `.slice(0, max - active)`,用的是「候选数 = 能起的条数」这个
  258 + // 在 max=1 时看不出错的假设 —— 只要队首那条起跑失败(5a 缺 run 行 / 5d 认领失败),
  259 + // 这一轮就白跑,空着的坑要等下一次钩子或 60s sweep 才被填上。max>1 时这类空转会成倍出现。
  260 + // 改成按**真的起来了几条**收敛:循环里自己数,坑满就 break。
  261 + // ⚠️ **但 `started_at` 非空的 manual 要放进来**(批 3 复核 L1):
  262 + // 「manual 不自动出队」约束的是**首次起跑的授权** —— 人要盯着一步步推,不该半夜被自动拉起。
  263 + // 而一条**已经被人亲手启动过**、跑到一半因为坑满被 `admitOrRequeue` 退回队列的 manual 任务,
  264 + // 授权早就给过了;让它自己接回来不是新授权,是**把人刚才那次点击兑现掉**。
  265 + // 不放进来的后果实测过:人点「启动」→ 退回队列 → 坑空了 → **没人接**,任务躺着、
  266 + // sweep 每 60s 空转,而 `graph-engine.js` 那句「『T1 永远回不来』这个后果因此消失」
  267 + // 对 manual 根本不成立 —— 又一条只兑现一半的注释。
  268 + // 判据用 **`attempt > 0`** 而不是 `started_at`:后者看着更贴切,但
  269 + // `requeueBlockedRootRun` 退回队列时会把 `started_at` 清成 NULL(它记的是「本次尝试何时开跑」,
  270 + // 退回队列后本次尝试就没了),于是那个字段恰好在我需要它的那一刻是空的 —— 实测踩过。
  271 + // `attempt` 由 `claimQueuedTask` 每次认领 +1、退回队列时不清,它才是「这条被人起过没有」的事实。
254 const candidates = listQueue(companyId) 272 const candidates = listQueue(companyId)
255 - .filter((t) => t.execution_mode === 'auto' && dependenciesSatisfied(t))  
256 - .slice(0, max - active); 273 + .filter((t) => (t.execution_mode === 'auto' || t.attempt > 0) && dependenciesSatisfied(t));
257 274
258 - // 5. 逐条出队,整条包在 try/catch 里 275 + // 5. 逐条出队,每条独立 try/catch —— 一条失败绝不能中止整轮(那等于把后面的坑白白空着)
259 for (const t of candidates) { 276 for (const t of candidates) {
  277 + // 本轮已经填满就收工。`active` 是进循环前的快照、`started.length` 是本轮的战果,
  278 + // 两者都只会低估空位(跑着的 run 可能中途收敛让出坑),保守一点即可:
  279 + // 少起的那条由漏斗钩子上的下一次 scheduleTick 接手,而不是在这里赌一个更新的计数。
  280 + if (active + started.length >= max) break;
260 const runId = t.current_run_id; 281 const runId = t.current_run_id;
261 try { 282 try {
262 // 5a. 入队时必须已经建好 queued 的 run 行;没有就是入队做了一半, 283 // 5a. 入队时必须已经建好 queued 的 run 行;没有就是入队做了一半,
@@ -278,11 +299,10 @@ function tickInner(companyId) { @@ -278,11 +299,10 @@ function tickInner(companyId) {
278 // 由 updateRunStatus 的 BEGIN IMMEDIATE 事务保护,超发的那条拿不到坑。 299 // 由 updateRunStatus 的 BEGIN IMMEDIATE 事务保护,超发的那条拿不到坑。
279 // 走 transitionRun 而不是裸的 updateRunStatus,是为了让这一跳同样落 timeline、 300 // 走 transitionRun 而不是裸的 updateRunStatus,是为了让这一跳同样落 timeline、
280 // 推 SSE(控制台要看见任务起跑);`to='ready'` 在活跃集里,所以它不会反手再触发一次调度。 301 // 推 SSE(控制台要看见任务起跑);`to='ready'` 在活跃集里,所以它不会反手再触发一次调度。
281 - // 三种「没抢到」的形态:  
282 - // · changes=0 → transitionRun 返回 null(这条 run 已被别人推走);  
283 - // · RunSlotBusyError → 主守卫在事务里判出坑满(**不是** changes=0);  
284 - // · UNIQUE constraint → 兜底索引(批 4 DROP 前一直在),  
285 - // 后两种都是抛出来的,落到下面的 catch → isRaceError → continue。 302 + // 两种「没抢到」的形态(批 4 DROP 唯一索引后,兜底那一层没有了):
  303 + // · changes=0 → transitionRun 返回 null(这条 run 已被别人推走)→ 试下一条;
  304 + // · RunSlotBusyError → 主守卫在事务里判出坑满(**不是** changes=0)→ 整轮到此为止。
  305 + // 抛出来的那种落到下面的 catch。
286 if (!sse().transitionRun(runId, 'queued', 'ready', {}, { 306 if (!sse().transitionRun(runId, 'queued', 'ready', {}, {
287 summary: `出队:queued → ready(task ${t.id})`, 307 summary: `出队:queued → ready(task ${t.id})`,
288 task_id: t.id, 308 task_id: t.id,
@@ -301,7 +321,11 @@ function tickInner(companyId) { @@ -301,7 +321,11 @@ function tickInner(companyId) {
301 engine().startRun(runId, { mode: t.execution_mode, task: t.description }); 321 engine().startRun(runId, { mode: t.execution_mode, task: t.description });
302 started.push({ taskId: t.id, runId, goalId: t.goal_id }); 322 started.push({ taskId: t.id, runId, goalId: t.goal_id });
303 } catch (err) { 323 } catch (err) {
304 - if (isRaceError(err)) continue; // 没抢到,正常 324 + // 坑满:这一刻公司真的没位置了,后面的候选一个都进不去 —— 继续试只是白白多抛几次。
  325 + // 与下面那种「这一条没抢到」区分开:那种要接着试下一条,否则 max>1 时一条被别人
  326 + // 推走就会让剩下的坑空着(正是本轮 slice 去掉的那类空转)。
  327 + if (err instanceof wdb().RunSlotBusyError) break;
  328 + if (isRaceError(err)) continue; // 这一条没抢到(已被别人从 queued 推走),试下一条
305 // 到这儿说明 run 可能已经出队但没能起跑:task 已标 running 而没有活的 run, 329 // 到这儿说明 run 可能已经出队但没能起跑:task 已标 running 而没有活的 run,
306 // 这正是要消灭的那类「写了但没生效还不报错」。收回 run,让 task 落到可见的终态。 330 // 这正是要消灭的那类「写了但没生效还不报错」。收回 run,让 task 落到可见的终态。
307 if (runId && (getRun(runId) || {}).run_status === 'ready') { 331 if (runId && (getRun(runId) || {}).run_status === 'ready') {
@@ -312,7 +336,7 @@ function tickInner(companyId) { @@ -312,7 +336,7 @@ function tickInner(companyId) {
312 } 336 }
313 } 337 }
314 338
315 - return { companyId, active, started, blocked, requeued }; 339 + return { companyId, active, max, started, blocked, requeued };
316 } 340 }
317 341
318 /** 342 /**
@@ -384,6 +408,7 @@ module.exports = { @@ -384,6 +408,7 @@ module.exports = {
384 sweep, 408 sweep,
385 startSweeper, 409 startSweeper,
386 stopSweeper, 410 stopSweeper,
387 - // 转发 work-graph-db 的那一份,别在这里存第二个副本  
388 - get MAX_PARALLEL_TASKS() { return maxParallelTasks(); }, 411 + // 批 4 删掉了 `get MAX_PARALLEL_TASKS()`:它无参,表达不了 per-company 的额度,
  412 + // 而且全仓零调用方 —— 一个「导出了、没人用、还恰好是错的」的形状。
  413 + // 要额度就调 `work-graph-db.maxActiveRootRuns(companyId)`,那是唯一持有者。
389 }; 414 };
server/services/work-graph-db.js
@@ -32,27 +32,90 @@ const { validateGraph, normalizeContracts } = require(&#39;./org-graph-service&#39;); @@ -32,27 +32,90 @@ const { validateGraph, normalizeContracts } = require(&#39;./org-graph-service&#39;);
32 /** 32 /**
33 * 「这条 root run 正占着公司的执行坑」的取值域。 33 * 「这条 root run 正占着公司的执行坑」的取值域。
34 * 34 *
35 - * **它同时是 `idx_graph_runs_one_active_root` 那个唯一索引的 WHERE 子句**(下面 getDb 里直接  
36 - * 拼进去),所以这里是全仓库唯一的定义处,调度器 / 路由复用它而不是再抄一份字面量。 35 + * 全仓库唯一的定义处,调度器 / 路由 / 准入事务复用它而不是再抄一份字面量。
37 * 为什么强调这件事:`canDispatch` 那次事故就是前端把这三个值复述了一遍、还多写了一个 36 * 为什么强调这件事:`canDispatch` 那次事故就是前端把这三个值复述了一遍、还多写了一个
38 * `blocked`,于是公司失败一次之后再也下不了任务 —— 取值域一旦有第二个持有者就会分叉。 37 * `blocked`,于是公司失败一次之后再也下不了任务 —— 取值域一旦有第二个持有者就会分叉。
39 * 38 *
40 * ⚠️ **`queued` 故意不在里面**:排队中的 run 不占坑,一家公司可以同时躺着任意多条。 39 * ⚠️ **`queued` 故意不在里面**:排队中的 run 不占坑,一家公司可以同时躺着任意多条。
41 - * 这正是「出队原子性」的**全部依赖** —— 出队那一跳 `queued → ready` 会撞上这个唯一索引,  
42 - * 超发的那一条被 DB 直接拒(SqliteError),而不是靠应用层「先查后插」的乐观检查。  
43 - * 改这个数组之前先读 27-P3C §四.3 与 §五.2;批 4 放开并行时要连索引一起动。 40 + * 这是「入队不占坑、出队才占坑」的全部依据 —— 出队那一跳 `queued → ready` 是「从活跃集外
  41 + * 进入活跃集」,会被 `updateRunStatus` 的准入事务数一次坑。
  42 + * 改这个数组之前先读 27-P3C §四.3 与 §五.2。
  43 + *
  44 + * 批 4 之前这个数组还兼任 `idx_graph_runs_one_active_root` 的 WHERE 子句(拼进建表 SQL)。
  45 + * 那条索引已 DROP(见 getDb 的迁移块),现在它只有一个用途:准入事务的判据。
44 */ 46 */
45 const ACTIVE_ROOT_RUN_STATUS = ['ready', 'running', 'paused']; 47 const ACTIVE_ROOT_RUN_STATUS = ['ready', 'running', 'paused'];
46 48
47 /** 49 /**
48 - * 一家公司同时能占几个执行坑。 50 + * 一家公司同时能占几个执行坑 —— 缺省值。
  51 + * **缺省必须是 1**:读不到配置的公司(没建目录的单测、被 delete() 改名的公司、`_unknown`)
  52 + * 一律回落到批 3 的行为。回落成 0 是「永远不出队 = 静默全停」,回落成 Infinity 是超发。
  53 + */
  54 +const DEFAULT_MAX_ACTIVE_ROOT_RUNS = 1;
  55 +
  56 +/**
  57 + * 硬上限。配置里写个 100000 不该被当真 —— 每个坑对应一条真在跑的 root run(一个 Claude CLI
  58 + * 子进程量级的开销),机器级闸门(SessionPool.maxSessions)至今不存在(登记在 28-followups)。
  59 + * 超过就按上限截断并打日志,不静默当成无限。
  60 + */
  61 +const MAX_ACTIVE_ROOT_RUNS_CAP = 20;
  62 +
  63 +/** 同一份非法配置不必每次准入都刷一行日志 */
  64 +const warnedMaxParallel = new Set();
  65 +
  66 +function warnMaxParallelOnce(companyId, raw, why) {
  67 + const key = `${companyId}:${raw}`;
  68 + if (warnedMaxParallel.has(key)) return;
  69 + warnedMaxParallel.add(key);
  70 + console.warn(`[work-graph-db] company ${companyId} 的 max_parallel_tasks=${JSON.stringify(raw)} ${why}`);
  71 +}
  72 +
  73 +/**
  74 + * 这家公司同时能占几个执行坑(27-P3C 批 4 / 27-P4 §一.3)。
  75 + *
  76 + * **调用时读,不在模块加载时快照**。`company-paths.js` 顶上那条教训就是这个:镜像时代两个
  77 + * 模块各自快照 GRAPH_RUNS_DIR,加载顺序不同就算出不同的根。这里同理 —— 公司配置随时可改,
  78 + * 快照一次就意味着「改了配置要重启才生效」,而且不报错。
  79 + *
  80 + * 读的是 `companyRootDir(companyId)/company.json` 的 `max_parallel_tasks`(snake_case,
  81 + * 与 27-P4 §一.3 的字段名逐字一致;**不认 camelCase 的 maxParallelTasks** —— 两个键并存
  82 + * 就是又一个双持有者)。走 `company-paths.companyRootDir` 而不是 `company-service.get`:
  83 + * · 前者认 GRAPH_RUNS_DIR,后者不认。准入判据一旦去读 company.json,设了 GRAPH_RUNS_DIR
  84 + * 的单测就会去读**真仓库**的 data/companies/,撞上同名公司就读到真配置(§六 缺陷 A 同型);
  85 + * · 顺带避开 `company-service → graph-service → graph-db` 那条 require 环。
  86 + * 两者在生产下算出的是同一个路径(都是 REPO_ROOT/data/companies/{cid})。
  87 + *
  88 + * 非法值(0 / 负数 / 小数 / 非数字 / 文件坏了)一律回落 DEFAULT 并打一次日志:
  89 + * 「配置写错了就当没配」必须是能看见的,否则就是一次静默降级。
49 * 90 *
50 - * ponytail: 批 3 硬编码为 1,与 `idx_graph_runs_one_active_root`(UNIQUE(company_id))  
51 - * 表达的是同一件事。批 4 放开并行时这里改成读 `company.json.max_parallel_tasks`  
52 - * 并 DROP 那个索引 —— 到那时 `updateRunStatus` 里的准入事务就是**唯一**的守卫,  
53 - * 所以它今天就得是对的,而不是等索引没了再回头补。 91 + * ponytail: 每次准入读一次盘(一个几百字节的 JSON)。准入是低频事件(一次出队/恢复一次),
  92 + * 没加缓存。真到高频时加一层 mtime 缓存,别改成模块级快照。
54 */ 93 */
55 -const MAX_ACTIVE_ROOT_RUNS = 1; 94 +function maxActiveRootRuns(companyId) {
  95 + if (!companyId) return DEFAULT_MAX_ACTIVE_ROOT_RUNS;
  96 + let raw;
  97 + try {
  98 + const { companyRootDir } = require('./company-paths');
  99 + const fp = require('path').join(companyRootDir(companyId), 'company.json');
  100 + const fs = require('fs');
  101 + if (!fs.existsSync(fp)) return DEFAULT_MAX_ACTIVE_ROOT_RUNS;
  102 + raw = JSON.parse(fs.readFileSync(fp, 'utf-8')).max_parallel_tasks;
  103 + } catch (err) {
  104 + // company.json 坏了不该让一次出队变成 500:回落缺省,但必须留声
  105 + warnMaxParallelOnce(companyId, null, `读取失败(${err.message}),按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`);
  106 + return DEFAULT_MAX_ACTIVE_ROOT_RUNS;
  107 + }
  108 + if (raw === undefined || raw === null) return DEFAULT_MAX_ACTIVE_ROOT_RUNS;
  109 + if (!Number.isInteger(raw) || raw < 1) {
  110 + warnMaxParallelOnce(companyId, raw, `不是 ≥1 的整数,按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`);
  111 + return DEFAULT_MAX_ACTIVE_ROOT_RUNS;
  112 + }
  113 + if (raw > MAX_ACTIVE_ROOT_RUNS_CAP) {
  114 + warnMaxParallelOnce(companyId, raw, `超过硬上限,按 ${MAX_ACTIVE_ROOT_RUNS_CAP} 处理`);
  115 + return MAX_ACTIVE_ROOT_RUNS_CAP;
  116 + }
  117 + return raw;
  118 +}
56 119
57 /** 120 /**
58 * 坑满:一条 root run 想从活跃集外进入活跃集,但公司的执行坑已经占满。 121 * 坑满:一条 root run 想从活跃集外进入活跃集,但公司的执行坑已经占满。
@@ -71,6 +134,11 @@ const MAX_ACTIVE_ROOT_RUNS = 1; @@ -71,6 +134,11 @@ const MAX_ACTIVE_ROOT_RUNS = 1;
71 * 那个形状(§十四.3 第 3 条:注释是对的、代码没兑现;这次反过来)。 134 * 那个形状(§十四.3 第 3 条:注释是对的、代码没兑现;这次反过来)。
72 * 现在文本不再是任何人的判据:唯一的判据是 `isSlotRaceError`(下面那个函数),按类型判。 135 * 现在文本不再是任何人的判据:唯一的判据是 `isSlotRaceError`(下面那个函数),按类型判。
73 * message 里那句 `active-root-run constraint` 只当人读的说明留着,改它不会弄坏任何分支。 136 * message 里那句 `active-root-run constraint` 只当人读的说明留着,改它不会弄坏任何分支。
  137 + *
  138 + * ⚠️ **待拆(批 4 留下的)**:批 4 已 DROP 掉同名的唯一索引,`constraint` 这个词会让读者
  139 + * 以为 DB 层还有一道约束 —— 该换成 `limit`。本批**故意不动这个词**:路由层正由另一个 agent
  140 + * 并行改造,万一那边还留着按文本嗅探的分支,改早了会把一次 409 退化成 500。等路由那批落地、
  141 + * 确认全仓只剩 `instanceof` / `isSlotRaceError` 之后换掉它并删本段。
74 */ 142 */
75 class RunSlotBusyError extends Error { 143 class RunSlotBusyError extends Error {
76 /** 144 /**
@@ -103,17 +171,29 @@ class RunSlotBusyError extends Error { @@ -103,17 +171,29 @@ class RunSlotBusyError extends Error {
103 * 一次正常竞争当成故障去 `abandonRun`(run 收成 stopped、task 收成 cancelled = 丢任务), 171 * 一次正常竞争当成故障去 `abandonRun`(run 收成 stopped、task 收成 cancelled = 丢任务),
104 * 而路由那边照常 409。这正是 `canDispatch` 那次事故的形状,只是换了个取值域。 172 * 而路由那边照常 409。这正是 `canDispatch` 那次事故的形状,只是换了个取值域。
105 * 173 *
106 - * 三种形态,三道防线各一种:  
107 - * · `RunSlotBusyError` 主守卫:准入事务在 BEGIN IMMEDIATE 里数出坑已满;  
108 - * · `SQLITE_CONSTRAINT_UNIQUE` 兜底:真有两条同时写进活跃集时唯一索引直接拒(批 4 DROP 它后消失)。  
109 - * 判 `code` 而不是判 message:better-sqlite3 的 SqliteError 自带这个字段,文案换了它不变;  
110 - * · `InvalidTransitionError` 这条 run 已经被别人从 queued 推走了。 174 + * **现在只有一种形态**:`RunSlotBusyError` —— 准入事务在 `BEGIN IMMEDIATE` 里数出坑已满。
  175 + *
  176 + * ⚠️ 批 3 那版还把 `InvalidTransitionError` 算作竞态,注释写着「这条 run 已经被别人从 queued
  177 + * 推走了」。**探针实测证伪了这句话**:被别人推走时 `updateRunStatus` 走的是条件更新
  178 + * `WHERE id=? AND run_status=@from`,**返回 0、不抛异常**。真正抛 `InvalidTransitionError` 的
  179 + * 只有一种情况 —— **调用方声明了一个非法转换**(`expected = from` 是调用方传进来的声明值,
  180 + * `canTransitionRun` judge 的是 `expected → to`,不是 `cur.run_status → to`)。
  181 + * 也就是说那一支把「调用方写错了转换」当成「这轮没抢到」吞掉,还不留日志:
  182 + * 调度器会 `continue`,任务永远排队,界面与正常排队一模一样 —— 又一条「写不进去但不报错」。
  183 + * 已删除该分支,理由与下面删 UNIQUE 那支完全相同:让它吵起来。
  184 + *
  185 + * ⚠️ 批 4 **删掉了 `err.code === 'SQLITE_CONSTRAINT_UNIQUE'` 那一支**,这是一次故意的行为改变。
  186 + * 它当初的语义是「兜底那道唯一索引拒了」。索引 DROP 之后,这一跳再吐出 UNIQUE 冲突只剩一个
  187 + * 可能:**索引被建回来了**(旧版本进程启动时又跑了一遍它的 `CREATE UNIQUE INDEX IF NOT EXISTS`,
  188 + * 见 getDb 迁移块那段告示)。那种情况下 max>1 会被悄悄压回 1 —— 留着这一支的话,调度器把它
  189 + * 当「这轮没抢到」`continue` 掉,任务永远排队、日志一行没有、界面和正常排队一模一样,
  190 + * 正是要消灭的「写不进去但不报错」。删掉之后它会一路抛到 `abandonRun` / 500,吵得人能看见。
  191 + *
  192 + * 判类型而不是判 message:改一句报错文案不该改变任何分支(这是批 3 那次分叉的根因)。
111 */ 193 */
112 function isSlotRaceError(err) { 194 function isSlotRaceError(err) {
113 if (!err) return false; 195 if (!err) return false;
114 - return err instanceof InvalidTransitionError  
115 - || err instanceof RunSlotBusyError  
116 - || err.code === 'SQLITE_CONSTRAINT_UNIQUE'; 196 + return err instanceof RunSlotBusyError;
117 } 197 }
118 198
119 // ──────────────────────────────────────────────────────────────── 199 // ────────────────────────────────────────────────────────────────
@@ -237,13 +317,10 @@ function getDb() { @@ -237,13 +317,10 @@ function getDb() {
237 created_at TEXT NOT NULL, 317 created_at TEXT NOT NULL,
238 updated_at TEXT NOT NULL 318 updated_at TEXT NOT NULL
239 ); 319 );
240 - -- v1 约束:同一 company 至多一个「活跃」root run。取值域从 ACTIVE_ROOT_RUN_STATUS 拼进来,  
241 - -- 不再手抄一份 —— 索引的 WHERE 和调度器判「坑满没满」的判据必须是同一个数组(§五.1)。  
242 - -- 注意 queued 不在其中:排队的 run 不占坑,这是出队 CAS 的依据(见该常量注释)。  
243 - CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_runs_one_active_root  
244 - ON graph_runs(company_id)  
245 - WHERE parent_run_id IS NULL  
246 - AND run_status IN (${ACTIVE_ROOT_RUN_STATUS.map((s) => `'${s}'`).join(',')}); 320 + -- 批 4 之前这里建过 idx_graph_runs_one_active_root(UNIQUE(company_id) WHERE 活跃且是 root),
  321 + -- 即「一家公司同时只有一条活跃 root run」那条 v1 硬约束。它与 max_parallel_tasks > 1 直接冲突,
  322 + -- 已删除,并在下面的迁移块里显式 DROP。**别把它加回来**:加回来 = 把并行悄悄压回 1
  323 + -- (症状见 isSlotRaceError 那段注释)。守卫现在只有一处,在 updateRunStatus 的准入事务里。
247 324
248 CREATE TABLE IF NOT EXISTS graph_run_nodes ( 325 CREATE TABLE IF NOT EXISTS graph_run_nodes (
249 id TEXT PRIMARY KEY, 326 id TEXT PRIMARY KEY,
@@ -433,6 +510,29 @@ function getDb() { @@ -433,6 +510,29 @@ function getDb() {
433 510
434 // 存量库迁移(写法照抄 graph-db.js migrate():hasColumn 判断 + ALTER TABLE ADD COLUMN, 511 // 存量库迁移(写法照抄 graph-db.js migrate():hasColumn 判断 + ALTER TABLE ADD COLUMN,
435 // 新建库走上面的 CREATE TABLE 就已经是目标形态,这里是 no-op)。 512 // 新建库走上面的 CREATE TABLE 就已经是目标形态,这里是 no-op)。
  513 +
  514 + // ── 批 4:DROP 掉「一家公司只许一条活跃 root run」的唯一索引 ────────────────
  515 + //
  516 + // 从建表块里删掉那条 CREATE **对存量库无效**(`IF NOT EXISTS` 只管不重复建,不会把已有的
  517 + // 删掉),所以必须在这里显式 DROP —— 与 graph-db.js:76 那处
  518 + // `DROP INDEX IF EXISTS idx_org_graph_nodes_company_role` 同一写法。
  519 + // 不套 hasColumn:那个函数 pragma 的是 table_info,承载不了索引;`DROP INDEX IF EXISTS`
  520 + // 本身就是幂等的,重复跑、并发跑都安全(SQLite 把 DDL 序列化,慢的那个等 busy_timeout)。
  521 + //
  522 + // ⚠️ 删索引 = 删掉最后一道 DB 兜底。删之前先确认主守卫是**真的**主守卫 ——
  523 + // `updateRunStatus` 的准入是四步一体、缺一步就重新出现超发窗口:
  524 + // 1. `BEGIN IMMEDIATE`:读之前就拿写锁,杜绝两个连接各自数出「还有空位」;
  525 + // 2. 事务内**重读** run_status:状态已被别人推走时返回 0(幂等 no-op),不误报坑满;
  526 + // 3. 事务内 `listActiveRootRunIds` 计数,判据是「从活跃集外进入活跃集」(集内移动不重复占坑);
  527 + // 4. 同一事务内做条件更新 `WHERE id=? AND run_status=@from`(写进去的 to 不在 WHERE 里)。
  528 + // 三个测试文件钉着这四步:task-scheduler.test.js「准入守卫」那组、task-queue-acceptance.test.js
  529 + // 的超发用例、graph-run-slot-recovery.test.js 的拒绝-退队列路径。
  530 + //
  531 + // ⚠️ **上线要求全停再起**:DROP 之后只要有**旧版本代码**的进程再调一次 getDb(),
  532 + // 它那条 `CREATE UNIQUE INDEX IF NOT EXISTS` 会把索引原样建回来,于是 max>1 的第二条准入
  533 + // 吃一句 SQLite 原文的 500(isSlotRaceError 已不再吞它,就是为了让这一幕吵起来)。
  534 + db.exec(`DROP INDEX IF EXISTS idx_graph_runs_one_active_root`);
  535 +
436 // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 536 // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。
437 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { 537 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) {
438 db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); 538 db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`);
@@ -837,8 +937,9 @@ function readDefGraph(kind, graphId) { @@ -837,8 +937,9 @@ function readDefGraph(kind, graphId) {
837 * root run:不传 parentRunId,root_run_id = 自身 id,budget_json 只在这里非空(§四.A.3)。 937 * root run:不传 parentRunId,root_run_id = 自身 id,budget_json 只在这里非空(§四.A.3)。
838 * 子 run: 传 parentRunId + parentNodeRunId,root_run_id 从父 run 继承(冗余存储避免上溯)。 938 * 子 run: 传 parentRunId + parentNodeRunId,root_run_id 从父 run 继承(冗余存储避免上溯)。
839 * 939 *
840 - * 同一 company 只允许一个 active root run(idx_graph_runs_one_active_root);  
841 - * 冲突时抛 SqliteError,调用方自己决定是拒绝还是复用已有 run。 940 + * **建 run 本身不占坑,也不做额度检查**(批 4 起唯一的坑约束是 `updateRunStatus` 的准入事务)。
  941 + * 建出来的 run 只有走「进入活跃集」那一跳时才会被数一次坑 —— 计数守卫**故意不放在这里**:
  942 + * 建 run 与进活跃集之间隔着排队,放在这里等于把「此刻有没有位置」当成「以后有没有位置」。
842 * 943 *
843 * @returns {{ok: true, id, nodeRunIds: Map<defNodeId,string>}} 944 * @returns {{ok: true, id, nodeRunIds: Map<defNodeId,string>}}
844 */ 945 */
@@ -1137,9 +1238,9 @@ const RUN_PATCH_COLUMNS = { @@ -1137,9 +1238,9 @@ const RUN_PATCH_COLUMNS = {
1137 /** 1238 /**
1138 * 当前占着这家公司执行坑的 root run 数。 1239 * 当前占着这家公司执行坑的 root run 数。
1139 * 1240 *
1140 - * WHERE 与 `idx_graph_runs_one_active_root` **逐字同源**(同一个 ACTIVE_ROOT_RUN_STATUS 拼出来)。  
1141 - * 一旦两边分叉,症状是「事务守卫放行、索引拒绝」的 500 —— 一个把关的说行、另一个说不行,  
1142 - * 而报出来的只有一句 SQLite 原文。所以计数只有这一个持有者,调度器也调它,不许自己再写一遍 SQL。 1241 + * WHERE 从 ACTIVE_ROOT_RUN_STATUS 拼出来,与准入事务的判据同源。计数只有这一个持有者,
  1242 + * 调度器 / 路由都调它,不许自己再写一遍 SQL —— 批 4 DROP 掉唯一索引之后这条 SQL 就是
  1243 + * 「坑满没满」的**全部**依据,多一个持有者就多一个能悄悄分叉的地方。
1143 * 1244 *
1144 * @param {string} companyId 1245 * @param {string} companyId
1145 * @param {object} [db] 传进来是为了让准入的计数与随后的 UPDATE 落在**同一个事务**里 1246 * @param {object} [db] 传进来是为了让准入的计数与随后的 UPDATE 落在**同一个事务**里
@@ -1222,10 +1323,17 @@ function updateRunStatus(runId, from, to, patch = {}) { @@ -1222,10 +1323,17 @@ function updateRunStatus(runId, from, to, patch = {}) {
1222 && !ACTIVE_ROOT_RUN_STATUS.includes(expected); 1323 && !ACTIVE_ROOT_RUN_STATUS.includes(expected);
1223 if (!admitting) return write(); 1324 if (!admitting) return write();
1224 1325
1225 - // BEGIN IMMEDIATE:**读之前**就拿到写锁,于是「数出 0 个活跃」到「自己占上」之间  
1226 - // 不可能有第二个连接插进来各自数出 0(check-then-act 的经典超发)。 1326 + // 额度在**进事务之前**解析:它要读一次盘(company.json)+ JSON.parse,塞进 BEGIN IMMEDIATE
  1327 + // 就是拿着 SQLite 写锁去做 syscall,而且文件坏掉时会在事务里抛。
  1328 + // 这一步不需要在事务里:需要原子的是「数坑 + 占坑」,不是「读配置」——
  1329 + // 这一刻读到的额度即使下一毫秒被人改了,最多影响这一次调度,不影响任何不变量。
  1330 + const max = maxActiveRootRuns(cur.company_id);
  1331 +
  1332 + // BEGIN IMMEDIATE:**读之前**就拿到写锁,于是「数出还有空位」到「自己占上」之间
  1333 + // 不可能有第二个连接插进来各自数出还有空位(check-then-act 的经典超发)。
1227 // 同进程内 better-sqlite3 是同步单写者本来就串行,这把锁防的是多进程 / 多连接。 1334 // 同进程内 better-sqlite3 是同步单写者本来就串行,这把锁防的是多进程 / 多连接。
1228 - // 唯一索引仍在,是兜底那一层:主守卫(本事务)与兜底(DB 约束)并存,批 4 只 DROP 后者。 1335 + // 批 4 起这是**唯一**的守卫:`idx_graph_runs_one_active_root` 那道 DB 兜底已经 DROP
  1336 + // (理由与四步论证见 getDb 迁移块),所以下面这四行今天就得是对的,没有第二道网接着。
1229 return db.transaction(() => { 1337 return db.transaction(() => {
1230 // 事务内重读才是权威的。状态已经不是 expected → 这次调用本来就是幂等 no-op 1338 // 事务内重读才是权威的。状态已经不是 expected → 这次调用本来就是幂等 no-op
1231 // (下面 UPDATE 的 WHERE 同样匹配不到),返回 0。**绝不能**当成坑满抛错: 1339 // (下面 UPDATE 的 WHERE 同样匹配不到),返回 0。**绝不能**当成坑满抛错:
@@ -1234,8 +1342,8 @@ function updateRunStatus(runId, from, to, patch = {}) { @@ -1234,8 +1342,8 @@ function updateRunStatus(runId, from, to, patch = {}) {
1234 if (!fresh || fresh.run_status !== expected) return 0; 1342 if (!fresh || fresh.run_status !== expected) return 0;
1235 1343
1236 const heldBy = listActiveRootRunIds(cur.company_id, db); 1344 const heldBy = listActiveRootRunIds(cur.company_id, db);
1237 - if (heldBy.length >= MAX_ACTIVE_ROOT_RUNS) {  
1238 - throw new RunSlotBusyError(cur.company_id, runId, heldBy, MAX_ACTIVE_ROOT_RUNS); 1345 + if (heldBy.length >= max) {
  1346 + throw new RunSlotBusyError(cur.company_id, runId, heldBy, max);
1239 } 1347 }
1240 return write(); 1348 return write();
1241 }).immediate(); 1349 }).immediate();
@@ -2285,7 +2393,7 @@ module.exports = { @@ -2285,7 +2393,7 @@ module.exports = {
2285 updateNodeStatus, 2393 updateNodeStatus,
2286 bumpNodeAttempt, 2394 bumpNodeAttempt,
2287 updateRunStatus, 2395 updateRunStatus,
2288 - // 「坑满没满」的唯一计数处(WHERE 与唯一索引同源),调度器复用它 2396 + // 「坑满没满」的唯一计数处(WHERE 与准入事务同源),调度器 / 路由复用它
2289 countActiveRootRuns, 2397 countActiveRootRuns,
2290 listActiveRootRunIds, 2398 listActiveRootRunIds,
2291 setRunFlags, 2399 setRunFlags,
@@ -2347,10 +2455,14 @@ module.exports = { @@ -2347,10 +2455,14 @@ module.exports = {
2347 // 状态机 2455 // 状态机
2348 NODE_TRANSITIONS, 2456 NODE_TRANSITIONS,
2349 RUN_TRANSITIONS, 2457 RUN_TRANSITIONS,
2350 - // 唯一索引的 WHERE 与调度器的「坑满没满」判据共用这一个数组,别再抄字面量 2458 + // 准入事务与调度器的「坑满没满」判据共用这一个数组,别再抄字面量
2351 ACTIVE_ROOT_RUN_STATUS, 2459 ACTIVE_ROOT_RUN_STATUS,
2352 - // 并行度上限:批 3 恒为 1,批 4 改成读 company.json。调度器取这里,不许再写一个 1  
2353 - MAX_ACTIVE_ROOT_RUNS, 2460 + // 并行度上限:per-company,读 company.json.max_parallel_tasks。
  2461 + // **故意不导出一个常量**:批 3 那个 `MAX_ACTIVE_ROOT_RUNS = 1` 一旦留下来,
  2462 + // 就会有人拿它当「上限」用,而它对一家 max=3 的公司是错的(且不报错)。
  2463 + maxActiveRootRuns,
  2464 + DEFAULT_MAX_ACTIVE_ROOT_RUNS,
  2465 + MAX_ACTIVE_ROOT_RUNS_CAP,
2354 canTransitionNode, 2466 canTransitionNode,
2355 canTransitionRun, 2467 canTransitionRun,
2356 InvalidTransitionError, 2468 InvalidTransitionError,
server/tests/unit/goal-task-acceptance.test.js
@@ -1549,7 +1549,7 @@ describe(&#39;27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)&#39;, () =&gt; { @@ -1549,7 +1549,7 @@ describe(&#39;27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)&#39;, () =&gt; {
1549 for (let t = 0; t < 3; t++) { 1549 for (let t = 0; t < 3; t++) {
1550 const task = wdb.createTask({ goalId: goal.id, title: `T${g}-${t}`, description: `活 ${g}-${t}` }); 1550 const task = wdb.createTask({ goalId: goal.id, title: `T${g}-${t}`, description: `活 ${g}-${t}` });
1551 // 每条 task 挂一条 run + 2 个节点行:没有 run 的 task 走不到进度查询,挡不住 N+1。 1551 // 每条 task 挂一条 run + 2 个节点行:没有 run 的 task 走不到进度查询,挡不住 N+1。
1552 - // run_status 用 completed —— idx_graph_runs_one_active_root 只许一条活跃 root run, 1552 + // run_status 用 completed —— 活跃 root run 是要占公司执行坑的(批 4 前还有一条同名唯一索引),
1553 // 而这里要的是 15 条历史 run 同时出现在列表上(那正是 N+1 最疼的形态)。 1553 // 而这里要的是 15 条历史 run 同时出现在列表上(那正是 N+1 最疼的形态)。
1554 const runId = `run_n1_${g}_${t}`; 1554 const runId = `run_n1_${g}_${t}`;
1555 db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at) 1555 db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at)
server/tests/unit/goal-task-model.test.js
@@ -23,7 +23,7 @@ import { @@ -23,7 +23,7 @@ import {
23 dependsLabel, pickDefaultSelection, findTask, runStateToTaskPatch, 23 dependsLabel, pickDefaultSelection, findTask, runStateToTaskPatch,
24 RUN_TERMINAL_TO_TASK_STATUS, 24 RUN_TERMINAL_TO_TASK_STATUS,
25 queueBadge, parseBlockedReason, blockedLabel, taskActions, queueStat, queueNoteText, 25 queueBadge, parseBlockedReason, blockedLabel, taskActions, queueStat, queueNoteText,
26 - dispatchedTaskId, pendingRunHint, hasStartedRun, UPSTREAM_BLOCK_PREFIX, 26 + dispatchedTaskId, pendingRunHint, hasStartedRun, UPSTREAM_BLOCK_PREFIX, readSlots,
27 } from '../../../web/src/components/graph-console/goal-task-model.js'; 27 } from '../../../web/src/components/graph-console/goal-task-model.js';
28 28
29 const T0 = Date.parse('2026-08-18T10:00:00.000Z'); 29 const T0 = Date.parse('2026-08-18T10:00:00.000Z');
@@ -332,6 +332,139 @@ describe(&#39;「新目标」的队列提示&#39;, () =&gt; { @@ -332,6 +332,139 @@ describe(&#39;「新目标」的队列提示&#39;, () =&gt; {
332 }); 332 });
333 }); 333 });
334 334
  335 +describe('执行位(批 4 · 一家公司可以同时跑几条)', () => {
  336 + it('三个数全部读服务端的,free 连减法都不做 —— 一做减法就是第二个持有者', () => {
  337 + expect(readSlots({ max: 3, used: 2, free: 1 })).toEqual({ max: 3, used: 2, free: 1 });
  338 + expect(readSlots({ max: 1, used: 0, free: 1 })).toEqual({ max: 1, used: 0, free: 1 });
  339 + // max 刚被改小时服务端给的是截断过的 free(slotState 里的 Math.max(0, …)),原样带走
  340 + expect(readSlots({ max: 3, used: 5, free: 0 })).toEqual({ max: 3, used: 5, free: 0 });
  341 + // holders 一起来也不碍事:这里只读三个数
  342 + expect(readSlots({ max: 3, used: 3, free: 0, holders: [{ runId: 'r1' }] }))
  343 + .toEqual({ max: 3, used: 3, free: 0 });
  344 + });
  345 +
  346 + it('形状不对一律 null —— 界面宁可少说一句,也不能凭空说出一个执行位总数', () => {
  347 + expect(readSlots(null)).toBeNull();
  348 + expect(readSlots({})).toBeNull();
  349 + expect(readSlots({ max: 3, used: 1 })).toBeNull(); // 缺 free
  350 + expect(readSlots({ used: 1, free: 2 })).toBeNull(); // 缺 max
  351 + expect(readSlots({ max: '3', used: '1', free: '2' })).toBeNull(); // 字符串不认
  352 + expect(readSlots({ max: 3, used: 1.5, free: 1.5 })).toBeNull();
  353 + expect(readSlots({ max: 0, used: 0, free: 0 })).toBeNull(); // max=0 = 永远不出队,不是能显示的值
  354 + // 这两条钉的是「不许走 Number() 转换」:Number(null)/Number('') 都是 0,
  355 + // 一个字段缺失的响应会被读成「0 个执行位在用」并原样显示出去
  356 + expect(readSlots({ max: 3, used: null, free: 3 })).toBeNull();
  357 + expect(readSlots({ max: 3, used: '', free: 3 })).toBeNull();
  358 + });
  359 +
  360 + it('形状逐字同服务端唯一那一份(routes/graph-run.js#slotState 的返回值)', () => {
  361 + // slotState(companyId) → { max, used, free, holders };这里连字段名都不翻译
  362 + const fromServer = { max: 3, used: 3, free: 0, holders: [{ runId: 'r1', taskId: 'T-1' }] };
  363 + expect(readSlots(fromServer)).toEqual({ max: 3, used: 3, free: 0 });
  364 + });
  365 +});
  366 +
  367 +describe('并行下的队列文案', () => {
  368 + it('有 slots 就报「几分之几个执行位」——「1 个在跑」在 3 坑公司里读起来像满了', () => {
  369 + const txt = queueNoteText({ running: 1, queued: 2, blocked: 0 }, { max: 3, used: 1, free: 2 });
  370 + expect(txt).toContain('1/3 个执行位在用');
  371 + expect(txt).toContain('2 个在排队');
  372 + expect(txt).toContain('还空着 2 个执行位');
  373 + expect(txt).not.toMatch(/第\s*\d+\s*位/); // 位次照旧一个都不编
  374 + });
  375 +
  376 + it('坑满时不说「还空着」,退回「排在它们后面」', () => {
  377 + const txt = queueNoteText({ running: 3, queued: 1, blocked: 0 }, { max: 3, used: 3, free: 0 });
  378 + expect(txt).toContain('3/3 个执行位在用');
  379 + expect(txt).not.toContain('还空着');
  380 + expect(txt).toContain('坑一空就自动开跑');
  381 + });
  382 +
  383 + it('slots 给不出时逐字退回批 3 原样 —— 今天服务端还没吐这个字段,走的就是这一支', () => {
  384 + const before = queueNoteText({ running: 1, queued: 2, blocked: 1 });
  385 + expect(before).toBe(queueNoteText({ running: 1, queued: 2, blocked: 1 }, null));
  386 + expect(before).toBe(queueNoteText({ running: 1, queued: 2, blocked: 1 }, { max: 3 }));
  387 + expect(before).toContain('1 个任务在跑');
  388 + expect(before).not.toContain('执行位');
  389 + });
  390 +
  391 + it('used 与 running 对不上时两个都报,不替服务端断言它们该相等', () => {
  392 + // used 数的是活跃 root run(含 ready/paused),running 数的是 task 行 —— 不是同一件事
  393 + const txt = queueNoteText({ running: 1, queued: 0, blocked: 0 }, { max: 3, used: 2, free: 1 });
  394 + expect(txt).toContain('2/3 个执行位在用');
  395 + expect(txt).toContain('1 个任务在跑');
  396 + });
  397 +
  398 + it('队里空但有执行位在用时也要说话(在跑的那条不在 queueStat 的三个数里也算数)', () => {
  399 + expect(queueNoteText({ running: 0, queued: 0, blocked: 0 }, { max: 3, used: 1, free: 2 }))
  400 + .toContain('1/3 个执行位在用');
  401 + // 真的一个都没有:还是不说话
  402 + expect(queueNoteText({ running: 0, queued: 0, blocked: 0 }, { max: 3, used: 0, free: 3 })).toBe('');
  403 + });
  404 +});
  405 +
  406 +describe('排队中的任务:还要等多久', () => {
  407 + it('位次 ≤ 空位 = 下一轮就开跑,不是「要等前面的收敛」', () => {
  408 + const h = pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 2 }, { max: 3, used: 1, free: 2 });
  409 + expect(h).toContain('排在第 2 位');
  410 + expect(h).toContain('还空着 2 个');
  411 + expect(h).toContain('下一轮调度');
  412 + expect(h).not.toContain('要等');
  413 + });
  414 +
  415 + it('坑满时说「还要等几个执行位腾出来」,不是「前面还有几个任务」', () => {
  416 + // 位次 4、3 个坑全满:空位会先被前 3 条占掉,我要等 4 个坑腾出来才轮到
  417 + const h = pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 4 }, { max: 3, used: 3, free: 0 });
  418 + expect(h).toContain('还要等 4 个执行位腾出来');
  419 + expect(h).not.toContain('要等 0 个');
  420 + // 队首 + 满坑 = 等 1 个(旧文案在这里会说「前面还有 0 个」)
  421 + expect(pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 1 }, { max: 1, used: 1, free: 0 }))
  422 + .toContain('还要等 1 个执行位腾出来');
  423 + });
  424 +
  425 + it('manual 的排队任务:空位再多也不会自己动 —— 调度器只出队 auto', () => {
  426 + const h = pendingRunHint({ status: 'queued', mode: 'manual', queuePosition: 1 }, { max: 3, used: 0, free: 3 });
  427 + expect(h).toContain('手动');
  428 + expect(h).toContain('立即开始');
  429 + expect(h).not.toContain('自动开跑');
  430 + // 没有 slots 时同样要说,这条与并行无关
  431 + expect(pendingRunHint({ status: 'queued', mode: 'manual', queuePosition: 1 })).toContain('立即开始');
  432 + });
  433 +
  434 + it('slots 给不出时逐字退回批 3 原样', () => {
  435 + expect(pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 3 }))
  436 + .toContain('前面还有 2 个任务');
  437 + expect(pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 1 }))
  438 + .toContain('排在队首');
  439 + expect(pendingRunHint({ status: 'queued', mode: 'auto' }, { max: 3, used: 1, free: 2 }))
  440 + .toContain('服务端没给位次');
  441 + });
  442 +});
  443 +
  444 +describe('并行下左栏每一行', () => {
  445 + it('位次只对 queued 有意义:N 条同时 running 一个位次都不标', () => {
  446 + expect(queueBadge({ status: 'running', queuePosition: 1 })).toBeNull();
  447 + expect(queueBadge({ status: 'running', queuePosition: 2 })).toBeNull();
  448 + expect(queueBadge({ status: 'blocked', queuePosition: 3 })).toBeNull();
  449 + expect(queueBadge({ status: 'queued', queuePosition: 3 }).text).toBe('排队 #3');
  450 + });
  451 +
  452 + it('N 条并行时默认选中最新那条,且重复调用结果稳定(不会每轮轮询跳一条)', () => {
  453 + const goals = [{
  454 + goalId: 'G-1',
  455 + tasks: [
  456 + { taskId: 'T-a', status: 'running', createdAt: '2026-08-18T10:00:00Z' },
  457 + { taskId: 'T-b', status: 'running', createdAt: '2026-08-18T12:00:00Z' },
  458 + { taskId: 'T-c', status: 'running', createdAt: '2026-08-18T11:00:00Z' },
  459 + ],
  460 + }];
  461 + expect(pickDefaultSelection(goals)).toEqual({ goalId: 'G-1', taskId: 'T-b' });
  462 + // 数组顺序变了也选同一条:排序只看 createdAt,不吃调用方的顺序
  463 + const shuffled = [{ goalId: 'G-1', tasks: [goals[0].tasks[1], goals[0].tasks[2], goals[0].tasks[0]] }];
  464 + expect(pickDefaultSelection(shuffled)).toEqual({ goalId: 'G-1', taskId: 'T-b' });
  465 + });
  466 +});
  467 +
335 describe('dispatch 响应 → 选中态', () => { 468 describe('dispatch 响应 → 选中态', () => {
336 it('起了 run(started)与入了队(queued[])两种形状都认;认不出就返回空串,不猜一条出来', () => { 469 it('起了 run(started)与入了队(queued[])两种形状都认;认不出就返回空串,不猜一条出来', () => {
337 expect(dispatchedTaskId({ started: { taskId: 'T-1' } })).toBe('T-1'); 470 expect(dispatchedTaskId({ started: { taskId: 'T-1' } })).toBe('T-1');
server/tests/unit/graph-run-route-slot-busy.test.js
@@ -111,7 +111,10 @@ describe(&#39;POST /api/graph-runs/... 撞上坑满 → 409(不是 500)&#39;, () =&gt; @@ -111,7 +111,10 @@ describe(&#39;POST /api/graph-runs/... 撞上坑满 → 409(不是 500)&#39;, () =&gt;
111 expect(r.status).toBe(409); // 修之前:500 + 一句 "company … 的执行坑已满" 原文 111 expect(r.status).toBe(409); // 修之前:500 + 一句 "company … 的执行坑已满" 原文
112 expect(body.reason).toBe('run-slot-busy'); 112 expect(body.reason).toBe('run-slot-busy');
113 expect(body.companyId).toBe(CID); 113 expect(body.companyId).toBe(CID);
114 - expect(body.max).toBe(wdb.MAX_ACTIVE_ROOT_RUNS); 114 + // 批 4 起额度是 per-company 的(company.json.max_parallel_tasks,这家没配 → 缺省 1)。
  115 + // 钉的是「409 里报的额度 = 准入真的用的那个额度」,不是钉某个常量。
  116 + expect(body.max).toBe(wdb.maxActiveRootRuns(CID));
  117 + expect(body.max).toBe(wdb.DEFAULT_MAX_ACTIVE_ROOT_RUNS);
115 118
116 // 「挡我的是谁」:run id 与 task id 都要给得出来,否则界面上只剩「坑满」 119 // 「挡我的是谁」:run id 与 task id 都要给得出来,否则界面上只剩「坑满」
117 expect(body.holders).toEqual([ 120 expect(body.holders).toEqual([
server/tests/unit/task-queue-acceptance.test.js
@@ -276,20 +276,37 @@ describe(&#39;批 3 排队 · §八 验收&#39;, () =&gt; { @@ -276,20 +276,37 @@ describe(&#39;批 3 排队 · §八 验收&#39;, () =&gt; {
276 const runStatus = (id) => wdb.getRun(id).run_status; 276 const runStatus = (id) => wdb.getRun(id).run_status;
277 277
278 // ────────────────────────────────────────────────────────────── 278 // ──────────────────────────────────────────────────────────────
279 - // 唯一索引:queued 不在 WHERE 里 279 + // 批 4:唯一索引已 DROP —— 事务准入是唯一的守卫
280 // ────────────────────────────────────────────────────────────── 280 // ──────────────────────────────────────────────────────────────
281 - it('唯一索引的 WHERE 里没有 queued —— 出队原子性的全部依赖,直接对着建表 SQL 断言', () => { 281 + /**
  282 + * 批 3 这一条钉的是「唯一索引的 WHERE 里没有 queued」(当时出队原子性靠它兜底)。
  283 + * 批 4 把索引 DROP 了 —— 但要钉的事情**没变**:超发必须拦得住。所以这一条整体反转:
  284 + * · 索引必须**不存在**(`CREATE ... IF NOT EXISTS` 对存量库不生效,所以必须显式 DROP。
  285 + * 它要是还在,max>1 的第二条准入会在事务放行之后吃一句 SQLite 原文的 500);
  286 + * · 主守卫必须**真的**是主守卫,而且此刻就在拦超发(下面直接演示)。
  287 + * 断言索引不在,而不只是断言 JS 常量没了:两者一旦分叉,只看常量的用例照样绿。
  288 + */
  289 + it('唯一索引已 DROP,且主守卫独自拦得住超发 —— 删兜底之后 max=1 的行为一个字不变', () => {
282 const row = wdb.getDb() 290 const row = wdb.getDb()
283 .prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_graph_runs_one_active_root'`) 291 .prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_graph_runs_one_active_root'`)
284 .get(); 292 .get();
285 - expect(row).toBeTruthy();  
286 - // 断言的是**索引自己的 SQL**,不是 JS 常量:常量与索引一旦分叉,只看常量的用例照样绿  
287 - expect(row.sql).toMatch(/parent_run_id IS NULL/);  
288 - expect(row.sql).not.toMatch(/queued/);  
289 - for (const s of ['ready', 'running', 'paused']) expect(row.sql).toContain(`'${s}'`);  
290 - // 反过来也要成立:JS 侧那份取值域与索引逐字同源 293 + expect(row).toBeUndefined();
  294 +
  295 + // 取值域仍是准入判据的唯一持有者(queued 不在里面 = 排队不占坑)
291 expect(wdb.ACTIVE_ROOT_RUN_STATUS).toEqual(['ready', 'running', 'paused']); 296 expect(wdb.ACTIVE_ROOT_RUN_STATUS).toEqual(['ready', 'running', 'paused']);
292 - expect(wdb.MAX_ACTIVE_ROOT_RUNS).toBe(1); 297 + expect(wdb.ACTIVE_ROOT_RUN_STATUS).not.toContain('queued');
  298 + // 没配 max_parallel_tasks 的公司回落 1 —— 存量行为不变的那条保证
  299 + const companyId = nextCompany();
  300 + expect(wdb.maxActiveRootRuns(companyId)).toBe(1);
  301 +
  302 + // 主守卫单独上阵:第一条进坑,第二条被事务拒(RunSlotBusyError,不是 SqliteError)
  303 + const goalId = newGoal(companyId);
  304 + const a = enqueue(companyId, goalId, { title: 'a' });
  305 + const b = enqueue(companyId, goalId, { title: 'b' });
  306 + expect(wdb.updateRunStatus(a.runId, 'queued', 'ready')).toBe(1);
  307 + expect(() => wdb.updateRunStatus(b.runId, 'queued', 'ready')).toThrow(wdb.RunSlotBusyError);
  308 + expect(wdb.getRun(b.runId).run_status).toBe('queued'); // 拒绝是干净的:一个字都没写
  309 + expect(wdb.countActiveRootRuns(companyId)).toBe(1);
293 }); 310 });
294 311
295 // ────────────────────────────────────────────────────────────── 312 // ──────────────────────────────────────────────────────────────
@@ -585,6 +602,43 @@ describe(&#39;批 3 排队 · §八 验收&#39;, () =&gt; { @@ -585,6 +602,43 @@ describe(&#39;批 3 排队 · §八 验收&#39;, () =&gt; {
585 expect(wdb.countActiveRootRuns(companyId)).toBe(1); 602 expect(wdb.countActiveRootRuns(companyId)).toBe(1);
586 }); 603 });
587 604
  605 + it('L1:人亲手起过的 manual 被退回队列后,坑一空自己接回来(不用再点一次)', async () => {
  606 + const companyId = nextCompany();
  607 + const goalId = newGoal(companyId);
  608 + hangingExecutor();
  609 +
  610 + // 人亲手起跑一条 manual —— 这一步就是「授权」,started_at 在这里被写上
  611 + const m = enqueue(companyId, goalId, { title: 'm', mode: 'manual', priority: 'P0' });
  612 + expect(humanStart(wdb.getTask(m.taskId))).toBeTruthy();
  613 + expect(wdb.getTask(m.taskId).attempt).toBeGreaterThan(0); // 授权的事实记在 attempt 上
  614 +
  615 + // 它撞上预算/挂起被退回队列(直接用数据层那条退路,等价于 admitOrRequeue 走到的分支)
  616 + expect(sse.transitionRun(m.runId, 'running', 'blocked', {}, { summary: '用例:预算耗尽' })).toBeTruthy();
  617 + expect(wdb.requeueBlockedRootRun(m.runId)).toBeTruthy();
  618 + expect(taskStatus(m.taskId)).toBe('queued');
  619 + expect(runStatus(m.runId)).toBe('queued');
  620 +
  621 + // 坑是空的 —— 批 3 那版这里会永远躺着(调度器 filter 只认 auto),人得**另外**再点一次
  622 + expect(wdb.countActiveRootRuns(companyId)).toBe(0);
  623 + scheduler.tick(companyId);
  624 +
  625 + // 现在它自己回来了:人刚才那次点击被兑现,而不是被吞掉
  626 + expect(taskStatus(m.taskId)).toBe('running');
  627 + expect(runStatus(m.runId)).toBe('running');
  628 + });
  629 +
  630 + it('L1 反面:从没被人起过的 manual 仍然不许自动出队(首次起跑的授权不能省)', () => {
  631 + const companyId = nextCompany();
  632 + const goalId = newGoal(companyId);
  633 + hangingExecutor();
  634 + const m = enqueue(companyId, goalId, { title: 'm', mode: 'manual', priority: 'P0' });
  635 + expect(wdb.getTask(m.taskId).attempt).toBe(0); // 从没被人起过
  636 + scheduler.tick(companyId);
  637 + scheduler.tick(companyId);
  638 + expect(taskStatus(m.taskId)).toBe('queued');
  639 + expect(wdb.countActiveRootRuns(companyId)).toBe(0);
  640 + });
  641 +
588 // ────────────────────────────────────────────────────────────── 642 // ──────────────────────────────────────────────────────────────
589 // 验收 23:blocked 是推导出来的,不是落死的 643 // 验收 23:blocked 是推导出来的,不是落死的
590 // ────────────────────────────────────────────────────────────── 644 // ──────────────────────────────────────────────────────────────
server/tests/unit/task-queue-routes.test.js
@@ -226,17 +226,24 @@ describe(&#39;批 3 排队 · HTTP 端点(27-P3C §六.3)&#39;, () =&gt; { @@ -226,17 +226,24 @@ describe(&#39;批 3 排队 · HTTP 端点(27-P3C §六.3)&#39;, () =&gt; {
226 // /start —— manual 任务唯一的起跑路径(§五.2bis) 226 // /start —— manual 任务唯一的起跑路径(§五.2bis)
227 // ──────────────────────────────────────────────────────────────── 227 // ────────────────────────────────────────────────────────────────
228 228
229 - it('/start 把排队中的 manual 拉起来;坑被占着时回 409 + 当前位次,绝不假装已开始', async () => { 229 + it('/start 把排队中的 manual 拉起来;执行位满时回 409 + 当前位次,绝不假装已开始', async () => {
230 const cid = newCompany(); 230 const cid = newCompany();
231 const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }]); 231 const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }]);
232 const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); 232 const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
233 const runningId = dispatched.started.taskId; 233 const runningId = dispatched.started.taskId;
234 const queuedId = goal.tasks.find((t) => t.taskId !== runningId).taskId; 234 const queuedId = goal.tasks.find((t) => t.taskId !== runningId).taskId;
235 235
236 - // 坑被占着:如实回 409。回 200 的话界面会显示「已开始」而实际什么都没跑 236 + // 执行位被占满:如实回 409。回 200 的话界面会显示「已开始」而实际什么都没跑
237 const [busy, err] = await call('POST', `/api/company/${cid}/tasks/${queuedId}/start`); 237 const [busy, err] = await call('POST', `/api/company/${cid}/tasks/${queuedId}/start`);
238 expect(busy).toBe(409); 238 expect(busy).toBe(409);
239 - expect(err.activeRunId).toBe(dispatched.started.runId); 239 + // 批 4:形状与 graph-run.js 的 run-slot-busy 逐字同源(那边 holders 从批 3 起就是数组)。
  240 + // 这里以前是 activeRunId 一个单数字段 —— N 个执行位时只点名一条,用户停掉它发现还是起不来。
  241 + expect(err.reason).toBe('run-slot-busy');
  242 + expect(err.max).toBe(wdb.maxActiveRootRuns(cid)); // 这家没配 max_parallel_tasks → 缺省 1
  243 + expect(err.active).toBe(1);
  244 + expect(err.holders).toEqual([
  245 + expect.objectContaining({ runId: dispatched.started.runId, taskId: runningId, runStatus: 'running' }),
  246 + ]);
240 expect(err.queuePosition).toBe(1); 247 expect(err.queuePosition).toBe(1);
241 expect(wdb.getTask(queuedId).status).toBe('queued'); // 状态一个字节都没动 248 expect(wdb.getTask(queuedId).status).toBe('queued'); // 状态一个字节都没动
242 249
@@ -250,6 +257,57 @@ describe(&#39;批 3 排队 · HTTP 端点(27-P3C §六.3)&#39;, () =&gt; { @@ -250,6 +257,57 @@ describe(&#39;批 3 排队 · HTTP 端点(27-P3C §六.3)&#39;, () =&gt; {
250 expect(wdb.getTask(queuedId).attempt).toBe(1); 257 expect(wdb.getTask(queuedId).attempt).toBe(1);
251 }); 258 });
252 259
  260 + it('max_parallel_tasks=3:一次下达把 3 个执行位填满并全部报出来;满了才 409,空一个就起得来', async () => {
  261 + const cid = newCompany();
  262 + // 额度是 per-company 的,写在 company.json 里(走 company-paths,认 GRAPH_RUNS_DIR)。
  263 + // 别用 CompanyService:那份 stub 不认这个字段,而准入读的也不是它。
  264 + const dir = path.join(runsDir, '_companies', cid);
  265 + fs.mkdirSync(dir, { recursive: true });
  266 + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: cid, max_parallel_tasks: 3 }));
  267 + expect(wdb.maxActiveRootRuns(cid)).toBe(3);
  268 +
  269 + const goal = await newGoal(cid, [
  270 + { description: 'a' }, { description: 'b' }, { description: 'c' }, { description: 'd' },
  271 + ]);
  272 + const [st, body] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
  273 + expect(st).toBe(201);
  274 +
  275 + // 批 4 之前这里只会起 1 条(`if (activeRootRun) return null`),而且只报第一条。
  276 + // 两个毛病都是「不报错的少做 / 少说」,界面上与正常排队一模一样。
  277 + expect(body.startedTasks).toHaveLength(3);
  278 + expect(body.started).toEqual(body.startedTasks[0]); // 单数字段与数组同源,不是另算一遍
  279 + expect(body.queued.filter((q) => q.queuePosition).map((q) => q.queuePosition)).toEqual([1]);
  280 +
  281 + // 左栏轮询的两个端点要给得出「几分之几个执行位」——前端 readSlots 读的就是这三个数,
  282 + // 它**不自己算 free**(free 带着「max 被改小时截断到 0」的语义)。给不出 = 文案退回批 3。
  283 + const [gs, tree] = await call('GET', `/api/company/${cid}/goals?withTasks=1`);
  284 + expect(gs).toBe(200);
  285 + expect(tree.slots).toEqual({ max: 3, used: 3, free: 0 });
  286 + const [ts, list] = await call('GET', `/api/company/${cid}/tasks`);
  287 + expect(ts).toBe(200);
  288 + expect(list.slots).toEqual({ max: 3, used: 3, free: 0 });
  289 +
  290 + const startedIds = body.startedTasks.map((s) => s.taskId);
  291 + const waiting = goal.tasks.find((t) => !startedIds.includes(t.taskId)).taskId;
  292 +
  293 + // 位子真满了才 409,而且要说得出「几条在跑 / 上限几 / 谁占着」
  294 + const [busy, err] = await call('POST', `/api/company/${cid}/tasks/${waiting}/start`);
  295 + expect(busy).toBe(409);
  296 + expect(err.reason).toBe('run-slot-busy');
  297 + expect(err.active).toBe(3);
  298 + expect(err.max).toBe(3);
  299 + expect(err.holders.map((h) => h.runId).sort()).toEqual(body.startedTasks.map((s) => s.runId).sort());
  300 + // hint 要点名**所有**停得掉的那几条:只给一条的话,用户停掉它发现还是起不来
  301 + for (const s of body.startedTasks) expect(err.hint).toContain(`/api/graph-runs/${s.runId}/stop`);
  302 +
  303 + // 腾出一个位 → manual 立刻起得来(3 个位空着 1 个还拒绝,正是批 4 要消灭的那个 bug)
  304 + sse.transitionRun(body.startedTasks[0].runId, 'running', 'completed', {}, { summary: '用例直推终态' });
  305 + const [ok, res] = await call('POST', `/api/company/${cid}/tasks/${waiting}/start`);
  306 + expect(ok).toBe(201);
  307 + expect(res.started.taskId).toBe(waiting);
  308 + expect(wdb.countActiveRootRuns(cid)).toBe(3);
  309 + });
  310 +
253 it('/start 对 draft 与「上游没做完」各给各的 409,并点名挡我的是谁', async () => { 311 it('/start 对 draft 与「上游没做完」各给各的 409,并点名挡我的是谁', async () => {
254 const cid = newCompany(); 312 const cid = newCompany();
255 const goal = await newGoal(cid, [ 313 const goal = await newGoal(cid, [
@@ -272,7 +330,8 @@ describe(&#39;批 3 排队 · HTTP 端点(27-P3C §六.3)&#39;, () =&gt; { @@ -272,7 +330,8 @@ describe(&#39;批 3 排队 · HTTP 端点(27-P3C §六.3)&#39;, () =&gt; {
272 const [depStatus, depErr] = await call('POST', `/api/company/${cid}/tasks/${deploy.taskId}/start`); 330 const [depStatus, depErr] = await call('POST', `/api/company/${cid}/tasks/${deploy.taskId}/start`);
273 expect(depStatus).toBe(409); 331 expect(depStatus).toBe(409);
274 expect(depErr.blockedBy).toEqual([build.taskId]); 332 expect(depErr.blockedBy).toEqual([build.taskId]);
275 - expect(depErr.activeRunId).toBeUndefined(); 333 + expect(depErr.reason).toBeUndefined(); // 不是 run-slot-busy:别把人引去 stop 那条没用的路
  334 + expect(depErr.holders).toBeUndefined();
276 335
277 // 上游被停掉(cancelled = 永远不会 completed)之后,下一轮 tick 把它推导成 blocked 336 // 上游被停掉(cancelled = 永远不会 completed)之后,下一轮 tick 把它推导成 blocked
278 // 并写明是谁挡的 —— 不许让它继续以 queued 的样子躺在队里假装在排队(§五.1.2) 337 // 并写明是谁挡的 —— 不许让它继续以 queued 的样子躺在队里假装在排队(§五.1.2)
server/tests/unit/task-scheduler.test.js
@@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
7 * 15 排队期间 replan → 出队后按新图跑(syncRunNodesToDef) 7 * 15 排队期间 replan → 出队后按新图跑(syncRunNodesToDef)
8 * 23 上游失败 → 下游 blocked,上游恢复后自动回 queued(blocked 是推导出来的,不落死) 8 * 23 上游失败 → 下游 blocked,上游恢复后自动回 queued(blocked 是推导出来的,不落死)
9 * 外加 §九.1 那张「queued 混进 run_status」的回归表里点名要钉死的三条断言: 9 * 外加 §九.1 那张「queued 混进 run_status」的回归表里点名要钉死的三条断言:
10 - * · 唯一索引不覆盖 queued(出队原子性的全部依赖) 10 + * · queued 不在活跃集里(排队不占坑,出队那一跳才被准入事务数一次)
11 * · advanceOne 挡住 queued run(防止 queued run 被误推进的唯一一道闸) 11 * · advanceOne 挡住 queued run(防止 queued run 被误推进的唯一一道闸)
12 * · startRun 拒绝 queued run(出队顺序不能反过来) 12 * · startRun 拒绝 queued run(出队顺序不能反过来)
13 * 13 *
@@ -105,7 +105,7 @@ describe(&#39;task-scheduler&#39;, () =&gt; { @@ -105,7 +105,7 @@ describe(&#39;task-scheduler&#39;, () =&gt; {
105 105
106 // ── 夹具 ────────────────────────────────────────────────────── 106 // ── 夹具 ──────────────────────────────────────────────────────
107 let seq = 0; 107 let seq = 0;
108 - /** 每个用例一家独立公司:唯一索引是按 company 约束的 */ 108 + /** 每个用例一家独立公司:执行坑是按 company 数的 */
109 const nextCompany = () => `sched-co-${++seq}`; 109 const nextCompany = () => `sched-co-${++seq}`;
110 110
111 function ensureAgents(ids) { 111 function ensureAgents(ids) {
@@ -172,7 +172,7 @@ describe(&#39;task-scheduler&#39;, () =&gt; { @@ -172,7 +172,7 @@ describe(&#39;task-scheduler&#39;, () =&gt; {
172 // §九.1 回归表:queued 不被误当可执行 172 // §九.1 回归表:queued 不被误当可执行
173 // ────────────────────────────────────────────────────────────── 173 // ──────────────────────────────────────────────────────────────
174 describe('queued 混进 run_status 的三道闸', () => { 174 describe('queued 混进 run_status 的三道闸', () => {
175 - it('唯一索引不覆盖 queued:同一公司可以躺任意多条 queued root run,而出队时超发被 DB 拒', () => { 175 + it('queued 不占坑:同一公司可以躺任意多条 queued root run,而出队时超发被准入事务拒', () => {
176 const companyId = nextCompany(); 176 const companyId = nextCompany();
177 const goalId = newGoal(companyId); 177 const goalId = newGoal(companyId);
178 178
@@ -190,12 +190,17 @@ describe(&#39;task-scheduler&#39;, () =&gt; { @@ -190,12 +190,17 @@ describe(&#39;task-scheduler&#39;, () =&gt; {
190 // 第二条被**主守卫**(准入事务里数坑)拒:抛 RunSlotBusyError,**不是** changes = 0 190 // 第二条被**主守卫**(准入事务里数坑)拒:抛 RunSlotBusyError,**不是** changes = 0
191 expect(() => wdb.updateRunStatus(b.runId, 'queued', 'ready')).toThrow(wdb.RunSlotBusyError); 191 expect(() => wdb.updateRunStatus(b.runId, 'queued', 'ready')).toThrow(wdb.RunSlotBusyError);
192 expect(wdb.getRun(b.runId).run_status).toBe('queued'); 192 expect(wdb.getRun(b.runId).run_status).toBe('queued');
193 - // 兜底那一层必须真的还在(注释里承诺了「两道守卫并存」,就得能演示出来):  
194 - // 绕开 updateRunStatus 直接写库,唯一索引照样拒。批 4 DROP 它之后这一条要一起删。 193 + // 批 3 这里演示的是「兜底那一层(唯一索引)也还在」:绕开 updateRunStatus 直接写库照样被拒。
  194 + // 批 4 DROP 了那条索引,所以这一段反过来钉两件事 ——
  195 + // ① 索引确实没了:裸写库现在**写得进去**(这正是「删掉了最后一道 DB 兜底」的样子,
  196 + // 要能演示出来,否则「已 DROP」只是一句注释);
  197 + // ② 于是唯一的守卫只剩上面那个准入事务,它今天就得是对的。
195 expect(() => 198 expect(() =>
196 wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'ready' WHERE id = ?`).run(b.runId) 199 wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'ready' WHERE id = ?`).run(b.runId)
197 - ).toThrow(/UNIQUE constraint failed/);  
198 - expect(wdb.getRun(b.runId).run_status).toBe('queued'); 200 + ).not.toThrow();
  201 + expect(wdb.getRun(b.runId).run_status).toBe('ready');
  202 + // 把裸写的这条放回 queued,别让它污染后面的断言(它是绕过守卫写进去的,不是准入放行的)
  203 + wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'queued' WHERE id = ?`).run(b.runId);
199 // 同一条重复出队 = changes 0(幂等 no-op,不抛) 204 // 同一条重复出队 = changes 0(幂等 no-op,不抛)
200 expect(wdb.updateRunStatus(a.runId, 'queued', 'ready')).toBe(0); 205 expect(wdb.updateRunStatus(a.runId, 'queued', 'ready')).toBe(0);
201 // queued 有出路:撤出队列 206 // queued 有出路:撤出队列
@@ -262,9 +267,11 @@ describe(&#39;task-scheduler&#39;, () =&gt; { @@ -262,9 +267,11 @@ describe(&#39;task-scheduler&#39;, () =&gt; {
262 * 理由写着「routes/company.js 那两处 catch 至今是按文本嗅探的」—— **批 3 把那两处 267 * 理由写着「routes/company.js 那两处 catch 至今是按文本嗅探的」—— **批 3 把那两处
263 * catch 整段删了**(下达不再因坑满失败,改成入队)。于是这条用例一直绿着,钉的却是 268 * catch 整段删了**(下达不再因坑满失败,改成入队)。于是这条用例一直绿着,钉的却是
264 * 一个没有任何调用方的契约,还反过来禁止清理那句报文。 269 * 一个没有任何调用方的契约,还反过来禁止清理那句报文。
265 - * 现在钉真正的那条:判据只有 `work-graph-db.isSlotRaceError` 一个持有者,按类型判,  
266 - * 三种形态一个都不能漏 —— 漏一种,调度器就会把一次正常竞争当故障去 abandonRun(丢任务), 270 + * 现在钉真正的那条:判据只有 `work-graph-db.isSlotRaceError` 一个持有者,按类型判。
  271 + * 该认的一个都不能漏 —— 漏一种,调度器就会把一次正常竞争当故障去 abandonRun(丢任务),
267 * 而路由那边照常 409,两边对同一件事给出相反结论。 272 * 而路由那边照常 409,两边对同一件事给出相反结论。
  273 + * 不该认的也一个都不能多认(批 4 起 SQLITE_CONSTRAINT_UNIQUE 就在这一侧,见下):
  274 + * 认宽了是把真 bug 当正常竞争 continue 掉,那是静默。
268 */ 275 */
269 it('「没抢到坑」的判据只有一个持有者,且按类型判 —— 改一句报错文案不该改变任何分支', () => { 276 it('「没抢到坑」的判据只有一个持有者,且按类型判 —— 改一句报错文案不该改变任何分支', () => {
270 const companyId = nextCompany(); 277 const companyId = nextCompany();
@@ -278,16 +285,23 @@ describe(&#39;task-scheduler&#39;, () =&gt; { @@ -278,16 +285,23 @@ describe(&#39;task-scheduler&#39;, () =&gt; {
278 expect(caught).toBeInstanceOf(wdb.RunSlotBusyError); 285 expect(caught).toBeInstanceOf(wdb.RunSlotBusyError);
279 expect(wdb.isSlotRaceError(caught)).toBe(true); 286 expect(wdb.isSlotRaceError(caught)).toBe(true);
280 287
281 - // 兜底那一层(唯一索引)也必须被认出来:它抛的是 SqliteError,带 code 不带类型  
282 - let dbErr;  
283 - try {  
284 - wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'ready' WHERE id = ?`).run(b.runId);  
285 - } catch (e) { dbErr = e; }  
286 - expect(dbErr.code).toBe('SQLITE_CONSTRAINT_UNIQUE');  
287 - expect(wdb.isSlotRaceError(dbErr)).toBe(true);  
288 -  
289 - // 第三种:这条 run 已经被别人推走了  
290 - expect(wdb.isSlotRaceError(new wdb.InvalidTransitionError('run', 'x', 'queued', 'ready'))).toBe(true); 288 + // 批 3 这里钉的是「兜底那一层(唯一索引)抛的 SqliteError 也必须被认成『没抢到』」。
  289 + // 批 4 DROP 了索引,于是这条判据**反过来**:一个 SQLITE_CONSTRAINT_UNIQUE 再出现在
  290 + // 这条路上,只可能是索引被旧版本进程建回来了(max>1 被悄悄压回 1)。
  291 + // 认成「没抢到」的话调度器会 continue 掉,任务永远排队、日志一行没有 ——
  292 + // 正是要消灭的「写不进去但不报错」。所以它必须**不**算竞争,一路抛到人能看见的地方。
  293 + const fakeUniqueErr = Object.assign(new Error('UNIQUE constraint failed: graph_runs.company_id'), {
  294 + code: 'SQLITE_CONSTRAINT_UNIQUE',
  295 + });
  296 + expect(wdb.isSlotRaceError(fakeUniqueErr)).toBe(false);
  297 +
  298 + // ⚠️ 批 3 这里断言 InvalidTransitionError 也算「没抢到」,注释写着「这条 run 已经被别人
  299 + // 从 queued 推走了」。**探针实测证伪**:被别人推走时 updateRunStatus 走的是条件更新
  300 + // `WHERE id=? AND run_status=@from`,**返回 0、不抛异常**;真正抛 InvalidTransitionError
  301 + // 的只有「调用方声明了非法转换」(expected 是调用方传进来的声明值,canTransitionRun
  302 + // judge 的是 expected→to 而不是 cur.run_status→to)。
  303 + // 把调用方的 bug 当成正常竞争吞掉、还不留日志 —— 与上面那条 UNIQUE 同一个病。
  304 + expect(wdb.isSlotRaceError(new wdb.InvalidTransitionError('run', 'x', 'queued', 'ready'))).toBe(false);
291 // 别的错误一律不算「没抢到」—— 认宽了就是把真 bug 当成正常竞争 continue 掉 305 // 别的错误一律不算「没抢到」—— 认宽了就是把真 bug 当成正常竞争 continue 掉
292 expect(wdb.isSlotRaceError(new Error('UNIQUE constraint failed: 别处的错'))).toBe(false); 306 expect(wdb.isSlotRaceError(new Error('UNIQUE constraint failed: 别处的错'))).toBe(false);
293 expect(wdb.isSlotRaceError(null)).toBe(false); 307 expect(wdb.isSlotRaceError(null)).toBe(false);
@@ -315,6 +329,116 @@ describe(&#39;task-scheduler&#39;, () =&gt; { @@ -315,6 +329,116 @@ describe(&#39;task-scheduler&#39;, () =&gt; {
315 }); 329 });
316 }); 330 });
317 331
  332 + /**
  333 + * 批 4:额度从「模块级常量 1」变成 per-company 的 `company.json.max_parallel_tasks`。
  334 + *
  335 + * 这组用例的重点不是「并行能跑」,而是**回落**那一条:全仓几乎所有单测的公司都没有
  336 + * company.json(`sched-co-*` 只是个字符串),一旦回落算错,1195 条测试会一起变色 ——
  337 + * 所以「读不到 = 1」是保护存量的那条断言,必须先钉。
  338 + *
  339 + * 配置写在 `company-paths.companyRootDir()` 下而不是 `data/companies/` 下:
  340 + * 前者认 GRAPH_RUNS_DIR,本文件设了它,于是这组用例不会去碰仓库里的真公司目录
  341 + * (准入判据一旦去读真 data/,撞上同名公司就读到真配置 —— §六 缺陷 A 同型)。
  342 + */
  343 + describe('每家公司自己的执行坑数(company.json.max_parallel_tasks)', () => {
  344 + /** 给这家公司写一份 company.json;`n === null` 表示写一个没有该字段的配置 */
  345 + function setMaxParallel(companyId, n) {
  346 + const { companyRootDir } = require('../../services/company-paths');
  347 + const dir = companyRootDir(companyId);
  348 + fs.mkdirSync(dir, { recursive: true });
  349 + const cfg = { id: companyId, name: companyId };
  350 + if (n !== null) cfg.max_parallel_tasks = n;
  351 + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify(cfg));
  352 + return companyId;
  353 + }
  354 +
  355 + it('没有 company.json 的公司回落 1 —— 存量单测与存量公司的行为一个字不变', () => {
  356 + const companyId = nextCompany();
  357 + expect(wdb.maxActiveRootRuns(companyId)).toBe(wdb.DEFAULT_MAX_ACTIVE_ROOT_RUNS);
  358 + expect(wdb.DEFAULT_MAX_ACTIVE_ROOT_RUNS).toBe(1);
  359 + // 有 company.json 但没写这个字段,同样回落
  360 + expect(wdb.maxActiveRootRuns(setMaxParallel(nextCompany(), null))).toBe(1);
  361 + });
  362 +
  363 + it('非法值一律回落 1,不静默当成无限:0 / 负数 / 小数 / 字符串 / 超上限', () => {
  364 + for (const bad of [0, -3, 1.5, '3', true, null]) {
  365 + expect(wdb.maxActiveRootRuns(setMaxParallel(nextCompany(), bad))).toBe(1);
  366 + }
  367 + // 超上限按上限截断(不是回落 1,也不是当真)
  368 + const huge = setMaxParallel(nextCompany(), 100000);
  369 + expect(wdb.maxActiveRootRuns(huge)).toBe(wdb.MAX_ACTIVE_ROOT_RUNS_CAP);
  370 + });
  371 +
  372 + it('max=3:前三条准入放行,第 4 条被拒且 active / max / heldBy 三个字段都对', () => {
  373 + const companyId = setMaxParallel(nextCompany(), 3);
  374 + const goalId = newGoal(companyId);
  375 + const runs = ['a', 'b', 'c', 'd'].map((title) => enqueue(companyId, goalId, { title }).runId);
  376 +
  377 + for (const [i, runId] of runs.slice(0, 3).entries()) {
  378 + expect(wdb.updateRunStatus(runId, 'queued', 'ready')).toBe(1);
  379 + expect(wdb.countActiveRootRuns(companyId)).toBe(i + 1);
  380 + }
  381 +
  382 + let caught;
  383 + try { wdb.updateRunStatus(runs[3], 'queued', 'ready'); } catch (e) { caught = e; }
  384 + expect(caught).toBeInstanceOf(wdb.RunSlotBusyError);
  385 + expect(caught.max).toBe(3);
  386 + expect(caught.active).toBe(3);
  387 + // heldBy 点得出**具体是谁**占着 —— 少了它 409 上只剩「坑满」,人无从判断该等谁
  388 + expect(caught.heldBy.slice().sort()).toEqual(runs.slice(0, 3).slice().sort());
  389 + expect(wdb.getRun(runs[3]).run_status).toBe('queued'); // 拒绝是干净的
  390 +
  391 + // 让出一个坑,第 4 条同一跳立刻过(额度是「同时几条」,不是「一共几条」)
  392 + expect(wdb.updateRunStatus(runs[0], 'ready', 'running')).toBe(1); // 集内移动,不占新坑
  393 + expect(wdb.updateRunStatus(runs[0], 'running', 'completed')).toBe(1);
  394 + expect(wdb.updateRunStatus(runs[3], 'queued', 'ready')).toBe(1);
  395 + });
  396 +
  397 + it('额度是 per-company 的:A 公司放开到 3,B 公司照样只有 1', () => {
  398 + const a = setMaxParallel(nextCompany(), 3);
  399 + const b = nextCompany(); // 没配 → 1
  400 + const aRuns = ['a1', 'a2'].map((t) => enqueue(a, newGoal(a), { title: t }).runId);
  401 + const bRuns = ['b1', 'b2'].map((t) => enqueue(b, newGoal(b), { title: t }).runId);
  402 +
  403 + for (const runId of aRuns) expect(wdb.updateRunStatus(runId, 'queued', 'ready')).toBe(1);
  404 + expect(wdb.updateRunStatus(bRuns[0], 'queued', 'ready')).toBe(1);
  405 + expect(() => wdb.updateRunStatus(bRuns[1], 'queued', 'ready')).toThrow(wdb.RunSlotBusyError);
  406 + expect(wdb.countActiveRootRuns(a)).toBe(2);
  407 + expect(wdb.countActiveRootRuns(b)).toBe(1);
  408 + });
  409 +
  410 + it('一次 tick 把空坑填满,不是一条一条来(max=3 → 三条同时起跑)', async () => {
  411 + const companyId = setMaxParallel(nextCompany(), 3);
  412 + const goalId = newGoal(companyId);
  413 + hangingExecutor();
  414 + const tasks = ['a', 'b', 'c', 'd'].map((title) => enqueue(companyId, goalId, { title }));
  415 +
  416 + const res = scheduler.tick(companyId);
  417 + expect(res.max).toBe(3);
  418 + expect(res.started.map((s) => s.taskId)).toEqual(tasks.slice(0, 3).map((t) => t.taskId));
  419 + await flush();
  420 + expect(wdb.countActiveRootRuns(companyId)).toBe(3);
  421 + expect(wdb.getTask(tasks[3].taskId).status).toBe('queued'); // 第 4 条还在队里
  422 + // 再 tick 一次不该超发:坑满就原地返回
  423 + expect(scheduler.tick(companyId).skipped).toBe('no-slot');
  424 + });
  425 +
  426 + it('候选里有一条起不来时,不该白占一个名额 —— 剩下的坑照样被填满', () => {
  427 + const companyId = setMaxParallel(nextCompany(), 2);
  428 + const goalId = newGoal(companyId);
  429 + hangingExecutor();
  430 + // 队首这条的 run 行被抹掉 = 入队做了一半(5a 那条分支),它必须被跳过而不是吃掉一个坑
  431 + const broken = enqueue(companyId, goalId, { title: 'broken' });
  432 + wdb.getDb().prepare(`UPDATE tasks SET current_run_id = NULL WHERE id = ?`).run(broken.taskId);
  433 + const ok = ['a', 'b'].map((title) => enqueue(companyId, goalId, { title }));
  434 +
  435 + const res = scheduler.tick(companyId);
  436 + // 批 3 的 `.slice(0, max - active)` 会把 broken 算进名额,于是只起来一条
  437 + expect(res.started.map((s) => s.taskId)).toEqual(ok.map((t) => t.taskId));
  438 + expect(wdb.getTask(broken.taskId).status).toBe('blocked');
  439 + });
  440 + });
  441 +
318 it('advanceOne 挡住 queued run:不推进、不报错、节点一个都不点亮', async () => { 442 it('advanceOne 挡住 queued run:不推进、不报错、节点一个都不点亮', async () => {
319 const companyId = nextCompany(); 443 const companyId = nextCompany();
320 const { runId } = enqueue(companyId, newGoal(companyId), { title: 'a' }); 444 const { runId } = enqueue(companyId, newGoal(companyId), { title: 'a' });
server/tests/unit/task-slots-parallel.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — 批 4 · 「一家公司同时只有一个 active root run」解除之后的执行位(27-P3C 批 4 / 27-P4 §一.3)
  3 + *
  4 + * 与既有三份的分工(都不重复它们已经钉住的形状):
  5 + * · `task-scheduler.test.js` —— 单个原语的形状(准入判据、CAS 的 WHERE、per-company 额度的回落)
  6 + * · `task-queue-acceptance.test.js` —— 端到端「队伍自己往前走」
  7 + * · `task-queue-routes.test.js` —— HTTP 端点的字段形状
  8 + * 这一份钉的是批 4 拆掉唯一索引之后**只剩一道守卫**的那几条性质,每一条都在「不成立时会静默」的位置上:
  9 + * ① max=1(缺省)时行为与批 3 逐字一致 —— 这是回归防线:绝大多数公司没写 max_parallel_tasks,
  10 + * 回落算错的话不是报错,而是「并行度悄悄变了」;
  11 + * ② max=3 时三条真的同时在跑、第四条排队、收敛一条自动补位(补位那一跳靠漏斗钩子,漏了就是永远排队);
  12 + * ③ **超发拦得住**。索引没了,`updateRunStatus` 的 BEGIN IMMEDIATE 是唯一的守卫,所以这里不满足于
  13 + * 串行连打,还越过**进程边界**打一次:8 个独立进程越过同一个时间栅栏同时抢 3 个位。
  14 + * 同进程内 better-sqlite3 是同步单写者,怎么打都串行 —— 那测不到这把锁存在的理由;
  15 + * ④ 坑满时人点「启动」拿到的是 409(不是 500)、点得出**全部**占位者,位子一空同一个请求就成功;
  16 + * ⑤ 配置非法(0 / 负数 / 字符串 / 缺失)一律回落 1 **并留声**,绝不回落成 0(永远不出队 = 静默全停)
  17 + * 或无限(超发);
  18 + * ⑥ DROP 迁移:带旧索引的存量库启动一次索引就没了,重复启动、多进程同时启动都不打架。
  19 + *
  20 + * 隔离手法同 task-scheduler.test.js:**先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require**
  21 + * (graph-run-sse 在模块加载时快照 GRAPH_RUNS_DIR,反过来会往仓库 data/companies/ 写而且不报错)。
  22 + * DB 路径与其它测试文件故意不同:vitest 默认按文件并行,共用一个 sqlite 文件会互相 DELETE 对方的行。
  23 + */
  24 +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
  25 +import fs from 'fs';
  26 +import os from 'os';
  27 +import path from 'path';
  28 +import { spawn } from 'child_process';
  29 +import express from 'express';
  30 +
  31 +const TEST_DB_PATH = '/tmp/test-task-slots-parallel.sqlite';
  32 +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  33 +const MODULES = [
  34 + '../../services/graph-db',
  35 + '../../services/org-graph-service',
  36 + '../../services/work-graph-db',
  37 + '../../services/company-paths',
  38 + '../../services/graph-run-sse',
  39 + '../../services/graph-engine',
  40 + '../../services/task-settle',
  41 + '../../services/task-scheduler',
  42 + '../../routes/graph-run',
  43 +];
  44 +
  45 +/** 存量库里那条索引的**原样** SQL(从批 4 的 diff 里逐字抄来,用于伪造「升级前的库」) */
  46 +const LEGACY_INDEX_SQL = `
  47 + CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_runs_one_active_root
  48 + ON graph_runs(company_id)
  49 + WHERE parent_run_id IS NULL
  50 + AND run_status IN ('ready','running','paused')
  51 +`;
  52 +const INDEX_EXISTS_SQL = `
  53 + SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_graph_runs_one_active_root'
  54 +`;
  55 +
  56 +/**
  57 + * 子进程脚本:本文件唯一能真正测到「跨进程」的手段。
  58 + * 只依赖 work-graph-db 的公开 API(路径由 WGDB_MODULE 传进来),不碰任何私有函数。
  59 + */
  60 +const WORKER_SRC = `
  61 +const wdb = require(process.env.WGDB_MODULE);
  62 +const [mode, ...args] = process.argv.slice(2);
  63 +const out = (o) => console.log(JSON.stringify(o));
  64 +try {
  65 + if (mode === 'boot') {
  66 + // 「一个进程启动了一次」= 跑一遍 getDb()(建表 + 迁移块,DROP INDEX 就在里面)
  67 + const db = wdb.getDb();
  68 + out({ index: !!db.prepare(${JSON.stringify(INDEX_EXISTS_SQL)}).get() });
  69 + } else if (mode === 'seed') {
  70 + const [cid, n] = args;
  71 + const db = wdb.getDb();
  72 + const now = new Date().toISOString();
  73 + db.prepare(
  74 + "INSERT OR IGNORE INTO system_agents (id,name,scope,owner_company_id,source_kind,legacy_path,status,created_at,updated_at)"
  75 + + " VALUES ('solo','solo','public',NULL,'global_agent','system/agents/solo','active',?,?)"
  76 + ).run(now, now);
  77 + const g = wdb.createWorkGraph({ companyId: cid, nodes: ['solo'], edges: [] });
  78 + if ((g.errors || []).length) throw new Error(JSON.stringify(g.errors));
  79 + const runs = [];
  80 + for (let i = 0; i < Number(n); i += 1) {
  81 + runs.push(wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: cid, runStatus: 'queued' }).id);
  82 + }
  83 + out({ runs });
  84 + } else if (mode === 'race') {
  85 + const [runId, startAt] = args;
  86 + // 忙等到同一个时间栅栏:sleep 的精度不够,而这里要的正是「同时」
  87 + while (Date.now() < Number(startAt)) { /* spin */ }
  88 + out({ changes: wdb.updateRunStatus(runId, 'queued', 'ready') });
  89 + } else if (mode === 'count') {
  90 + out({ active: wdb.countActiveRootRuns(args[0]), max: wdb.maxActiveRootRuns(args[0]) });
  91 + }
  92 +} catch (err) {
  93 + out({ err: err.name, code: err.code, msg: err.message });
  94 +}
  95 +`;
  96 +
  97 +describe('批 4 · 公司级执行位(max_parallel_tasks)', () => {
  98 + let wdb;
  99 + let engine;
  100 + let sse;
  101 + let scheduler;
  102 + let runsDir;
  103 + let workerPath;
  104 + let server;
  105 + let origin;
  106 + /** 挂起的 executor promise,afterAll 统一放掉,免得留下永不 settle 的 driver */
  107 + const pending = [];
  108 +
  109 + beforeAll(async () => {
  110 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
  111 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  112 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-slots-parallel-'));
  113 + process.env.GRAPH_RUNS_DIR = runsDir;
  114 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  115 +
  116 + wdb = require('../../services/work-graph-db');
  117 + sse = require('../../services/graph-run-sse');
  118 + engine = require('../../services/graph-engine');
  119 + scheduler = require('../../services/task-scheduler');
  120 +
  121 + workerPath = path.join(runsDir, 'slot-worker.cjs');
  122 + fs.writeFileSync(workerPath, WORKER_SRC);
  123 +
  124 + const app = express();
  125 + app.use(express.json());
  126 + app.use('/api/graph-runs', require('../../routes/graph-run'));
  127 + await new Promise((resolve) => { server = app.listen(0, resolve); });
  128 + origin = `http://127.0.0.1:${server.address().port}`;
  129 + });
  130 +
  131 + /**
  132 + * `advanceOne` 的 ensureProjectRoot 会在**仓库的** data/companies/ 下给每家公司建项目根
  133 + * (它不认 GRAPH_RUNS_DIR)。只删本文件造的那些前缀目录,且必须确认里面没有 company.json
  134 + * —— 那是真公司,删了就是删用户数据。(写法与 task-scheduler.test.js 一致)
  135 + */
  136 + function cleanupLeakedCompanyDirs() {
  137 + const root = path.resolve(__dirname, '../../../data/companies');
  138 + if (!fs.existsSync(root)) return;
  139 + for (const cid of fs.readdirSync(root)) {
  140 + if (!cid.startsWith('slots-co-')) continue;
  141 + const dir = path.join(root, cid);
  142 + if (fs.existsSync(path.join(dir, 'company.json'))) continue;
  143 + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ }
  144 + }
  145 + }
  146 +
  147 + afterAll(async () => {
  148 + // 先放掉挂起的 executor 再等一拍:auto driver 恢复后还会跑一轮 advanceOne,
  149 + // 而 ensureProjectRoot 会把刚删掉的公司目录原样建回来 —— 顺序反了就等于没清理
  150 + for (const p of pending) p();
  151 + await new Promise((r) => setTimeout(r, 120));
  152 + scheduler.stopSweeper();
  153 + if (server) server.close();
  154 + cleanupLeakedCompanyDirs();
  155 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
  156 + delete process.env.GRAPH_DB_PATH;
  157 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* ignore */ }
  158 + delete process.env.GRAPH_RUNS_DIR;
  159 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  160 + });
  161 +
  162 + beforeEach(() => {
  163 + const db = wdb.getDb();
  164 + db.pragma('foreign_keys = OFF');
  165 + db.exec(`
  166 + DELETE FROM graph_run_loop_records;
  167 + DELETE FROM graph_run_nodes;
  168 + DELETE FROM graph_runs;
  169 + DELETE FROM tasks;
  170 + DELETE FROM goals;
  171 + DELETE FROM work_graph_edges;
  172 + DELETE FROM work_graph_nodes;
  173 + DELETE FROM work_graphs;
  174 + DELETE FROM system_agents;
  175 + `);
  176 + db.pragma('foreign_keys = ON');
  177 + engine.setAgentExecutor(null);
  178 + });
  179 +
  180 + // ── 夹具 ──────────────────────────────────────────────────────
  181 + let seq = 0;
  182 + /** 每个用例一家独立公司:执行位是按 company 数的 */
  183 + const nextCompany = () => `slots-co-${++seq}`;
  184 +
  185 + function ensureAgents(ids) {
  186 + const now = new Date().toISOString();
  187 + const ins = wdb.getDb().prepare(`
  188 + INSERT OR IGNORE INTO system_agents
  189 + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
  190 + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)
  191 + `);
  192 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  193 + }
  194 +
  195 + function buildGraph(companyId, nodes = ['solo']) {
  196 + ensureAgents(nodes);
  197 + const edges = nodes.slice(1).map((to, i) => ({ from: nodes[i], to }));
  198 + const g = wdb.createWorkGraph({ companyId, nodes, edges });
  199 + expect(g.errors || []).toEqual([]);
  200 + return g.id;
  201 + }
  202 +
  203 + const newGoal = (companyId) => wdb.createGoal({ companyId, title: 'g', description: 'g' }).id;
  204 +
  205 + /** 入队:建 task 行 → 建 queued 的 run 行(不占位)→ task 标 queued + 挂 current_run_id */
  206 + function enqueue(companyId, goalId, { title, mode = 'auto' } = {}) {
  207 + const task = wdb.createTask({
  208 + goalId, companyId, title, description: `${title} 的描述`, executionMode: mode,
  209 + });
  210 + const run = wdb.createRun({
  211 + graphKind: 'work',
  212 + graphId: buildGraph(companyId),
  213 + companyId,
  214 + goalId,
  215 + taskId: task.id,
  216 + executionMode: mode,
  217 + runStatus: 'queued',
  218 + });
  219 + wdb.updateTask(task.id, { status: 'queued', currentRunId: run.id, queuedAt: new Date().toISOString() });
  220 + return { taskId: task.id, runId: run.id };
  221 + }
  222 +
  223 + /** 永不 resolve 的 executor:让起跑的 run 稳稳停在 running,好观察执行位 */
  224 + function hangingExecutor(onCall) {
  225 + engine.setAgentExecutor((ctx) => {
  226 + if (onCall) onCall(ctx);
  227 + return new Promise((resolve) => pending.push(() => resolve({ status: 'success' })));
  228 + });
  229 + }
  230 +
  231 + const flush = () => new Promise((r) => setImmediate(() => setImmediate(r)));
  232 +
  233 + /** 给这家公司写一份 company.json(走 company-paths,认 GRAPH_RUNS_DIR,绝不碰真 data/companies/) */
  234 + function setMaxParallel(companyId, n) {
  235 + const { companyRootDir } = require('../../services/company-paths');
  236 + const dir = companyRootDir(companyId);
  237 + fs.mkdirSync(dir, { recursive: true });
  238 + const cfg = { id: companyId, name: companyId };
  239 + if (n !== undefined) cfg.max_parallel_tasks = n;
  240 + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify(cfg));
  241 + return companyId;
  242 + }
  243 +
  244 + const statuses = (ids) => ids.map((id) => wdb.getTask(id).status);
  245 +
  246 + /** 起一个子进程跑 worker,拿回 { code, json, stdout, stderr } */
  247 + function runWorker(args, { dbPath = TEST_DB_PATH } = {}) {
  248 + return new Promise((resolve) => {
  249 + const child = spawn(process.execPath, [workerPath, ...args], {
  250 + env: {
  251 + ...process.env,
  252 + GRAPH_DB_PATH: dbPath,
  253 + GRAPH_RUNS_DIR: runsDir,
  254 + WGDB_MODULE: require.resolve('../../services/work-graph-db'),
  255 + },
  256 + stdio: ['ignore', 'pipe', 'pipe'],
  257 + });
  258 + let stdout = '';
  259 + let stderr = '';
  260 + child.stdout.on('data', (d) => { stdout += d; });
  261 + child.stderr.on('data', (d) => { stderr += d; });
  262 + child.on('close', (code) => {
  263 + let json = null;
  264 + try { json = JSON.parse(stdout.trim().split('\n').pop()); } catch { /* 留给断言去报 */ }
  265 + resolve({ code, json, stdout, stderr });
  266 + });
  267 + });
  268 + }
  269 +
  270 + // ────────────────────────────────────────────────────────────────
  271 + // ① max=1:默认配置下的行为必须与批 3 逐字一致
  272 + // ────────────────────────────────────────────────────────────────
  273 + describe('① max=1(缺省):批 4 之后一个字都不该变', () => {
  274 + it('没配 company.json 的公司:一次只放一条、其余照排、收敛后自动接力,全程 active ≤ 1', async () => {
  275 + const companyId = nextCompany();
  276 + const goalId = newGoal(companyId);
  277 + hangingExecutor();
  278 + // 「读不到配置 = 1」是保护全仓存量公司与存量单测的那条断言
  279 + expect(wdb.maxActiveRootRuns(companyId)).toBe(1);
  280 +
  281 + const ids = ['a', 'b', 'c'].map((title) => enqueue(companyId, goalId, { title }));
  282 + const first = scheduler.tick(companyId);
  283 + expect(first.max).toBe(1);
  284 + expect(first.started.map((s) => s.taskId)).toEqual([ids[0].taskId]);
  285 + await flush();
  286 + expect(statuses(ids.map((t) => t.taskId))).toEqual(['running', 'queued', 'queued']);
  287 + expect(wdb.countActiveRootRuns(companyId)).toBe(1);
  288 +
  289 + // 再 tick 一次绝不能多起一条(批 3 这里就是 no-slot)
  290 + expect(scheduler.tick(companyId).skipped).toBe('no-slot');
  291 + expect(wdb.countActiveRootRuns(companyId)).toBe(1);
  292 + // 位次口径没变:跑着的那条不在队里,b 是第 1
  293 + expect(scheduler.queuePosition(companyId, ids[1].taskId)).toBe(1);
  294 + expect(scheduler.queuePosition(companyId, ids[2].taskId)).toBe(2);
  295 +
  296 + // 收敛一条 → 漏斗钩子自己把下一条拉起来,**没有人再 tick 一次**
  297 + sse.transitionRun(ids[0].runId, 'running', 'completed', {}, { summary: '用例直推终态' });
  298 + await flush();
  299 + expect(statuses(ids.map((t) => t.taskId))).toEqual(['completed', 'running', 'queued']);
  300 + expect(wdb.countActiveRootRuns(companyId)).toBe(1);
  301 +
  302 + sse.transitionRun(ids[1].runId, 'running', 'completed', {}, { summary: '用例直推终态' });
  303 + await flush();
  304 + expect(statuses(ids.map((t) => t.taskId))).toEqual(['completed', 'completed', 'running']);
  305 + expect(wdb.countActiveRootRuns(companyId)).toBe(1);
  306 + });
  307 + });
  308 +
  309 + // ────────────────────────────────────────────────────────────────
  310 + // ② max=3:三条同时在跑
  311 + // ────────────────────────────────────────────────────────────────
  312 + describe('② max=3:三条无依赖的任务同时进入 running', () => {
  313 + it('前三条同时跑、第四条排队;收敛一条之后第四条自动补位(补位靠漏斗钩子,不靠人再点一次)', async () => {
  314 + const companyId = setMaxParallel(nextCompany(), 3);
  315 + const goalId = newGoal(companyId);
  316 + hangingExecutor();
  317 + const ids = ['a', 'b', 'c', 'd'].map((title) => enqueue(companyId, goalId, { title }));
  318 +
  319 + const res = scheduler.tick(companyId);
  320 + expect(res.max).toBe(3);
  321 + expect(res.started.map((s) => s.taskId)).toEqual(ids.slice(0, 3).map((t) => t.taskId));
  322 + await flush();
  323 +
  324 + // 「同时在跑」要三条都验:任务行是 running,run 行在活跃集里,计数是 3
  325 + expect(statuses(ids.map((t) => t.taskId))).toEqual(['running', 'running', 'running', 'queued']);
  326 + for (const t of ids.slice(0, 3)) {
  327 + expect(wdb.ACTIVE_ROOT_RUN_STATUS).toContain(wdb.getRun(t.runId).run_status);
  328 + }
  329 + expect(wdb.countActiveRootRuns(companyId)).toBe(3);
  330 + expect(wdb.getRun(ids[3].runId).run_status).toBe('queued'); // 第四条一个位都没占
  331 + expect(scheduler.queuePosition(companyId, ids[3].taskId)).toBe(1);
  332 + expect(scheduler.tick(companyId).skipped).toBe('no-slot'); // 满了就是满了
  333 +
  334 + // 收敛一条 → 第四条自己顶上来,且顶上来之后仍然是 3(不是 4)
  335 + sse.transitionRun(ids[0].runId, 'running', 'completed', {}, { summary: '用例直推终态' });
  336 + await flush();
  337 + expect(statuses(ids.map((t) => t.taskId))).toEqual(['completed', 'running', 'running', 'running']);
  338 + expect(wdb.countActiveRootRuns(companyId)).toBe(3);
  339 + });
  340 + });
  341 +
  342 + // ────────────────────────────────────────────────────────────────
  343 + // ③ 超发拦得住 —— 索引已 DROP,这一节是全批最重要的一条
  344 + // ────────────────────────────────────────────────────────────────
  345 + describe('③ 超发:唯一索引没了之后,活跃数永不超过 max', () => {
  346 + it('8 条抢 3 个位:恰好 3 条进得去,其余全是干净的拒绝(一个字都没写进去)', () => {
  347 + const companyId = setMaxParallel(nextCompany(), 3);
  348 + const goalId = newGoal(companyId);
  349 + const runs = Array.from({ length: 8 }, (_, i) => enqueue(companyId, goalId, { title: `t${i}` }).runId);
  350 +
  351 + const results = runs.map((runId) => {
  352 + try {
  353 + return { ok: wdb.updateRunStatus(runId, 'queued', 'ready') };
  354 + } catch (err) {
  355 + return { err: err.name, active: err.active, max: err.max };
  356 + }
  357 + });
  358 + expect(results.filter((r) => r.ok === 1)).toHaveLength(3);
  359 + // 被拒的每一条都必须是 RunSlotBusyError(别的错 = 别的毛病,不许混过去),
  360 + // 而且拒绝当下报的 active 恒等于 max —— 报成别的数就说明计数和判据不同源
  361 + for (const r of results.filter((x) => !x.ok)) {
  362 + expect(r).toEqual({ err: 'RunSlotBusyError', active: 3, max: 3 });
  363 + }
  364 + expect(wdb.countActiveRootRuns(companyId)).toBe(3);
  365 + // 拒绝是干净的:没进去的那 5 条一条都没被改写
  366 + expect(runs.filter((id) => wdb.getRun(id).run_status === 'queued')).toHaveLength(5);
  367 + });
  368 +
  369 + it('出队过程中反手再 tick 一次(漏斗钩子的真实形状):活跃数一刻都没超过 max', async () => {
  370 + const companyId = setMaxParallel(nextCompany(), 3);
  371 + const goalId = newGoal(companyId);
  372 + const observed = [];
  373 + const nested = [];
  374 + // executor 在起跑的栈里被调用 —— 这正是「tick 起 run → run 转状态 → 钩子又 tick」那条回路
  375 + hangingExecutor(() => {
  376 + observed.push(wdb.countActiveRootRuns(companyId));
  377 + nested.push(scheduler.tick(companyId));
  378 + });
  379 + const ids = Array.from({ length: 8 }, (_, i) => enqueue(companyId, goalId, { title: `t${i}` }));
  380 +
  381 + scheduler.tick(companyId);
  382 + await flush();
  383 + await flush();
  384 +
  385 + expect(observed.length).toBeGreaterThan(0);
  386 + expect(Math.max(...observed)).toBeLessThanOrEqual(3); // 中途一刻都没超发
  387 + expect(wdb.countActiveRootRuns(companyId)).toBe(3);
  388 + expect(statuses(ids.map((t) => t.taskId)).filter((s) => s === 'running')).toHaveLength(3);
  389 + // 嵌套那次要么被重入闸门挡回(同一次 tick 的栈里),要么老老实实报坑满 —— 都不许多起一条
  390 + for (const r of nested) {
  391 + expect(['reentrant', 'no-slot', undefined]).toContain(r.skipped);
  392 + if (!r.skipped) expect(r.started).toEqual([]);
  393 + }
  394 + });
  395 +
  396 + it('多进程同时抢:8 个独立进程越过同一个时间栅栏,活跃数仍然只有 3', async () => {
  397 + // 单独一个库文件:跨进程这一节要测的是 BEGIN IMMEDIATE 那把写锁,
  398 + // 与本文件其它用例共用一个库只会让计数互相污染。
  399 + const dbPath = path.join(runsDir, 'mp-race.sqlite');
  400 + const companyId = setMaxParallel(`slots-mp-${++seq}`, 3);
  401 +
  402 + const seeded = await runWorker(['seed', companyId, '8'], { dbPath });
  403 + expect(seeded.code, seeded.stderr).toBe(0);
  404 + const runIds = seeded.json.runs;
  405 + expect(runIds).toHaveLength(8);
  406 +
  407 + // 同一个时间栅栏:子进程各自忙等到这一刻再打(起进程本身要几十毫秒,不留余量就变成串行)
  408 + const startAt = Date.now() + 1500;
  409 + const results = await Promise.all(runIds.map((runId) => runWorker(['race', runId, String(startAt)], { dbPath })));
  410 + for (const r of results) expect(r.code, r.stderr).toBe(0);
  411 +
  412 + const admitted = results.filter((r) => r.json && r.json.changes === 1);
  413 + const refused = results.filter((r) => r.json && r.json.err);
  414 + expect(admitted).toHaveLength(3);
  415 + // 拒绝的一律是 RunSlotBusyError —— 出现 SQLITE_BUSY / UNIQUE 就说明守卫不是靠事务成立的
  416 + expect(refused.map((r) => r.json.err)).toEqual(['RunSlotBusyError', 'RunSlotBusyError',
  417 + 'RunSlotBusyError', 'RunSlotBusyError', 'RunSlotBusyError']);
  418 + const counted = await runWorker(['count', companyId], { dbPath });
  419 + expect(counted.json).toEqual({ active: 3, max: 3 });
  420 + }, 60000);
  421 +
  422 + it('旧版本进程把唯一索引建回来时必须吵:SQLITE_CONSTRAINT_UNIQUE 不算「没抢到」', () => {
  423 + const companyId = setMaxParallel(nextCompany(), 3);
  424 + const goalId = newGoal(companyId);
  425 + const a = enqueue(companyId, goalId, { title: 'a' });
  426 + const b = enqueue(companyId, goalId, { title: 'b' });
  427 + const db = wdb.getDb();
  428 +
  429 + db.exec(LEGACY_INDEX_SQL); // 伪造「旧版本进程启动了一次」
  430 + try {
  431 + expect(wdb.updateRunStatus(a.runId, 'queued', 'ready')).toBe(1);
  432 + // max=3 明明还有位,索引却把第二条按 v1 的规矩拒了 —— 并行被悄悄压回 1
  433 + let caught;
  434 + try { wdb.updateRunStatus(b.runId, 'queued', 'ready'); } catch (e) { caught = e; }
  435 + expect(caught).toBeDefined();
  436 + expect(caught.code).toBe('SQLITE_CONSTRAINT_UNIQUE');
  437 + // 关键:**不能**被当成正常竞争。当成竞争的话调度器 continue 掉,
  438 + // 这条任务永远排队、日志一行没有、界面与正常排队一模一样。
  439 + expect(wdb.isSlotRaceError(caught)).toBe(false);
  440 + } finally {
  441 + db.exec('DROP INDEX IF EXISTS idx_graph_runs_one_active_root');
  442 + }
  443 + // 索引一没,同一跳立刻过 —— 证明刚才那次拒绝确实是索引干的,不是额度不够
  444 + expect(wdb.updateRunStatus(b.runId, 'queued', 'ready')).toBe(1);
  445 + expect(wdb.countActiveRootRuns(companyId)).toBe(2);
  446 + });
  447 + });
  448 +
  449 + // ────────────────────────────────────────────────────────────────
  450 + // ④ 恢复路径:坑满 → 409(不是 500)→ 位子一空同一个请求就成功
  451 + // ────────────────────────────────────────────────────────────────
  452 + describe('④ 位满时人点「启动」:409 且点名全部占位者,位子一空能回来', () => {
  453 + it('max=3 全占满 → 409 + holders 三条都在 + hint 指向每一条真停得掉的;腾一个位就 200', async () => {
  454 + const companyId = setMaxParallel(nextCompany(), 3);
  455 + const goalId = newGoal(companyId);
  456 + // 占位的三条:直推 running(用例不需要真跑 agent)
  457 + const holders = ['h1', 'h2', 'h3'].map((title) => {
  458 + const { taskId, runId } = enqueue(companyId, goalId, { title });
  459 + wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'running' WHERE id = ?`).run(runId);
  460 + return { taskId, runId };
  461 + });
  462 + expect(wdb.countActiveRootRuns(companyId)).toBe(3);
  463 +
  464 + // 被挡的那条:**没有 task** 的已收敛 run(裸 run 退不回队列,错误才会一路抛到路由层)
  465 + const dead = wdb.createRun({ graphKind: 'work', graphId: buildGraph(companyId, ['solo2']), companyId });
  466 + wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(dead.id);
  467 + const nodeId = wdb.getRunState(dead.id).nodes[0].id;
  468 + wdb.getDb().prepare(`UPDATE graph_run_nodes SET status = 'fail' WHERE id = ?`).run(nodeId);
  469 +
  470 + const url = `${origin}/api/graph-runs/${dead.id}/nodes/${nodeId}/retry`;
  471 + const r1 = await fetch(url, { method: 'POST' });
  472 + const body = await r1.json();
  473 + expect(r1.status).toBe(409); // 500 会让调用方去重试 / 报故障,而这里唯一有用的动作是等或停
  474 + expect(body.reason).toBe('run-slot-busy');
  475 + expect({ active: body.active, max: body.max }).toEqual({ active: 3, max: 3 });
  476 + // 三条占位者一条都不能少:只点名一条的话,用户停掉它发现还是起不来
  477 + expect(body.holders.map((h) => h.runId).sort()).toEqual(holders.map((h) => h.runId).sort());
  478 + expect(body.holders.map((h) => h.taskId).sort()).toEqual(holders.map((h) => h.taskId).sort());
  479 + for (const h of holders) expect(body.hint).toContain(`/api/graph-runs/${h.runId}/stop`);
  480 + // 拒绝是干净的:节点没被动过
  481 + expect(wdb.getRunNode(nodeId).status).toBe('fail');
  482 + expect(wdb.getRunNode(nodeId).attempt).toBe(0);
  483 +
  484 + // 腾出一个位 → 同一个请求就成功(这才叫「回得来」)
  485 + sse.transitionRun(holders[0].runId, 'running', 'completed', {}, { summary: '用例直推终态' });
  486 + await flush();
  487 + const r2 = await fetch(url, { method: 'POST' });
  488 + const ok = await r2.json();
  489 + expect(r2.status).toBe(200);
  490 + expect(ok.ok).toBe(true);
  491 + // 它真的占上了刚空出来的那个位(ready 还是已经被驱动推到 running 都算 —— 判据是活跃集,
  492 + // 不是某个具体状态:按具体状态断言的话,哪天驱动早半拍这条用例就会假红)
  493 + expect(wdb.ACTIVE_ROOT_RUN_STATUS).toContain(wdb.getRun(dead.id).run_status);
  494 + expect(wdb.countActiveRootRuns(companyId)).toBe(3); // 3 个位:走了一条、进来一条
  495 + });
  496 + });
  497 +
  498 + // ────────────────────────────────────────────────────────────────
  499 + // ⑤ 配置非法 → 回落 1 并留声
  500 + // ────────────────────────────────────────────────────────────────
  501 + describe('⑤ 配置非法:回落 1,绝不回落成 0(静默全停)或无限(超发)', () => {
  502 + it('0 / -1 / "abc" / true / 小数 / 缺字段 / 没文件 / 坏 JSON 一律 1,且每一次都真的打了日志', () => {
  503 + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
  504 + try {
  505 + for (const bad of [0, -1, 'abc', true, 1.5, '3']) {
  506 + const cid = setMaxParallel(nextCompany(), bad);
  507 + warn.mockClear();
  508 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  509 + // 「配置写错了就当没配」必须是能看见的,否则就是一次静默降级
  510 + expect(warn).toHaveBeenCalledTimes(1);
  511 + expect(String(warn.mock.calls[0][0])).toContain(cid);
  512 + }
  513 +
  514 + // 坏掉的 JSON:不许把一次出队变成 500,但同样必须留声
  515 + const broken = nextCompany();
  516 + const { companyRootDir } = require('../../services/company-paths');
  517 + fs.mkdirSync(companyRootDir(broken), { recursive: true });
  518 + fs.writeFileSync(path.join(companyRootDir(broken), 'company.json'), '{ 这不是 JSON');
  519 + warn.mockClear();
  520 + expect(wdb.maxActiveRootRuns(broken)).toBe(1);
  521 + expect(warn).toHaveBeenCalledTimes(1);
  522 +
  523 + // 没文件 / 没这个字段:这是绝大多数公司的常态,不该刷日志
  524 + warn.mockClear();
  525 + expect(wdb.maxActiveRootRuns(nextCompany())).toBe(1);
  526 + expect(wdb.maxActiveRootRuns(setMaxParallel(nextCompany(), undefined))).toBe(1);
  527 + expect(warn).not.toHaveBeenCalled();
  528 +
  529 + // 超上限截断到 CAP(不是回落 1,也不是当真)——「无限」在这里同样不许出现
  530 + const huge = setMaxParallel(nextCompany(), 1e6);
  531 + warn.mockClear();
  532 + expect(wdb.maxActiveRootRuns(huge)).toBe(wdb.MAX_ACTIVE_ROOT_RUNS_CAP);
  533 + expect(Number.isFinite(wdb.MAX_ACTIVE_ROOT_RUNS_CAP)).toBe(true);
  534 + expect(warn).toHaveBeenCalledTimes(1);
  535 + } finally {
  536 + warn.mockRestore();
  537 + }
  538 + });
  539 +
  540 + it('同一份非法配置只吵一次(日志不刷屏),但**回落值每次都算**,不是缓存下来的', () => {
  541 + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
  542 + try {
  543 + const cid = setMaxParallel(nextCompany(), 0);
  544 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  545 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  546 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  547 + expect(warn).toHaveBeenCalledTimes(1);
  548 +
  549 + // 把配置改对:**不重启、不清缓存**,下一次准入就该按新值走(额度是调用时读盘,不是模块级快照)
  550 + setMaxParallel(cid, 2);
  551 + expect(wdb.maxActiveRootRuns(cid)).toBe(2);
  552 + const goalId = newGoal(cid);
  553 + const runs = ['a', 'b', 'c'].map((t) => enqueue(cid, goalId, { title: t }).runId);
  554 + expect(wdb.updateRunStatus(runs[0], 'queued', 'ready')).toBe(1);
  555 + expect(wdb.updateRunStatus(runs[1], 'queued', 'ready')).toBe(1);
  556 + expect(() => wdb.updateRunStatus(runs[2], 'queued', 'ready')).toThrow(wdb.RunSlotBusyError);
  557 +
  558 + // 再改回非法值:回落 1 立刻生效,坑满的判据跟着变(此刻 2 条在跑 > 1,新的一条必被拒)
  559 + setMaxParallel(cid, -5);
  560 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  561 + let caught;
  562 + try { wdb.updateRunStatus(runs[2], 'queued', 'ready'); } catch (e) { caught = e; }
  563 + expect(caught).toBeInstanceOf(wdb.RunSlotBusyError);
  564 + expect(caught.max).toBe(1);
  565 + } finally {
  566 + warn.mockRestore();
  567 + }
  568 + });
  569 + });
  570 +
  571 + // ────────────────────────────────────────────────────────────────
  572 + // ⑥ DROP 迁移:存量库(带旧索引)启动一次就没了
  573 + // ────────────────────────────────────────────────────────────────
  574 + describe('⑥ 迁移:带 idx_graph_runs_one_active_root 的存量库', () => {
  575 + const Database = require('better-sqlite3');
  576 + /** 直接开一个连接看索引在不在 —— 不经过 work-graph-db,免得「自己证明自己」 */
  577 + function indexExists(dbPath) {
  578 + const db = new Database(dbPath, { readonly: true });
  579 + try { return !!db.prepare(INDEX_EXISTS_SQL).get(); } finally { db.close(); }
  580 + }
  581 + function createLegacyIndex(dbPath) {
  582 + const db = new Database(dbPath);
  583 + try { db.exec(LEGACY_INDEX_SQL); } finally { db.close(); }
  584 + }
  585 +
  586 + it('启动一次索引就没了;再启动两次不报错(DROP INDEX IF EXISTS 本身幂等)', async () => {
  587 + const dbPath = path.join(runsDir, 'migrate-seq.sqlite');
  588 + const first = await runWorker(['boot'], { dbPath });
  589 + expect(first.code, first.stderr).toBe(0);
  590 +
  591 + // 伪造存量库:把批 4 删掉的那条索引原样建回去
  592 + createLegacyIndex(dbPath);
  593 + expect(indexExists(dbPath)).toBe(true);
  594 +
  595 + const second = await runWorker(['boot'], { dbPath });
  596 + expect(second.code, second.stderr).toBe(0);
  597 + expect(second.json).toEqual({ index: false }); // 进程自己看到的
  598 + expect(indexExists(dbPath)).toBe(false); // 库里真的没了
  599 +
  600 + for (let i = 0; i < 2; i += 1) {
  601 + const again = await runWorker(['boot'], { dbPath });
  602 + expect(again.code, again.stderr).toBe(0);
  603 + expect(again.json).toEqual({ index: false });
  604 + }
  605 + }, 30000);
  606 +
  607 + it('多进程同时启动不打架:5 个进程一起跑迁移,全部退出码 0,索引没了', async () => {
  608 + const dbPath = path.join(runsDir, 'migrate-parallel.sqlite');
  609 + expect((await runWorker(['boot'], { dbPath })).code).toBe(0);
  610 + createLegacyIndex(dbPath);
  611 + expect(indexExists(dbPath)).toBe(true);
  612 +
  613 + const boots = await Promise.all(Array.from({ length: 5 }, () => runWorker(['boot'], { dbPath })));
  614 + for (const b of boots) {
  615 + expect(b.code, b.stderr).toBe(0); // SQLITE_BUSY / "no such index" 都会在这里现形
  616 + expect(b.json).toEqual({ index: false });
  617 + }
  618 + expect(indexExists(dbPath)).toBe(false);
  619 + }, 30000);
  620 +
  621 + it('本文件这个库自己也没有那条索引(新建库走 CREATE TABLE 块,那里已经没有它了)', () => {
  622 + expect(wdb.getDb().prepare(INDEX_EXISTS_SQL).get()).toBeUndefined();
  623 + });
  624 + });
  625 +});
web/src/components/graph-console/GoalTaskPane.vue
@@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
13 <NewGoalForm 13 <NewGoalForm
14 :company-id="companyId" 14 :company-id="companyId"
15 :queue-stat="stat" 15 :queue-stat="stat"
  16 + :slots="slots"
16 @changed="$emit('refresh')" 17 @changed="$emit('refresh')"
17 @dispatched="onDispatched" 18 @dispatched="onDispatched"
18 /> 19 />
@@ -150,6 +151,8 @@ const props = defineProps({ @@ -150,6 +151,8 @@ const props = defineProps({
150 loading: { type: Boolean, default: false }, 151 loading: { type: Boolean, default: false },
151 selectedTaskId: { type: String, default: '' }, 152 selectedTaskId: { type: String, default: '' },
152 selectedGoalId: { type: String, default: '' }, 153 selectedGoalId: { type: String, default: '' },
  154 + /** 执行位 `{ max, used, free }`,服务端原样给的;没给就是 null(见 goal-task-model.readSlots) */
  155 + slots: { type: Object, default: null },
153 }); 156 });
154 const emit = defineEmits(['select', 'refresh']); 157 const emit = defineEmits(['select', 'refresh']);
155 158
web/src/components/graph-console/GraphRunPane.vue
@@ -32,6 +32,8 @@ @@ -32,6 +32,8 @@
32 </div> 32 </div>
33 33
34 <p v-if="err" class="err">{{ err }}</p> 34 <p v-if="err" class="err">{{ err }}</p>
  35 + <!-- 200 但结果不是用户以为的那个:坑满 → 退回队列。不说的话界面上什么都看不出来 -->
  36 + <p v-if="notice" class="notice">{{ notice }}</p>
35 37
36 <WorkGraphCanvas 38 <WorkGraphCanvas
37 :nodes="nodes" 39 :nodes="nodes"
@@ -102,6 +104,8 @@ const selectedDefId = ref(null); @@ -102,6 +104,8 @@ const selectedDefId = ref(null);
102 const loading = ref(false); 104 const loading = ref(false);
103 const busy = ref(false); 105 const busy = ref(false);
104 const err = ref(''); 106 const err = ref('');
  107 +/** 非错误、但必须让用户看见的一句话(例:启动被退回队列)。与 err 互斥。 */
  108 +const notice = ref('');
105 const mode = ref('manual'); 109 const mode = ref('manual');
106 const confirmAuto = ref(false); 110 const confirmAuto = ref(false);
107 const sseStatus = ref('idle'); 111 const sseStatus = ref('idle');
@@ -225,7 +229,17 @@ async function act(op, body) { @@ -225,7 +229,17 @@ async function act(op, body) {
225 body: JSON.stringify(body || {}), 229 body: JSON.stringify(body || {}),
226 }); 230 });
227 const j = await r.json().catch(() => ({})); 231 const j = await r.json().catch(() => ({}));
228 - err.value = r.ok ? '' : (j.error || `HTTP ${r.status}`); 232 + // ⚠️ 200 不等于「你要的事发生了」。坑满时「启动」会走 admitOrRequeue 把这条 run
  233 + // **退回队列**,返回的是 200 + { ok:true, requeued:true, runStatus:'queued' }。
  234 + // 只判 r.ok 的话界面上一个字都不会说,而随后按钮全灰、run 面板换成定义视图 ——
  235 + // 用户点了「启动」,得到的是「什么都没说、图不见了」(批 3 复核 N2)。
  236 + if (r.ok && j.requeued) {
  237 + err.value = '';
  238 + notice.value = '执行位已满,这条任务已退回队列排队 —— 坑一空会自动接着跑,不用再点。';
  239 + } else {
  240 + notice.value = '';
  241 + err.value = r.ok ? '' : (j.error || `HTTP ${r.status}`);
  242 + }
229 } catch (e) { 243 } catch (e) {
230 err.value = e.message; 244 err.value = e.message;
231 } finally { 245 } finally {
@@ -339,6 +353,8 @@ function fmtNum(n) { return (n || 0).toLocaleString(); } @@ -339,6 +353,8 @@ function fmtNum(n) { return (n || 0).toLocaleString(); }
339 .btn.retry { border-color:#c96a00; color:#c96a00; } 353 .btn.retry { border-color:#c96a00; color:#c96a00; }
340 .next-hint { font-size:11px; color:#666; } 354 .next-hint { font-size:11px; color:#666; }
341 .next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; } 355 .next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; }
  356 +.notice { color: var(--text-secondary); background: var(--bg-overlay); border: 1px dashed var(--border-primary);
  357 + border-radius: 6px; padding: 6px 8px; font-size: 12px; line-height: 1.5; margin: 6px 0; }
342 .err { color:#c00; font-size:12px; margin:0; background:#fff5f5; border-left:3px solid #c00; padding:5px 8px; } 358 .err { color:#c00; font-size:12px; margin:0; background:#fff5f5; border-left:3px solid #c00; padding:5px 8px; }
343 359
344 .compact .ctrl .btn { padding:3px 7px; font-size:11px; } 360 .compact .ctrl .btn { padding:3px 7px; font-size:11px; }
web/src/components/graph-console/NewGoalForm.vue
@@ -159,6 +159,13 @@ const props = defineProps({ @@ -159,6 +159,13 @@ const props = defineProps({
159 companyId: { type: String, required: true }, 159 companyId: { type: String, required: true },
160 /** { running, queued, blocked } —— 由父组件从整棵目标树数出来(goal-task-model.queueStat) */ 160 /** { running, queued, blocked } —— 由父组件从整棵目标树数出来(goal-task-model.queueStat) */
161 queueStat: { type: Object, default: () => ({ running: 0, queued: 0, blocked: 0 }) }, 161 queueStat: { type: Object, default: () => ({ running: 0, queued: 0, blocked: 0 }) },
  162 + /**
  163 + * 执行位 `{ max, used, free }` —— **服务端原样给的**(形状逐字同
  164 + * `server/routes/graph-run.js#slotState`,409 `run-slot-busy` 也从它取),前端一个数都不推导。
  165 + * 服务端没给(今天就是这样)时传 null,文案自动退回批 3 原样。
  166 + * 判据与退化分支都钉在 goal-task-model.test.js 的 readSlots / queueNoteText 两组用例里。
  167 + */
  168 + slots: { type: Object, default: null },
162 }); 169 });
163 /** 170 /**
164 * changed —— 库里的 goal/task 变了,父组件重拉整棵树(建 goal / 改 task / 删 task 都发)。 171 * changed —— 库里的 goal/task 变了,父组件重拉整棵树(建 goal / 改 task / 删 task 都发)。
@@ -175,7 +182,7 @@ const ADD_ROW_DISABLED = @@ -175,7 +182,7 @@ const ADD_ROW_DISABLED =
175 * 和「正在提交」):批 3 之后下达就是入队,公司里有任务在跑不再是拒绝的理由, 182 * 和「正在提交」):批 3 之后下达就是入队,公司里有任务在跑不再是拒绝的理由,
176 * 把按钮灰掉等于把一条现在走得通的路继续锁着。文案是纯函数,测试在 goal-task-model.test.js。 183 * 把按钮灰掉等于把一条现在走得通的路继续锁着。文案是纯函数,测试在 goal-task-model.test.js。
177 */ 184 */
178 -const queueNote = computed(() => queueNoteText(props.queueStat)); 185 +const queueNote = computed(() => queueNoteText(props.queueStat, props.slots));
179 186
180 /** closed → step1 →(split? splitting : —)→ step2。creating 是 step1 的提交中态 */ 187 /** closed → step1 →(split? splitting : —)→ step2。creating 是 step1 的提交中态 */
181 const phase = ref('closed'); 188 const phase = ref('closed');
web/src/components/graph-console/goal-task-model.js
@@ -228,6 +228,39 @@ export function taskActions(task) { @@ -228,6 +228,39 @@ export function taskActions(task) {
228 return acts; 228 return acts;
229 } 229 }
230 230
  231 +// ────────────────────────────────────────────────────────────────────────────
  232 +// 执行位(批 4:一家公司可以同时跑 max_parallel_tasks 条 root run)
  233 +// ────────────────────────────────────────────────────────────────────────────
  234 +
  235 +/**
  236 + * 服务端给的「执行位」快照 → `{ max, used, free }`,给不出就是 `null`。
  237 + *
  238 + * **形状逐字沿用服务端唯一那一份**:`server/routes/graph-run.js#slotState` 返回
  239 + * `{ max, used, free, holders }`(409 `run-slot-busy` 的 body 与 `routes/company.js`
  240 + * 的 409 都从它取)。这里连 `free` 都**读**服务端的、不自己 `max - used` ——
  241 + * 一做减法就是第二个持有者,而 `slotState` 那边的 free 还带着「max 刚改小」的截断语义。
  242 + *
  243 + * 校验从严、给不出就返回 null:`max` 是 per-company 配置(`company.json.max_parallel_tasks`),
  244 + * 服务端没吐、吐了个字符串,都不能让界面凭空说出一个「3 个执行位」来。
  245 + * null 时所有文案退回**批 3 的原样**(只说在跑几个、不提总数),少说一句好过说错一句。
  246 + *
  247 + * ⚠️ **今天还没有任何端点把它吐给前端**:`slotState` 只喂 409 的 body,
  248 + * `GET /:cid/goals` 与 `GET /:cid/tasks` 都还没带 `slots`。所以现在走的恒是 null 那一支
  249 + * = 与批 3 完全一致。服务端补的时候直接把 `slotState(companyId)` 的返回值挂上去即可
  250 + * (holders 可留可去,这里不读),别在那条路上写第二份取值域。
  251 + */
  252 +export function readSlots(raw) {
  253 + const s = raw || {};
  254 + // **只认真正的 number**,不走 Number() 转换:`Number(null)` / `Number('')` 都是 0,
  255 + // 于是一个字段缺失的响应会被读成「0 个执行位在用」并原样显示出去 —— 那正是本函数要挡的
  256 + // 「凭空说出一个数」。字符串数字同理:服务端吐 '3' 说明那一头的形状已经不对了,
  257 + // 该让文案退回批 3 原样并被人看见,而不是替它纠正。
  258 + const { max, used, free } = s;
  259 + if (![max, used, free].every(Number.isInteger)) return null;
  260 + if (max < 1 || used < 0 || free < 0) return null;
  261 + return { max, used, free };
  262 +}
  263 +
231 /** 264 /**
232 * 全公司在途统计,喂「新目标」表单那句提示。 265 * 全公司在途统计,喂「新目标」表单那句提示。
233 * 只数后端给的 status,不推断位次 —— 一条**还没建出来**的任务排第几,谁都算不准 266 * 只数后端给的 status,不推断位次 —— 一条**还没建出来**的任务排第几,谁都算不准
@@ -259,19 +292,37 @@ export function queueStat(goals = []) { @@ -259,19 +292,37 @@ export function queueStat(goals = []) {
259 * 292 *
260 * @returns {string} 队里空空如也时返回 ''(没有队伍就没什么好说的) 293 * @returns {string} 队里空空如也时返回 ''(没有队伍就没什么好说的)
261 */ 294 */
262 -export function queueNoteText(stat) { 295 +export function queueNoteText(stat, rawSlots) {
263 const s = stat || {}; 296 const s = stat || {};
264 const running = Number(s.running) || 0; 297 const running = Number(s.running) || 0;
265 const queued = Number(s.queued) || 0; 298 const queued = Number(s.queued) || 0;
266 const blocked = Number(s.blocked) || 0; 299 const blocked = Number(s.blocked) || 0;
267 - if (!running && !queued && !blocked) return ''; 300 + const slots = readSlots(rawSlots);
  301 + if (!running && !queued && !blocked && !(slots && slots.used)) return '';
268 302
269 const parts = []; 303 const parts = [];
270 - if (running) parts.push(`${running} 个任务在跑`); 304 + // 有 slots 就报「几分之几个执行位」——「1 个任务在跑」在 3 坑的公司里读起来像「满了」,
  305 + // 而它其实还空着 2 个,这是批 4 之后这句话唯一会骗人的地方。
  306 + //
  307 + // **两个数各归各的来源,不互相换算**:执行位用服务端的 slots.used(数的是活跃 root run,
  308 + // 含 ready/paused),在跑几条任务用 stat.running(数的是 task 行)。两者绝大多数时候相等,
  309 + // 但不是同一件事 —— 拿一个去顶另一个就是又一次「前端自己推导后端的取值域」。
  310 + if (slots) parts.push(`${slots.used}/${slots.max} 个执行位在用`);
  311 + else if (running) parts.push(`${running} 个任务在跑`);
  312 + // 两个数对不上时**两个都报**:藏掉一个就等于替服务端断言它们该相等
  313 + if (slots && running && running !== slots.used) parts.push(`${running} 个任务在跑`);
271 if (queued) parts.push(`${queued} 个在排队`); 314 if (queued) parts.push(`${queued} 个在排队`);
272 if (blocked) parts.push(`${blocked} 个卡在上游`); 315 if (blocked) parts.push(`${blocked} 个卡在上游`);
273 - return `当前${parts.join('、')}。「全部下达」照常可点:本次的任务会进同一个队列,`  
274 - + '排在它们后面,坑一空就自动开跑 —— 确切位次由服务端算,下达后显示在左栏每一行上。'; 316 +
  317 + // 尾巴分两支:知道还有几个空位时就说「有空位」(这才是用户此刻真正在问的「我要等吗」),
  318 + // 不知道就退回批 3 原样。**不预测本次会不会立刻开跑**:那取决于 priority / 依赖 / 执行模式,
  319 + // 三样都只有服务端算得准,写死一句「马上开跑」就是编一个承诺。
  320 + const tail = slots && slots.free > 0
  321 + ? `还空着 ${slots.free} 个执行位。「全部下达」照常可点:本次的任务会进同一个队列,`
  322 + + '有空位就会被调度器直接拉起来 —— 确切位次由服务端算,下达后显示在左栏每一行上。'
  323 + : '「全部下达」照常可点:本次的任务会进同一个队列,'
  324 + + '排在它们后面,坑一空就自动开跑 —— 确切位次由服务端算,下达后显示在左栏每一行上。';
  325 + return `当前${parts.join('、')}。${tail}`;
275 } 326 }
276 327
277 /** 328 /**
@@ -306,13 +357,42 @@ export function dispatchedTaskId(d) { @@ -306,13 +357,42 @@ export function dispatchedTaskId(d) {
306 /** 357 /**
307 * 选中一条还没起过 run 的 task 时,org 栏顶上那句话。 358 * 选中一条还没起过 run 的 task 时,org 栏顶上那句话。
308 * 排队中的那条要说清「前面还有几个」—— 这是「为什么点了下达却什么都没发生」的唯一答案。 359 * 排队中的那条要说清「前面还有几个」—— 这是「为什么点了下达却什么都没发生」的唯一答案。
  360 + *
  361 + * 批 4 之后位次单独一个数**答不了「我还要等吗」**:3 个执行位的公司里排第 3 位是「马上就跑」,
  362 + * 1 个执行位的公司里排第 2 位就是「要等一整轮」。所以 slots 给得出来时把空位一起说了。
  363 + * `slots` 给不出(服务端还没吐这个字段)就退回批 3 原样 —— 见 readSlots 上那段。
  364 + *
  365 + * **manual 的排队任务单独一句**:调度器只出队 `execution_mode==='auto'` 的
  366 + * (`server/services/task-scheduler.js#tickInner`,§五.2bis 明写它故意不碰 manual),
  367 + * 所以对一条 manual 任务说「有空位就自动开跑」是纯粹的谎 —— 它会一直排在那里,
  368 + * 直到有人点 ⋯ 菜单里的「立即开始」。这一条与并行无关,是批 3 就在的,批 4 顺手补上。
309 */ 369 */
310 -export function pendingRunHint(task) { 370 +export function pendingRunHint(task, rawSlots) {
311 const t = task || {}; 371 const t = task || {};
312 const tail = '下面是 org graph 定义本身,节点状态一律 pending。'; 372 const tail = '下面是 org graph 定义本身,节点状态一律 pending。';
313 if (t.status !== 'queued') return `还没有起过 run(${taskStyle(t.status).label}),${tail}`; 373 if (t.status !== 'queued') return `还没有起过 run(${taskStyle(t.status).label}),${tail}`;
  374 +
  375 + const slots = readSlots(rawSlots);
  376 + const free = slots ? slots.free : null;
  377 + // 手动模式:空位有多少都不会让它自己动起来,先把这件事说清楚
  378 + if (t.mode === 'manual') {
  379 + return '排队中。这条是「手动」模式,调度器不会自动拉起它'
  380 + + `(它只出队自动模式的任务${free ? `,${free} 个执行位空着也一样` : ''});`
  381 + + `用任务行的 ⋯ 菜单「立即开始」抢一个执行位,${tail}`;
  382 + }
  383 +
314 const n = Number(t.queuePosition); 384 const n = Number(t.queuePosition);
315 if (!Number.isInteger(n) || n <= 0) return `排队中(服务端没给位次),${tail}`; 385 if (!Number.isInteger(n) || n <= 0) return `排队中(服务端没给位次),${tail}`;
  386 + if (slots && n <= free) {
  387 + return `排在第 ${n} 位,${slots.used}/${slots.max} 个执行位在用、还空着 ${free} 个 ——`
  388 + + ` 下一轮调度就会被拉起来(最迟 60s 的兜底扫描),${tail}`;
  389 + }
  390 + if (slots) {
  391 + // 「还要等几个」= 还差几个执行位腾出来(n - free),不是「前面还有几个任务」(n-1):
  392 + // 空位会先被前 free 条占掉,剩下的每腾出一个坑才轮下去一位。free=0 时这句就是「等 n 个」
  393 + return `排在第 ${n} 位,${slots.used}/${slots.max} 个执行位在用、没有空位 ——`
  394 + + ` 还要等 ${n - free} 个执行位腾出来才轮到(按服务端的队列口径),${tail}`;
  395 + }
316 if (n === 1) return `排在队首,前面的任务一收敛就自动开跑,${tail}`; 396 if (n === 1) return `排在队首,前面的任务一收敛就自动开跑,${tail}`;
317 return `排队中,前面还有 ${n - 1} 个任务,${tail}`; 397 return `排队中,前面还有 ${n - 1} 个任务,${tail}`;
318 } 398 }
@@ -376,6 +456,12 @@ export function dependsLabel(task, siblings = []) { @@ -376,6 +456,12 @@ export function dependsLabel(task, siblings = []) {
376 * 默认选中:最近一条 running 的 task,没有就最近创建的 task。 456 * 默认选中:最近一条 running 的 task,没有就最近创建的 task。
377 * 「最近」按 createdAt 倒序;goals 列表本身已经是 created_at DESC,但不能依赖调用方的顺序 —— 457 * 「最近」按 createdAt 倒序;goals 列表本身已经是 created_at DESC,但不能依赖调用方的顺序 ——
378 * 轮询回来的数组顺序变了就会让选中态莫名其妙跳一次。 458 * 轮询回来的数组顺序变了就会让选中态莫名其妙跳一次。
  459 + *
  460 + * 批 4(一家公司可以同时跑多条)之后 running 会有 N 条,这里挑「最新那条」。
  461 + * **不会因此抖动**:排序只看 createdAt,与谁先跑完无关;而且调用方只在选中态失效时才问一次
  462 + * (`GraphConsole.ensureSelection`),选中之后 20s 轮询不会重挑。
  463 + * 上限:N 条 createdAt 完全相同时顺序由 localeCompare 的稳定性决定 —— 那种并列本身就没有
  464 + * 「更该选哪条」的答案,不值得为它引入第二个排序键。
379 */ 465 */
380 export function pickDefaultSelection(goals = []) { 466 export function pickDefaultSelection(goals = []) {
381 const all = []; 467 const all = [];
web/src/views/GraphConsole.vue
@@ -49,6 +49,7 @@ @@ -49,6 +49,7 @@
49 :loading="goalsLoading" 49 :loading="goalsLoading"
50 :selected-task-id="selectedTaskId" 50 :selected-task-id="selectedTaskId"
51 :selected-goal-id="selectedGoalId" 51 :selected-goal-id="selectedGoalId"
  52 + :slots="slots"
52 @select="selectTask" 53 @select="selectTask"
53 @refresh="reloadTree" 54 @refresh="reloadTree"
54 /> 55 />
@@ -80,7 +81,7 @@ @@ -80,7 +81,7 @@
80 跟左栏的中文标签是两套说法 --> 81 跟左栏的中文标签是两套说法 -->
81 <p class="hint"> 82 <p class="hint">
82 <template v-if="selectedTask"> 83 <template v-if="selectedTask">
83 - 「{{ selectedTask.title }}」{{ pendingRunHint(selectedTask) }} 84 + 「{{ selectedTask.title }}」{{ pendingRunHint(selectedTask, slots) }}
84 </template> 85 </template>
85 <template v-else> 86 <template v-else>
86 从最左栏选一条任务;没有选中时这里画的是 org graph 定义本身,节点状态一律 pending。 87 从最左栏选一条任务;没有选中时这里画的是 org graph 定义本身,节点状态一律 pending。
@@ -200,6 +201,17 @@ const defEdges = ref([]); @@ -200,6 +201,17 @@ const defEdges = ref([]);
200 201
201 const goals = ref([]); 202 const goals = ref([]);
202 const goalsLoading = ref(false); 203 const goalsLoading = ref(false);
  204 +/**
  205 + * 执行位 `{ max, used, free }` —— 批 4 之后一家公司可以同时跑多条 root run,
  206 + * 「在跑几条」不再等于「满了没有」。这个值**只从服务端读**,前端不数 goals 里的 running 去凑:
  207 + * used 数的是活跃 root run(含 ready/paused),max 是 per-company 配置
  208 + * (company.json.max_parallel_tasks),两个都不在这棵目标树里 —— 自己算就是 canDispatch 那次事故的第二版。
  209 + *
  210 + * ⚠️ 服务端今天还没把它挂到任何响应上(`routes/graph-run.js#slotState` 目前只喂 409 的 body),
  211 + * 所以它恒为 null,界面文案自动退回批 3 原样。补的时候直接把 `slotState(companyId)` 的返回值
  212 + * 挂到 `GET /:cid/goals`(或随 taskView 的那一份)上即可,别在那条路上写第二份取值域。
  213 + */
  214 +const slots = ref(null);
203 const selectedGoalId = ref(''); 215 const selectedGoalId = ref('');
204 const selectedTaskId = ref(''); 216 const selectedTaskId = ref('');
205 217
@@ -342,6 +354,9 @@ async function loadGoals() { @@ -342,6 +354,9 @@ async function loadGoals() {
342 if (!r.ok) return; 354 if (!r.ok) return;
343 const j = await r.json(); 355 const j = await r.json();
344 goals.value = j.goals || []; 356 goals.value = j.goals || [];
  357 + // 服务端没给就明确置回 null(不是保留上一份):坑位数过期比没有更坏 ——
  358 + // 「还空着 1 个」在真的满了之后还挂在屏幕上,用户就会去点一个必然 409 的按钮
  359 + slots.value = j.slots || null;
345 ensureSelection(); 360 ensureSelection();
346 } catch { /* 轮询失败保留上一份树,别把已经画出来的东西清空 */ } finally { 361 } catch { /* 轮询失败保留上一份树,别把已经画出来的东西清空 */ } finally {
347 goalsLoading.value = false; 362 goalsLoading.value = false;