Commit 4258706715861693acf93967b57f16aa488ab81b
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>
Showing
17 changed files
with
1676 additions
and
234 deletions
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('/:companyId/org-graph/replan', (req, res) => { | @@ -1207,11 +1207,13 @@ router.post('/:companyId/org-graph/replan', (req, res) => { | ||
| 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 = () => require('../services/work-graph-db').ACTIVE_RO | @@ -1223,9 +1225,9 @@ const activeRootRunStatus = () => require('../services/work-graph-db').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('/:companyId/goals', (req, res) => { | @@ -2089,7 +2154,7 @@ router.get('/:companyId/goals', (req, res) => { | ||
| 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('/:companyId/goals', (req, res) => { | @@ -2111,6 +2176,7 @@ router.get('/:companyId/goals', (req, res) => { | ||
| 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('/:companyId/goals/:goalId', (req, res) => { | @@ -2323,13 +2389,14 @@ router.patch('/:companyId/goals/:goalId', (req, res) => { | ||
| 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('/:companyId/goals/:goalId/dispatch', (req, res) => { | @@ -2363,8 +2430,8 @@ router.post('/:companyId/goals/:goalId/dispatch', (req, res) => { | ||
| 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('/:companyId/goals/:goalId/dispatch', (req, res) => { | @@ -2396,13 +2463,12 @@ router.post('/:companyId/goals/:goalId/dispatch', (req, res) => { | ||
| 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('/:companyId/tasks', (req, res) => { | @@ -2511,7 +2577,10 @@ router.get('/:companyId/tasks', (req, res) => { | ||
| 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('/:companyId/tasks', (req, res) => { | @@ -2572,7 +2641,7 @@ router.post('/:companyId/tasks', (req, res) => { | ||
| 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('/:companyId/tasks', (req, res) => { | @@ -2601,7 +2670,7 @@ router.post('/:companyId/tasks', (req, res) => { | ||
| 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('/:companyId/tasks', (req, res) => { | @@ -2641,9 +2710,9 @@ router.post('/:companyId/tasks', (req, res) => { | ||
| 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('/:companyId/tasks/:taskId/start', (req, res) => { | @@ -2825,20 +2894,24 @@ router.post('/:companyId/tasks/:taskId/start', (req, res) => { | ||
| 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('/:id/stream', (req, res) => { | @@ -428,34 +457,25 @@ router.get('/:id/stream', (req, res) => { | ||
| 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('./org-graph-service'); | @@ -32,27 +32,90 @@ const { validateGraph, normalizeContracts } = require('./org-graph-service'); | ||
| 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('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | @@ -1549,7 +1549,7 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | ||
| 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('「新目标」的队列提示', () => { | @@ -332,6 +332,139 @@ describe('「新目标」的队列提示', () => { | ||
| 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('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => | @@ -111,7 +111,10 @@ describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => | ||
| 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('批 3 排队 · §八 验收', () => { | @@ -276,20 +276,37 @@ describe('批 3 排队 · §八 验收', () => { | ||
| 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('批 3 排队 · §八 验收', () => { | @@ -585,6 +602,43 @@ describe('批 3 排队 · §八 验收', () => { | ||
| 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('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { | @@ -226,17 +226,24 @@ describe('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { | ||
| 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('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { | @@ -250,6 +257,57 @@ describe('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { | ||
| 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('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { | @@ -272,7 +330,8 @@ describe('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { | ||
| 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('task-scheduler', () => { | @@ -105,7 +105,7 @@ describe('task-scheduler', () => { | ||
| 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('task-scheduler', () => { | @@ -172,7 +172,7 @@ describe('task-scheduler', () => { | ||
| 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('task-scheduler', () => { | @@ -190,12 +190,17 @@ describe('task-scheduler', () => { | ||
| 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('task-scheduler', () => { | @@ -262,9 +267,11 @@ describe('task-scheduler', () => { | ||
| 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('task-scheduler', () => { | @@ -278,16 +285,23 @@ describe('task-scheduler', () => { | ||
| 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('task-scheduler', () => { | @@ -315,6 +329,116 @@ describe('task-scheduler', () => { | ||
| 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; |