diff --git a/docs/todo/system-design/27-p3c-goal-task-model.md b/docs/todo/system-design/27-p3c-goal-task-model.md index 91f2e1d..d3eb547 100644 --- a/docs/todo/system-design/27-p3c-goal-task-model.md +++ b/docs/todo/system-design/27-p3c-goal-task-model.md @@ -2,7 +2,7 @@ > **所属**: [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)。 > **日期**: 2026-08-18 -> **状态**: **批 1、批 2 已实施并全绿**(2026-08-18,见 §十三 / §十四 实施记录)。批 3-5 未开工。开放问题已全部关闭。 +> **状态**: **批 1-4 已实施并全绿**(见 §十三 / §十四 / §十五 实施记录)。批 5 未开工,预研方案已就绪。开放问题已全部关闭。 > **⚠️ 批次与 phase 的对应不是一一对齐的**,判断「这份能不能搬进 done」时按批看,别按文档名看: > · **批 1-3 = 真正的 P3 收尾**(goal/task 两级模型 + `task-planning` + 排队),本文档的主体; > · **批 4(并行)归 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3**,那边是设计权威,本文档只服从(见 §五.2 的警示块); @@ -983,3 +983,85 @@ grep 确认 `reportRunOutcome` 无其它调用方(连它自己那行 `module.e 2. **汇报语义是 at-least-once**:exactly-once 要 `/api/agent/task/report` 认幂等键(goal id + 收敛时间),属接收侧改动。 3. **拆分超时只在本进程存活期间成立**:进程中途被杀会留下 `status='planning'` 的 goal,需要启动时扫描重推(与批 3 的中断恢复一起做)。 4. `io-model.check.js` 仍是零调用方(同款前科,批 2 已把 `goal-task-model.check.js` 转成真测试)。 + +--- + +## 十五、批 3 + 批 4 实施记录(2026-08-19) + +> 体例同 §十三 / §十四:**设计正文不改写,只在这里对账;冲突处以本节为准。** + +### 15.1 落地了什么 + +**批 3 · 排队**(commit `87b776c`,**该提交由第三方会话在对抗复核完成前误提,见 §15.4**) +`run_status='queued'` + `RUN_TRANSITIONS` + `syncRunNodesToDef` + `task-scheduler.js`(tick / sweep / +依赖推导)+ 入队/取消/rerun 端点 + 控制台队列显示与三处可见性修复。 + +**批 4 · 并行**(本次) +`idx_graph_runs_one_active_root` **已 DROP**;`max_parallel_tasks` 从 `company.json` **调用时读** +(缺省 1、非法回落 1 并留声、硬上限 20);准入判据「从活跃集外进入活跃集」按额度计数。 + +全量:**128 文件 / 1240 测试全绿**,`npm run web:build` 通过。 + +### 15.2 批 4 的生死线:超发(真进程实跑,不是纸面论证) + +删掉唯一索引 = 删掉最后一道 DB 兜底,此后全靠 `updateRunStatus` 的准入事务。**用 8 个真 node 进程 +并发抢坑实测**(每进程最多重试 40 次),四组: + +| max | 抢到坑的进程 | 库里实际活跃 root run | 判定 | +|---|---|---|---| +| 1 | 1 | 1 | ✅ | +| 2 | 2 | 2 | ✅ | +| 3 | 3 | 3 | ✅ | +| 5 | 5 | 5 | ✅ | + +落选进程全部吃 `RunSlotBusyError`(各 40 次全被拒),零 `other` 错误。准入的四步缺一不可: +`BEGIN IMMEDIATE` → 事务内重读 → 事务内计数 → 同事务条件更新。 + +### 15.3 批 3 复核的 4 条遗留(本次修完) + +**N1 🔴 `cancelQueuedRun` 的前提被批 3 自己改没了** +它的注释说「对一条还没开跑、没有子 run 的排队 run,`stopRun` 是多余的一圈」—— +而 `requeueBlockedRootRun` 落地后,`queued` 的 run **可以是跑过一半的**(有 success 节点、 +有活着的子 run;预算撞顶时引擎只把 root 置 blocked,子 run 要到节点边界才落 paused)。 +探针实测:取消后节点残留 `ready`+`success`、子 run 残留 `paused / stop_requested=0` —— +**那条 running 的子 run 继续调 agent 烧 token**。改为无条件走 `engine.stopRun`。 + +**L1 ⚠️ 恢复路径对 manual 只兑现一半** +`graph-engine.js` 写着「『T1 永远回不来』这个后果因此消失」,但调度器 `filter(execution_mode === 'auto')` +故意不碰 manual → 人点「启动」→ 退回队列 → 坑空了 → **没人接**。 +定案:**「manual 不自动出队」约束的是首次起跑的授权**;一条已被人亲手起过、因坑满被退回队列的 +manual,授权早就给过了,让它自己接回来是**兑现人刚才那次点击**,不是新授权。 +判据用 **`attempt > 0`** 而不是 `started_at` —— 后者看着更贴切,但 `requeueBlockedRootRun` +退回队列时会把它清成 NULL,**恰好在需要它的那一刻是空的**(实测踩过)。 + +**N2 🟠「退回队列」对用户完全不可见** +`startRunSync` 早返回 200 + `{ok:true, requeued:true}`,而 `GraphRunPane.act()` 只在 `!r.ok` 时写 +`err` → 界面零提示;随后按钮全灰、run 面板换成定义视图、提示语说「节点状态一律 pending」(**假的**)。 +`requeued` 字段全前端零处读取。现加 `notice` 一行明说「已退回队列排队,坑一空会自动接着跑」。 + +**N3 🟠 `isSlotRaceError` 的判据被探针证伪** +注释称 `InvalidTransitionError` = 「已被别人从 queued 推走」。实测:被推走时条件更新 +**返回 0、不抛**;真正抛它的只有「**调用方声明了非法转换**」。那一支把调用方的 bug 当竞态吞掉 +且不留日志。已删除该分支(与批 4 删 UNIQUE 那支同一理由:让它吵起来)。 + +> **4 条里有 3 条是同一个模式:注释承诺的前提被本轮自己的改动改没了,注释还留着。** +> §十三.3 / §十四.4 已经就这个模式立过两次案,这是第三、四、五次。 + +### 15.4 一次流程事故:代码在复核完成前被第三方提交 + +`87b776c` 由另一个会话(`lemonboss-68`)在 16:56 提交,**而批 3 的对抗复核 17:00 才出结果** +(就是 §15.3 那 4 条)。提交说明里的「1201 用例全绿」准确描述了当时那次测试运行,但它压在一份 +尚未通过复核的代码上,读起来像质量背书。该会话已写交接文档 `27-p3c-batch3-handover.md` 并更正记录。 + +**教训**:多会话共用同一个 worktree 时,「工作区里的改动」不等于「可以提交的改动」—— +未提交本身可能就是一种状态声明(这里是「在等复核」)。提交前问一句的成本,远低于把一份 +已知有缺陷的代码写进 main 的成本。 + +### 15.5 留给下一轮 + +1. **边界守卫会误判并行会话的改动**:它比对整仓 `git status` 差集,那里面**没有「谁写的」这一维**, + 多会话共用 worktree 时必然把开发会话的改动算到当时在跑的图节点头上(实测已导致一条 run 被判 + `boundary_escape`)。批 3 修的是这条日志的**可见性**(列出文件清单),**误判本身没修**。 +2. **机器级闸门仍然不存在**:`SessionPool` 没有 `maxSessions`,`task-queue.js` 另有个 `MAX_CONCURRENT=3` + 抢同一台机器。批 4 只放开了公司级额度(硬上限 20),机器压不压得住没有任何守卫。 +3. 批 5(两套任务体系统一)未开工,预研方案已就绪。 diff --git a/server/routes/company.js b/server/routes/company.js index f7342e1..9c48de5 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -1207,11 +1207,13 @@ router.post('/:companyId/org-graph/replan', (req, res) => { // 但只在 **goal 粒度**复用一次,见 `services/task-settle.js` 与下方缺陷 3 那段注释。 /** - * v1 约束:一家公司同时只有一个 active root run(27-P3P4 §一非目标)。 + * 「这条 root run 占着执行位」的取值域(27-P3P4 §一非目标;批 4 起同时能占几个由 + * `company.json.max_parallel_tasks` 定,缺省 1 —— 取值域本身没变)。 * - * **取值域只有一个持有者**:`work-graph-db.ACTIVE_ROOT_RUN_STATUS` —— 它同时是唯一索引 - * `idx_graph_runs_one_active_root` 的 WHERE 子句、是准入事务数坑的判据、是调度器判「坑空了没」 - * 的判据。本文件以前抄了一份字面量在这里,而 work-graph-db 的注释白纸黑字写着「这里是全仓库 + * **取值域只有一个持有者**:`work-graph-db.ACTIVE_ROOT_RUN_STATUS` —— 它是准入事务数位子的 + * 判据、是调度器判「还有没有空位」的判据(批 4 之前它还兼任唯一索引 + * `idx_graph_runs_one_active_root` 的 WHERE,那条索引已 DROP,判据只剩准入事务这一处)。 + * 本文件以前抄了一份字面量在这里,而 work-graph-db 的注释白纸黑字写着「这里是全仓库 * 唯一的定义处」—— **注释是对的、代码没兑现**,正是 §十四.3 第 3 条记着的那个形状。 * 现在改成取过来。惰性取值(模块加载期 require 会与路由的加载顺序纠缠)。 */ @@ -1223,9 +1225,9 @@ const activeRootRunStatus = () => require('../services/work-graph-db').ACTIVE_RO * = 上面那个活跃集 **+ `blocked`**,而且必须由它派生、不许再抄一份字面量:§九.1 记着的教训就是 * `ACTIVE_ROOT_RUN_STATUS` 在两个文件里逐字重复,改一处漏一处。 * - * 为什么 `blocked` 不直接并进 `ACTIVE_ROOT_RUN_STATUS`:那个常量是 - * `idx_graph_runs_one_active_root` 这条唯一索引的镜像(索引里就没有 blocked), - * 动它等于悄悄改「一家公司同时只能有一个 active root run」的口径。 + * 为什么 `blocked` 不直接并进 `ACTIVE_ROOT_RUN_STATUS`:那个常量是**准入事务数位子**的判据 + * (批 4 之前还兼任唯一索引 `idx_graph_runs_one_active_root` 的 WHERE,索引里就没有 blocked), + * 动它等于悄悄改「一条 run 占不占执行位」的口径 —— blocked 的 run 不占位,可以再起别的任务。 * 但对归档来说 blocked 恰恰**不能**放行:run 转 blocked 会把 task 也写成 blocked, * 人加完预算 `POST /api/graph-runs/:id/start` 就接着烧钱,而 task 行仍停在 blocked —— * 只看 `task.status === 'running'` 的旧守卫会放行,用户就把一条正在跑的 run 从界面上藏掉了。 @@ -1345,9 +1347,10 @@ function writeTaskGoalArtifact(companyId, rootRunId, payload) { // // 本批(批 1)只落地同步的那一半:建行 / 改行 / 归档 / 下达即起 run。 // **排队不在本批**(run_status='queued'、调度器、depends_on 的出队判据都是批 3), -// **自动拆分也不在**(task-planning 是批 2)。所以这里一次只起得来一条 run,起不来就是 409 —— -// 与既有唯一索引 idx_graph_runs_one_active_root 的语义保持一致,绝不假装「已排上队」: +// **自动拆分也不在**(task-planning 是批 2)。所以批 1 一次只起得来一条 run,起不来就是 409 —— +// 与当时那条唯一索引 idx_graph_runs_one_active_root 的语义一致,绝不假装「已排上队」: // 仓库里还没有出队的 worker,排进去的任务不会被消费,那是一条会静默丢任务的路。 +// (批 3 有了调度器之后下达改成入队、不再 409;批 4 DROP 了那条索引,执行位数改为 per-company。) /** * 数据层的两个专有错误 → HTTP(§四.4 描述冻结 / §六.3 依赖校验)。 @@ -1412,14 +1415,36 @@ function deriveTitle(text) { return t.length > 60 ? `${t.slice(0, 60)}…` : t; } -/** 当前占着坑的那条 root run;没有返回 null */ -function activeRootRun(companyId) { - const { listCompanyRuns } = require('../services/work-graph-db'); - return ( - listCompanyRuns(companyId, { rootOnly: true }).find((r) => - activeRootRunStatus().includes(r.run_status) - ) || null - ); +/** + * 这家公司此刻的执行位:`{ max, used, free, holders }`。 + * + * **形状与判据都不在这里**,取 `routes/graph-run.js` 那唯一一份(它同时供 409 用)。 + * 批 4 之前这里是 `activeRootRun(companyId)`:`listCompanyRuns(...).find(活跃)`, + * 两个调用方各自误用它的一半,max 一放开两处都错: + * · `startNextIfSlotFree` 拿它当「满没满」——它的语义是「有没有」, + * 3 个位空着 2 个时 manual 任务永远起不来,端点还照常回「排队中」(写不进去还不报错); + * · `/tasks/:id/start` 的 409 拿它当「谁挡着我」—— 它取的是 created_at **DESC** 的第一条, + * 而 `RunSlotBusyError.heldBy` 是 **ASC**,同一次拒绝的两个字段会指向不同的占位者。 + */ +function slotsOf(companyId) { + return require('./graph-run').slotState(companyId); +} + +/** + * 列表端点上给界面的那份执行位快照:`{ max, used, free }`。 + * + * 为什么落在 `GET /goals` 与 `GET /tasks` 上而不是 `GET /graph-runs`:控制台就是靠这两个 + * 端点轮询左栏的,多一个请求就多一个「两份数据不同时刻」的窗口;而 graph-runs 那边只是列 run, + * 在它上面再数一遍 root run 就是第三个持有者(批 4 把它的 canDispatch 删掉正是为此)。 + * + * **holders 不给**:列表页只回答「还能不能下达 / 要不要等」,不需要点名谁占着; + * 真被拒绝时 409 会带着 holders 回来(同一个 `slotState` 产出,形状逐字相同)。 + * 前端 `goal-task-model.readSlots` 也只读这三个数,且**不自己做 `max - used`** —— + * free 带着「max 被改小时截断到 0」的语义,减法算不出来。 + */ +function slotsView(companyId) { + const { max, used, free } = slotsOf(companyId); + return { max, used, free }; } /** goal 行 → 对外形状。库里是 snake_case,API 一律 camelCase(同 getRunState 口径) */ @@ -1564,8 +1589,8 @@ function isSlotRace(err) { * 并把 task 推到 `queued`(27-P3C 批 3 §五.1)。 * * 与批 1/批 2 的 `launchTaskRun` 的差别就是「不起跑」: - * · `createRun({runStatus:'queued'})` **不占** `idx_graph_runs_one_active_root` 的坑 - * (索引的 WHERE 只认 ready/running/paused),所以队列可以任意长,入队永不 409; + * · `createRun({runStatus:'queued'})` **不占执行位**(占位的取值域只认 ready/running/paused, + * 见 `activeRootRunStatus`),所以队列可以任意长,入队永不 409; * · 起跑那一跳(queued → ready → running)只有两个执行者:auto 归 `task-scheduler.tick`, * manual 归 `promoteQueuedTask`(人点了「下达 / 入队 / 重跑」才走)。 * @@ -1725,36 +1750,65 @@ function abandonRun(runId, why) { * 候选顺序取自 `scheduler.listQueue` —— 队列位次(priority → seq → created_at)全仓库 * 只有那一个持有者,这里再排一遍就是第二个持有者(canDispatch 那次事故的形状)。 * 队首可能属于**别的 goal**:队列是公司级的,「我下达的是 goal A,起跑的却是 goal B 的活」 - * 正是排队本来的含义,响应里的 started 会如实点名是哪一条。 + * 正是排队本来的含义,响应里的 started 会如实点名是哪几条。 + * + * 批 4:一次可能起跑**不止一条**(公司有 N 个执行位),所以返回数组。批 3 那两处 + * 「只报第一条 / 抢不到就不再试下一条」在 max=1 时等价,max>1 时一个漏说、一个漏起。 * - * @returns {{runId, run, task}|null} 本次真起跑了哪条;null = 没有(坑被占 / 队里没人够格) + * @returns {Array<{runId, run, task}>} 本次真起跑了哪几条(空数组 = 一条都没起) */ function startNextIfSlotFree(companyId) { const scheduler = require('../services/task-scheduler'); const wdb = require('../services/work-graph-db'); const ticked = scheduler.tick(companyId); + const started = []; - // **tick 起走的那条同样要报出来。** 少了这一段,auto 模式下 dispatch 明明起跑了一条任务, + // **tick 起走的那几条同样要报出来。** 少了这一段,auto 模式下 dispatch 明明起跑了任务, // 响应里却没有 `started` —— 前端 `dispatchedTaskId` 于是退回 `queued[0]`,选中态跳到 // 一条还在排队的任务上,用户点了「全部下达」却看不到正在跑的那张图,而接口回的是 201。 // 「做了但不说」和「说了没做」是同一类撒谎,都在本批要消灭的名单上。 - if (ticked.started && ticked.started.length) { - const s = ticked.started[0]; + // 批 4 从 `started[0]` 改成全部:一次 tick 就能填满剩余的 N 个位,只报第一条是同一句谎。 + for (const s of ticked.started || []) { const task = wdb.getTask(s.taskId); - if (task) return { runId: s.runId, run: wdb.getRun(s.runId) || {}, task }; + if (task) started.push({ runId: s.runId, run: wdb.getRun(s.runId) || {}, task }); } - // 坑被占着就到此为止:v1 一次只跑一条(MAX_ACTIVE_ROOT_RUNS) - if (activeRootRun(companyId)) return null; - for (const t of scheduler.listQueue(companyId)) { // auto 归调度器:这里再抢一次会绕开它「起不来就把 task 收回可见终态」的整套善后 if (t.execution_mode !== 'manual') continue; if (blockersOf(t).length) continue; // 上游没做完,依赖是硬约束 - // 抢不到就**不再试下一条**:坑只有一个,抢不到说明它已经没了 - return promoteQueuedTask(t); - } - return null; + // 到这儿才问「还有位吗」,且每一轮都**重算**:刚起跑一条就少一个,而且别人也在抢。 + // 批 4 之前这里是循环外一句 `if (activeRootRun(companyId)) return null;` —— + // 「有没有」当成「满没满」,3 个位空着 2 个时 manual 任务永远起不来,端点却照常回「排队中」。 + // ponytail: 每轮两次读(活跃 root run 一条查询 + company.json 一次 readFile)。 + // 两条便宜的过滤排在它前面,所以只有真候选才付这个钱;长队列真成问题时把 free 缓存进循环、 + // 按起跑数递减即可,别把判据挪到别处去。 + if (slotsOf(companyId).free <= 0) break; + const promoted = promoteQueuedTask(t); + if (promoted) started.push(promoted); + // 没抢到**不等于没位了**:promoteQueuedTask 把「位满」「被别人推走」「认领失败」都归成 null, + // 而后两种下一条照样起得来。真满了由上面那句 free<=0 在下一轮 break —— 判据只留一处。 + } + return started; +} + +/** 起跑了哪条 → 对外形状(三个端点此前各拼了一份,字段还不完全一样) */ +function startedView(s) { + return { taskId: s.task.id, goalId: s.task.goal_id, runId: s.runId, runStatus: (s.run || {}).run_status }; +} + +/** + * 响应里「本次起跑了什么」的两个字段。 + * + * `started` 仍是**单数**——前端 `dispatchedTaskId` 认的就是它,形状与批 3 逐字相同, + * 不是破坏性变更;`startedTasks` 是本次起跑的全部。 + * 两者**同源**:`started = startedTasks[0]`,不是另算一遍,所以分叉不出来。 + */ +function startedFields(list) { + const arr = (Array.isArray(list) ? list : [list]).filter(Boolean); + if (!arr.length) return {}; + const views = arr.map(startedView); + return { started: views[0], startedTasks: views }; } /** @@ -1764,9 +1818,18 @@ function startNextIfSlotFree(companyId) { * `task-settle` 把 task 收成 cancelled(`RUN_TO_TASK_STATUS.stopped = 'cancelled'`)—— * 于是「取消排队」这件事在库里、时间线上、界面上是同一件事,不用在这里再写一遍 task 状态。 * - * 不用 `engine.stopRun`:它会先把树上所有 run 置 `stop_requested=1` 再自底向上收, - * 对一条还没开跑、没有子 run 的排队 run 是多余的一圈;而且它转的是 `fresh.run_status → stopped`, - * 语义上看不出「这条是从队列里撤下来的」。 + * ⚠️ **这里原本写着「不用 engine.stopRun:对一条还没开跑、没有子 run 的排队 run 是多余的一圈」, + * 那个前提被批 3 自己的 `requeueBlockedRootRun` 改没了。** 恢复类转换撞坑满会把 run 退回队列, + * 所以 `run_status='queued'` 的 run **可以是跑过一半的**:有 success 节点、有 ready 节点、 + * 还可能挂着活着的子 run(预算撞顶时引擎只把 root 置 blocked,子 run 要到节点边界才落 paused, + * 也就是父 blocked 时子 run 可以仍在 running)。 + * + * 探针实测过后果:只发一次 `queued → stopped` 的话,root 落 stopped、task 显示「已取消」, + * 而那条 running 的子 run **既没收到 stop_requested、也没被 settle,继续调 agent 烧 token**。 + * + * 所以改成无条件走 `engine.stopRun`:它置整棵树的 `stop_requested`、自底向上收敛、 + * 把 pending/ready 节点 skip 掉。对一条真·空白的排队 run,多出来的那一圈只是几条命中 0 行的 + * 条件更新 —— **拿这点开销换「绝不留下还在烧钱的子 run」是划算的**。 * * @returns {boolean} true = 本次由我撤下的;false = 它已经不在队里了(被抢跑 / 已撤过) */ @@ -1776,10 +1839,10 @@ function cancelQueuedRun(task) { if (!runId) return false; const run = wdb.getRun(runId); if (!run || run.run_status !== 'queued') return false; - return Boolean(require('../services/graph-run-sse').transitionRun(runId, 'queued', 'stopped', {}, { - summary: `取消排队:queued → stopped(task ${task.id})`, - task_id: task.id, - })); + // stopRun 内部逐层走 transitionRun(同一个漏斗),所以 timeline / SSE / task-settle 的 + // 收敛回写照旧;这里只负责把「整棵树」交给它,不再自己发那一条转换。 + require('../services/graph-engine').stopRun(runId); + return Boolean(wdb.getRun(runId) && wdb.getRun(runId).run_status === 'stopped'); } /** @@ -1787,7 +1850,11 @@ function cancelQueuedRun(task) { * * `started` 只在**本次请求真的把某条 task 起跑了**时出现,且它可能不是 body 里那条 * (队首属于别的 goal 时)—— 前端 `dispatchedTaskId` 认的就是它。 + * 批 4 起一次可能起跑多条(N 个执行位),全部在 `startedTasks` 里,`started` = 它的第一条。 * `queuePosition` 一律现算(`task-scheduler.queuePosition`),起跑了的那条不在队里 → null。 + * + * @param {Array|object|null} started `startNextIfSlotFree` 的返回值(数组), + * 或 `promoteQueuedTask` 的单条返回值 —— `startedFields` 两种都收。 */ function queueReply(companyId, task, started) { const { queuePosition } = require('../services/task-scheduler'); @@ -1801,9 +1868,7 @@ function queueReply(companyId, task, started) { runId, status: fresh.status, queuePosition: queuePosition(companyId, fresh.id) || null, - ...(started - ? { started: { taskId: started.task.id, goalId: started.task.goal_id, runId: started.runId, runStatus: started.run.run_status } } - : {}), + ...startedFields(started), ...(runId ? { links: runLinks(runId) } : {}), }; } @@ -2089,7 +2154,7 @@ router.get('/:companyId/goals', (req, res) => { const goals = wdb.listCompanyGoals(companyId, { status: req.query.status || null, includeArchived }); if (req.query.withTasks !== '1' && req.query.withTasks !== 'true') { - return res.json({ goals: goals.map((g) => goalView(g)) }); + return res.json({ goals: goals.map((g) => goalView(g)), slots: slotsView(companyId) }); } const tasks = wdb.listCompanyTasks(companyId, { includeArchived }); @@ -2111,6 +2176,7 @@ router.get('/:companyId/goals', (req, res) => { .map((t) => taskView(t, progress.get(t.current_run_id), positions)) ) ), + slots: slotsView(companyId), }); } catch (err) { console.error('[TaskEntry] Error:', err.message); @@ -2323,13 +2389,14 @@ router.patch('/:companyId/goals/:goalId', (req, res) => { /** * POST /api/company/:companyId/goals/:goalId/dispatch — 下达这个 goal * - * **批 1 的语义 = 下达即执行,不入队**:给 goal 盖上 dispatched_at(描述从此冻结), - * 然后按 seq 起**第一条上游就绪的** draft task 的 run。其余 draft 原地不动,响应里如实列出来。 + * **现在的语义(批 3 起)= 下达即入队**:给 goal 盖上 dispatched_at(描述从此冻结), + * 把它下面**所有** draft task 入队,再推一次队伍(见下方 startNextIfSlotFree); + * 起跑了哪几条如实写在响应的 `started` / `startedTasks` 里。 * - * 为什么不是「全部入队」:v1 一家公司同时只有一个 active root run(唯一索引 - * idx_graph_runs_one_active_root),而出队的 worker 是批 3 才有的东西。把 N 条都标成 - * 「已入队」而没有任何东西会把它们推走,就是把「静默丢任务」写进数据 —— 宁可让用户看到 - * 「还有 2 条待下达」,也不要给一个不兑现的承诺。 + * 批 1 的语义是「只起第一条上游就绪的,其余原地不动」,理由是:当时一家公司同时只有一个 + * active root run(唯一索引 idx_graph_runs_one_active_root),而出队的 worker 是批 3 才有的 + * 东西 —— 把 N 条都标成「已入队」而没有任何东西会把它们推走,就是把「静默丢任务」写进数据。 + * 批 3 有了调度器,这条理由消失;批 4 DROP 了那条索引,执行位数改为 per-company。 * * **依赖是硬约束**(缺陷 F6 + 第二轮复核 缺陷 4):绝不起一条上游没跑完的 task —— * 「接口收下了 depends_on、返回成功、随后无声地违反它」比不支持依赖更糟:用户设了 @@ -2363,8 +2430,8 @@ router.post('/:companyId/goals/:goalId/dispatch', (req, res) => { // ⚠️ **这里以前有一道「公司已有 active root run → 409」的预检查,批 3 删掉了它** —— // 那正是本批要消灭的东西:下达不再因为「有别的任务在跑」被拒绝,而是排队。 - // 并发下真正的守卫仍在(`updateRunStatus` 的准入事务 + 唯一索引),只是它现在守的是 - // **出队**那一跳,不是入队。入队永不因坑满失败:queued 的 run 不占坑。 + // 并发下真正的守卫仍在(`updateRunStatus` 的准入事务;批 4 DROP 掉唯一索引之后它是唯一那道), + // 只是它守的是**出队**那一跳,不是入队。入队永不因位满失败:queued 的 run 不占执行位。 // // 同样删掉的还有「挑第一条跑得动的 draft、全都跑不动就 409」那一段。§六.2 写的是 // 「把该 goal 下**所有** draft task 入队」—— 有了队列,依赖没就绪不再是拒绝的理由, @@ -2396,13 +2463,12 @@ router.post('/:companyId/goals/:goalId/dispatch', (req, res) => { goalStatus: dispatchedGoal.status, // 本次入队的全部 task 及其位次。起跑了的那条不在队里 → position 为 null(不是 0) queued: enqueued.map((e) => ({ taskId: e.task.id, runId: e.runId, queuePosition: positions.get(e.task.id) || null })), - // `started` 只在本次请求真的把某条 task 起跑了时出现,**且它未必是本次入队的那几条**: - // 队列是公司级的,坑空时顶上来的可能是别的 goal 早就排着的活。前端 dispatchedTaskId - // 认的就是它 —— 如实点名比假装「你下达的那条起跑了」有用。 - ...(started - ? { started: { taskId: started.task.id, goalId: started.task.goal_id, runId: started.runId, runStatus: started.run.run_status } } - : {}), - ...(started ? { links: runLinks(started.runId) } : {}), + // `started` / `startedTasks` 只在本次请求真的把 task 起跑了时出现,**且未必是本次入队的那几条**: + // 队列是公司级的,位子空时顶上来的可能是别的 goal 早就排着的活。前端 dispatchedTaskId + // 认的就是 `started` —— 如实点名比假装「你下达的那条起跑了」有用。 + // 批 4:公司有 N 个执行位时一次能起跑多条,全在 `startedTasks` 里(`started` = 它的第一条)。 + ...startedFields(started), + ...(started.length ? { links: runLinks(started[0].runId) } : {}), }); } catch (err) { console.error('[TaskEntry] Error:', err.message); @@ -2511,7 +2577,10 @@ router.get('/:companyId/tasks', (req, res) => { }); const progress = progressOf(tasks); const positions = queuePositions(req.params.companyId); - res.json({ tasks: tasks.map((t) => taskView(t, progress.get(t.current_run_id), positions)) }); + res.json({ + tasks: tasks.map((t) => taskView(t, progress.get(t.current_run_id), positions)), + slots: slotsView(req.params.companyId), + }); } catch (err) { console.error('[TaskEntry] Error:', err.message); res.status(500).json({ error: err.message }); @@ -2572,7 +2641,7 @@ router.post('/:companyId/tasks', (req, res) => { // ⚠️ **这里以前有一道「公司已有 active root run → 409」的预检查,批 3 删掉了它。** // 这条 409 就是本批要消灭的那个东西:外部 agent 的 curl(URL 与请求体写在 tools-base.md 里, // 等于所有 agent 的提示词)撞上它只能自己重试或放弃。现在改成入队 —— 建一条 - // `run_status='queued'` 的 root run,它**不占**唯一索引的坑,所以入队永不失败。 + // `run_status='queued'` 的 root run,它**不占**执行位,所以入队永不失败。 const createdBy = body.created_by || body.from || 'human'; let goal; if (body.goal_id) { @@ -2601,7 +2670,7 @@ router.post('/:companyId/tasks', (req, res) => { description, executionMode: mode, priority: body.priority || goal.priority, - // 落库(缺陷 F3):本次起不来 run 时(唯一索引 409)这行就是草稿,hint 让用户稍后 + // 落库(缺陷 F3):本次起不来 run 时(批 3 之前是 409,现在是排队)这行就是草稿,hint 让用户稍后 // dispatch —— 那时请求体早没了,不存下来 dispatch 只能用默认额度,而用户以为自己填的算数。 budgetJson: body.budget !== undefined ? body.budget : null, // 显式传 report_to 优先,其次继承 goal 的(数据层也会兜一次,这里传的是本次请求的意图) @@ -2641,9 +2710,9 @@ router.post('/:companyId/tasks', (req, res) => { task: description, taskGoalArtifact: path.relative(REPO_ROOT, launched.artifact), budget: body.budget || DEFAULT_TASK_BUDGET, - ...(started && started.task.id !== taskRow.id - ? { started: { taskId: started.task.id, goalId: started.task.goal_id, runId: started.runId } } - : {}), + // 本次这条已经在上面逐字报过了(runId / status / runStatus),这里只报**别人**: + // 顶上来的可能是别的 goal 早就排着的活,一次可能不止一条(批 4 的 N 个执行位)。 + ...startedFields(started.filter((s) => s.task.id !== taskRow.id)), links: runLinks(launched.runId), }); } catch (err) { @@ -2825,20 +2894,24 @@ router.post('/:companyId/tasks/:taskId/start', (req, res) => { // 别回 200 让界面显示「已开始」而实际什么都没跑。 const started = promoteQueuedTask(task); if (!started) { - const active = activeRootRun(companyId); + // 「位子满了」和「这一瞬间被别人推走了」是两件事,答复也不同。 + // 判据与形状都取 graph-run.js 的 slotState(= 409 run-slot-busy 那一份): + // 批 4 之前这里自己 `.find()` 一条 active run + 三个单数字段, + // N 个执行位时只点名一条,用户停掉它之后发现还是起不来(其余的位仍占着)。 + const slots = slotsOf(companyId); + const busy = slots.free <= 0; + const { slotBusyHint } = require('./graph-run'); return res.status(409).json({ - error: active - ? `公司 ${companyId} 的执行坑被 run ${active.id} 占着,这条还得排着` + error: busy + ? `公司 ${companyId} 的执行位已满(${slots.used}/${slots.max}),这条还得排着` : `task ${task.id} 这一瞬间被别人推走了,没抢到`, + // 与 graph-run.js 的 409 逐字同形(reason / active / max / holders), + // 免得同一件事在两个端点上有两套字段名 + ...(busy ? { reason: 'run-slot-busy', active: slots.used, max: slots.max, holders: slots.holders } : {}), queuePosition: require('../services/task-scheduler').queuePosition(companyId, task.id) || null, - ...(active ? { activeRunId: active.id, activeTaskId: active.task_id, activeRunStatus: active.run_status } : {}), - // 「先停掉」这句只在它此刻真停得掉时给:占坑的那条可能还在 ready, - // 而 /stop 的可用条件里没有 ready —— 照着做就是一个 400(判据取自 graph-run.js 那一份) - hint: active - ? (require('./graph-run').STOPPABLE_RUN_STATUS.includes(active.run_status) - ? `等它收敛,或先停掉:POST /api/graph-runs/${active.id}/stop` - : `等它收敛(它现在是 ${active.run_status},还停不掉)`) - : '刷新后重试', + // 「先停掉」这句只在它此刻真停得掉时给:占位的那条可能还在 ready, + // 而 /stop 的可用条件里没有 ready —— 照着做就是一个 400(判据在 slotBusyHint 里,只此一份) + hint: busy ? slotBusyHint(slots) : '刷新后重试', }); } res.status(201).json(queueReply(companyId, started.task, started)); diff --git a/server/routes/graph-run.js b/server/routes/graph-run.js index 3064598..305193e 100644 --- a/server/routes/graph-run.js +++ b/server/routes/graph-run.js @@ -18,7 +18,7 @@ * 400 { error, op, runStatus, allowed } 操作在当前 run_status 下不可用(非法状态转换,明确说清) * 400 { error, scope, from, to } 引擎抛 InvalidTransitionError * 400 { error } 入参不合法 - * 409 { error, reason:'run-slot-busy', holders, hint } 公司的执行坑被别人占着(引擎抛 RunSlotBusyError) + * 409 { error, reason:'run-slot-busy', active, max, holders, hint } 公司的执行位已满(引擎抛 RunSlotBusyError) * 500 { error } 其它 */ @@ -35,6 +35,8 @@ const { listLoopRecords, edgeContracts, findFiles, + listActiveRootRunIds, + maxActiveRootRuns, InvalidTransitionError, RunSlotBusyError, } = require('../services/work-graph-db'); @@ -82,27 +84,19 @@ function sendError(res, err) { // (「导出了、零人使用」是这个仓库反复立案的惯犯)。全仓 work-graph-db 只有相对路径 // require,同一个模块实例,instanceof 成立。 if (err instanceof RunSlotBusyError) { - const holders = slotHolders(err); - const first = holders[0] || null; + const slots = slotState(err.companyId, err); return res.status(409).json({ error: err.message, reason: 'run-slot-busy', companyId: err.companyId, runId: err.runId, // 想进活跃集却被挡住的那条(= 本次操作的对象) - active: err.active, - max: err.max, + active: slots.used, + max: slots.max, // 「挡我的是谁」:没有它,用户只知道「坑满」,无从判断该等谁、该停谁。 - // 数组而不是单数:max 今天恒为 1,批 4 放开并行之后这里会有多条, - // 那时改形状就是又一次前端跟着改的破坏性变更。 - holders, - // 「先停掉它」这句只在**它此刻真的停得掉**时才给:ready 的 run 不在 STOPPABLE_RUN_STATUS - // 里,对它写 /stop 就是又一条指向不存在行为的提示(打过去 400)。 - hint: first - ? `坑被 run ${first.runId}${first.taskId ? `(task ${first.taskId})` : ''} 占着,等它收敛后重试;` - + (STOPPABLE_RUN_STATUS.includes(first.runStatus) - ? `不想等就先停掉它:POST /api/graph-runs/${first.runId}/stop` - : `它现在是 ${first.runStatus},还停不掉(/stop 只认 ${STOPPABLE_RUN_STATUS.join(' / ')}),马上就会开跑`) - : '坑刚刚才空出来(占坑的那条已经收敛),直接重试本次操作即可', + // 数组而不是单数:批 4 放开并行之后这里真的会有多条(max = 该公司的 + // company.json.max_parallel_tasks),形状从批 3 起就按多条写好了,这次没动。 + holders: slots.holders, + hint: slotBusyHint(slots), }); } @@ -110,22 +104,57 @@ function sendError(res, err) { } /** - * 「谁占着坑」→ 可读的行。 + * 「这家公司此刻的执行位」→ `{ max, used, free, holders }`。**这个形状全仓库只有这一份**, + * `routes/company.js` 的 409 也从这里取(它以前自己 `.find()` 一条 active run 再拼三个 + * 单数字段,那就是第二个持有者:批 4 之后两处会各自指向不同的占位者,因为 + * `activeRootRun` 取 created_at **DESC** 的第一条、准入事务给的 heldBy 是 **ASC**)。 * - * 首选 `err.heldBy`:那是准入事务在 BEGIN IMMEDIATE 里**当场**数出来的 run id, - * 比事后再查一次准(等 HTTP 响应写出去时,占坑的那条可能已经换人了)。 - * 只有 heldBy 为空 / 那几行已经不在时才回落到查询,取值域用本文件的 ACTIVE_ROOT_RUN_STATUS - * (与唯一索引逐字一致的那一份),不在这里再写一遍 —— - * canDispatch 那次事故就是取值域被第二个持有者复述了一遍。 + * 三样东西各有唯一来源,这里一样都不重算: + * · 取值域(哪些 run_status 算占位)= `work-graph-db.listActiveRootRunIds` 那条 WHERE。 + * 本文件以前抄过一份 `new Set([...])` 做回落查询 —— 那正是 canDispatch 事故的形状,已删; + * · 额度 = `work-graph-db.maxActiveRootRuns(companyId)`(读 company.json 的 + * max_parallel_tasks,缺省 1)。**不在这里写第二个默认值**; + * · 拒绝当下的实况优先用 `err.heldBy / err.active / err.max` —— 那是准入事务在 + * BEGIN IMMEDIATE 里**当场**数出来的,比事后再查一次准(等 HTTP 响应写出去时, + * 占位的那条可能已经换人了)。只有 heldBy 空 / 那几行已经不在时才回落到查询, + * 于是「执行位刚刚才空出来」这条 hint 仍然给得出来。 + * + * @param {string} companyId + * @param {RunSlotBusyError} [err] 有它就用它当场测的那一份 */ -function slotHolders(err) { - const view = (r) => ({ runId: r.id, taskId: r.task_id, goalId: r.goal_id, runStatus: r.run_status }); - const named = (Array.isArray(err.heldBy) ? err.heldBy : []).map((id) => getRun(id)).filter(Boolean); - if (named.length) return named.map(view); - if (!err.companyId) return []; - return listCompanyRuns(err.companyId, { rootOnly: true }) - .filter((r) => ACTIVE_ROOT_RUN_STATUS.has(r.run_status)) - .map(view); +function slotState(companyId, err = null) { + const view = (id) => { + const r = getRun(id); + return r ? { runId: r.id, taskId: r.task_id, goalId: r.goal_id, runStatus: r.run_status } : null; + }; + const named = (err && Array.isArray(err.heldBy) ? err.heldBy : []).map(view).filter(Boolean); + const holders = named.length || !companyId + ? named + : listActiveRootRunIds(companyId).map(view).filter(Boolean); + const max = err && Number.isInteger(err.max) ? err.max : maxActiveRootRuns(companyId); + const used = err && Number.isInteger(err.active) ? err.active : holders.length; + return { max, used, free: Math.max(0, max - used), holders }; +} + +/** + * 执行位满时那句「现在该干什么」。 + * + * 两条规矩没变,只是从「唯一那条占位者」推广到 N 条: + * · 「先停掉它」只在**它此刻真的停得掉**时才给 —— ready 的 run 不在 STOPPABLE_RUN_STATUS 里, + * 对它写 /stop 就是又一条指向不存在行为的提示(打过去 400); + * · 一条都停不掉时明说原因,别让人照着一条必然失败的路走一遍。 + * N 条时列出**所有**停得掉的那几条:只点名第一条,用户停掉它发现还是起不来(其余仍占着位)。 + */ +function slotBusyHint({ used, max, holders }) { + if (!holders.length) return '执行位刚刚才空出来(占位的那条已经收敛),直接重试本次操作即可'; + const who = holders + .map((h) => `run ${h.runId}${h.taskId ? `(task ${h.taskId})` : ''}`) + .join('、'); + const stoppable = holders.filter((h) => STOPPABLE_RUN_STATUS.includes(h.runStatus)); + const head = `执行位已满(${used}/${max}),占位的是 ${who},等其中任一条收敛后重试;`; + return head + (stoppable.length + ? `不想等就先停掉其中一条:${stoppable.map((h) => `POST /api/graph-runs/${h.runId}/stop`).join(' 或 ')}` + : `它们现在都还停不掉(/stop 只认 ${STOPPABLE_RUN_STATUS.join(' / ')}),马上就会开跑`); } /** 取 run,不存在直接回 404 并返回 null(调用方 return 掉即可) */ @@ -428,34 +457,25 @@ router.get('/:id/stream', (req, res) => { /** * GET /api/company/:companyId/graph-runs — 某公司的 run 列表 - * query: rootOnly=1 只列 root run(v1 一家公司同时只有一个 active root run,见 §一非目标) + * query: rootOnly=1 只列 root run * 公司无 run 时返回空数组,不报错(与 GET /api/company/:cid/org-graph 的处理一致)。 - */ -/** - * 「同一 company 只允许一个 active root run」的取值域。 * - * **不在这里写字面量**:唯一持有者是 `work-graph-db.ACTIVE_ROOT_RUN_STATUS` —— 它同时是 - * 唯一索引 `idx_graph_runs_one_active_root` 的 WHERE、是准入事务数坑的判据、是调度器判 - * 「坑空了没」的判据。本文件以前抄了一份 `new Set([...])`,那就是第二个持有者: - * canDispatch 那次事故(前端复述取值域还多写一个 blocked,公司失败一次后再也下不了任务) - * 就是这个形状。**注意 blocked 不在里面** —— blocked 的 run 不占坑,可以再下一个任务。 + * ⚠️ **批 4 删掉了 `canDispatch` / `activeRootRunId` 两个字段**,没有换成 `slots`。 + * 理由(§九.4quater 的规矩:零消费方的字段要么接上要么删掉): + * · 两个字段自批 2 起就**零消费方** —— 全仓只剩注释在引用它们当反面教材, + * `GraphConsole.vue` 的 loadRuns 只取 `j.runs`,用例一条都没断言过它们; + * · 它们的判据在批 4 之后必然**算错**:`canDispatch: !activeRoot` 的含义是「一个都不许有」, + * 而 max=3 的公司占了 1 个位照样能下达。留着 = 一个没人读、还回错值的字段; + * · 换成 `slots:{max,used,free}` 就是把同一个事实**第三次**摊到 HTTP 上 + * (409 的 `{active,max,holders}` 已经是权威形状),而且同样零消费方。 + * 真要给界面画并发,落点是随 taskView 一起给的那一份(与 409 同源,见 slotState), + * 不是在这个「列 run」的端点上把 root run 再数一遍。 */ -const ACTIVE_ROOT_RUN_STATUS = new Set( - require('../services/work-graph-db').ACTIVE_ROOT_RUN_STATUS -); - companyRouter.get('/:companyId/graph-runs', h((req, res) => { const rootOnly = req.query.rootOnly === '1' || req.query.rootOnly === 'true'; - const runs = listCompanyRuns(req.params.companyId, { rootOnly }); - // canDispatch 由**服务端**算:前端此前自己复述了一份取值域并且多写了一个 blocked, - // 于是一家公司只要失败过一次,控制台上就再也下不了第二个任务(后端其实放行)。 - // 规则只留一处,前端照抄结果即可(P3B 第三次真跑发现)。 - const activeRoot = runs.find((r) => !r.parent_run_id && ACTIVE_ROOT_RUN_STATUS.has(r.run_status)); res.json({ companyId: req.params.companyId, - runs, - canDispatch: !activeRoot, - activeRootRunId: activeRoot ? activeRoot.id : null, + runs: listCompanyRuns(req.params.companyId, { rootOnly }), }); })); @@ -463,3 +483,8 @@ module.exports = router; module.exports.companyRouter = companyRouter; // company.js 的 409 hint 要照它判「这条 run 现在停不停得掉」(理由见常量注释) module.exports.STOPPABLE_RUN_STATUS = STOPPABLE_RUN_STATUS; +// 「执行位」的形状与那句下一步:company.js 的 POST /tasks/:id/start 与 startNextIfSlotFree +// 都用这两个,别在那边再写一份(批 4 之前那边是 `.find(active)` + 三个单数字段, +// 与这里的 holders[] 各说各话)。 +module.exports.slotState = slotState; +module.exports.slotBusyHint = slotBusyHint; diff --git a/server/services/task-scheduler.js b/server/services/task-scheduler.js index 253d685..8714187 100644 --- a/server/services/task-scheduler.js +++ b/server/services/task-scheduler.js @@ -5,12 +5,13 @@ * * 它把 v1 那条硬约束从「拒绝」改成「排队」: * 下达任务时不再因为「公司已有 active root run」吃 409,而是建一条 `run_status='queued'` - * 的 run(不占唯一索引的坑)+ 一条 `status='queued'` 的 task;坑一空,这里把它拉起来。 + * 的 run(不占执行坑)+ 一条 `status='queued'` 的 task;坑一空,这里把它拉起来。 * * 三件事必须一起成立,少一件排队就是「静默丢任务」(正是当初否决排队的那条理由): * 1. **出队是原子的** —— `updateRunStatus(runId,'queued','ready')` 一条 CAS,且「数坑 + 占坑」 * 整段包在 `BEGIN IMMEDIATE` 里(守卫在 `updateRunStatus`,不在这里 —— 能把 root run - * 推进活跃集的路径有 6 条,枚举一次漏一次);`idx_graph_runs_one_active_root` 留作兜底; + * 推进活跃集的路径有 6 条,枚举一次漏一次)。批 4 DROP 掉了 `idx_graph_runs_one_active_root` + * 那道 DB 兜底,那个事务从此是**唯一**的守卫; * 2. **触发不靠枚举** —— 挂在 `graph-run-sse.transitionRun` 这个唯一漏斗上 * (`stopRun` 与「预算耗尽 → blocked」都不走 `settleRun`,枚举一次漏一次); * 3. **躺在队里的任务不许假装在排队** —— 依赖已不可能满足的,就地推导成 blocked 并写明原因。 @@ -19,11 +20,13 @@ */ /** - * 并行度上限的唯一持有者在 `work-graph-db.MAX_ACTIVE_ROOT_RUNS`(挨着唯一索引与准入事务)。 - * 这里**不重新写一个 1** —— 两个持有者迟早分叉,那正是 `canDispatch` 那次事故的形状。 + * 并行度上限的唯一持有者在 `work-graph-db.maxActiveRootRuns(companyId)`(挨着准入事务)。 + * 这里**不重新写一个默认值、也不缓存** —— 两个持有者迟早分叉,那正是 `canDispatch` + * 那次事故的形状。批 4 起它是 per-company 的(读 company.json.max_parallel_tasks), + * 所以签名必须带 companyId:一个无参的 getter 表达不了 per-company,只会变成又一个假上限。 * 惰性取值(模块加载期 require work-graph-db 会成环)。 */ -const maxParallelTasks = () => wdb().MAX_ACTIVE_ROOT_RUNS; +const maxParallelTasks = (companyId) => wdb().maxActiveRootRuns(companyId); /** * 依赖「已经死了」的上游状态。 @@ -127,7 +130,7 @@ function queuePosition(companyId, taskId) { /** * 当前占着坑的 root run 数。 * - * 计数 SQL 本身在 `work-graph-db.countActiveRootRuns`(与唯一索引、与准入事务同源), + * 计数 SQL 本身在 `work-graph-db.countActiveRootRuns`(与准入事务的判据同源), * 这里只是取来用 —— 调度器这一份**只是给界面看的快照**,不是准入判据: * 真正决胜的是 `updateRunStatus` 里那个 BEGIN IMMEDIATE 的事务(见下方第 3 步注释)。 */ @@ -208,7 +211,7 @@ function abandonRun(runId, why) { * 调度一次(§五.1 六步)。**绝不 throw**:它挂在状态转换的尾巴上,抛出去会把一次正常的 * run 收敛也带崩(调用方拿到异常,但状态和 SSE 事件已经发出去了)。 * - * @returns {{companyId:string, skipped?:string, started:object[], blocked:object[], requeued:string[], active?:number}} + * @returns {{companyId:string, skipped?:string, started:object[], blocked:object[], requeued:string[], active?:number, max?:number}} */ function tick(companyId) { if (!companyId) return { companyId, skipped: 'no-company', started: [], blocked: [], requeued: [] }; @@ -240,23 +243,41 @@ function tickInner(companyId) { // 队列可以变(另一个进程/另一次 tick),check-then-act 在这里天然不成立。 // 权威判据在 `updateRunStatus` 的准入事务里(BEGIN IMMEDIATE 内数坑 + 占坑一步做完), // 坑被抢走时它抛 RunSlotBusyError,由 5c 的 catch + isRaceError 当成「这轮没抢到」。 - // ponytail: max 恒为 1(批 3 只做排队);读 company.json.max_parallel_tasks 与 - // DROP 唯一索引一起归批 4 / 27-P4。 - const max = maxParallelTasks(); + // 批 4 起 max 是 per-company 的(company.json.max_parallel_tasks,缺省 1)。 + const max = maxParallelTasks(companyId); const active = activeRootRunCount(companyId); const started = []; - if (active >= max) return { companyId, skipped: 'no-slot', active, started, blocked, requeued }; + if (active >= max) return { companyId, skipped: 'no-slot', active, max, started, blocked, requeued }; // 4. 选候选:queued + 依赖全 completed + auto。 // manual **不参与自动出队**(§五.2bis):startRun(manual) 只点亮入口节点就停下等 /next, // 而那时 run 已经是 running、占着坑 —— 没人点「下一步」它就把后面所有任务无限期堵死, // 而队列界面上看不出任何异常。manual 只能由人在控制台上显式点「开始」。 + // + // **不 slice**:批 3 这里是 `.slice(0, max - active)`,用的是「候选数 = 能起的条数」这个 + // 在 max=1 时看不出错的假设 —— 只要队首那条起跑失败(5a 缺 run 行 / 5d 认领失败), + // 这一轮就白跑,空着的坑要等下一次钩子或 60s sweep 才被填上。max>1 时这类空转会成倍出现。 + // 改成按**真的起来了几条**收敛:循环里自己数,坑满就 break。 + // ⚠️ **但 `started_at` 非空的 manual 要放进来**(批 3 复核 L1): + // 「manual 不自动出队」约束的是**首次起跑的授权** —— 人要盯着一步步推,不该半夜被自动拉起。 + // 而一条**已经被人亲手启动过**、跑到一半因为坑满被 `admitOrRequeue` 退回队列的 manual 任务, + // 授权早就给过了;让它自己接回来不是新授权,是**把人刚才那次点击兑现掉**。 + // 不放进来的后果实测过:人点「启动」→ 退回队列 → 坑空了 → **没人接**,任务躺着、 + // sweep 每 60s 空转,而 `graph-engine.js` 那句「『T1 永远回不来』这个后果因此消失」 + // 对 manual 根本不成立 —— 又一条只兑现一半的注释。 + // 判据用 **`attempt > 0`** 而不是 `started_at`:后者看着更贴切,但 + // `requeueBlockedRootRun` 退回队列时会把 `started_at` 清成 NULL(它记的是「本次尝试何时开跑」, + // 退回队列后本次尝试就没了),于是那个字段恰好在我需要它的那一刻是空的 —— 实测踩过。 + // `attempt` 由 `claimQueuedTask` 每次认领 +1、退回队列时不清,它才是「这条被人起过没有」的事实。 const candidates = listQueue(companyId) - .filter((t) => t.execution_mode === 'auto' && dependenciesSatisfied(t)) - .slice(0, max - active); + .filter((t) => (t.execution_mode === 'auto' || t.attempt > 0) && dependenciesSatisfied(t)); - // 5. 逐条出队,整条包在 try/catch 里 + // 5. 逐条出队,每条独立 try/catch —— 一条失败绝不能中止整轮(那等于把后面的坑白白空着) for (const t of candidates) { + // 本轮已经填满就收工。`active` 是进循环前的快照、`started.length` 是本轮的战果, + // 两者都只会低估空位(跑着的 run 可能中途收敛让出坑),保守一点即可: + // 少起的那条由漏斗钩子上的下一次 scheduleTick 接手,而不是在这里赌一个更新的计数。 + if (active + started.length >= max) break; const runId = t.current_run_id; try { // 5a. 入队时必须已经建好 queued 的 run 行;没有就是入队做了一半, @@ -278,11 +299,10 @@ function tickInner(companyId) { // 由 updateRunStatus 的 BEGIN IMMEDIATE 事务保护,超发的那条拿不到坑。 // 走 transitionRun 而不是裸的 updateRunStatus,是为了让这一跳同样落 timeline、 // 推 SSE(控制台要看见任务起跑);`to='ready'` 在活跃集里,所以它不会反手再触发一次调度。 - // 三种「没抢到」的形态: - // · changes=0 → transitionRun 返回 null(这条 run 已被别人推走); - // · RunSlotBusyError → 主守卫在事务里判出坑满(**不是** changes=0); - // · UNIQUE constraint → 兜底索引(批 4 DROP 前一直在), - // 后两种都是抛出来的,落到下面的 catch → isRaceError → continue。 + // 两种「没抢到」的形态(批 4 DROP 唯一索引后,兜底那一层没有了): + // · changes=0 → transitionRun 返回 null(这条 run 已被别人推走)→ 试下一条; + // · RunSlotBusyError → 主守卫在事务里判出坑满(**不是** changes=0)→ 整轮到此为止。 + // 抛出来的那种落到下面的 catch。 if (!sse().transitionRun(runId, 'queued', 'ready', {}, { summary: `出队:queued → ready(task ${t.id})`, task_id: t.id, @@ -301,7 +321,11 @@ function tickInner(companyId) { engine().startRun(runId, { mode: t.execution_mode, task: t.description }); started.push({ taskId: t.id, runId, goalId: t.goal_id }); } catch (err) { - if (isRaceError(err)) continue; // 没抢到,正常 + // 坑满:这一刻公司真的没位置了,后面的候选一个都进不去 —— 继续试只是白白多抛几次。 + // 与下面那种「这一条没抢到」区分开:那种要接着试下一条,否则 max>1 时一条被别人 + // 推走就会让剩下的坑空着(正是本轮 slice 去掉的那类空转)。 + if (err instanceof wdb().RunSlotBusyError) break; + if (isRaceError(err)) continue; // 这一条没抢到(已被别人从 queued 推走),试下一条 // 到这儿说明 run 可能已经出队但没能起跑:task 已标 running 而没有活的 run, // 这正是要消灭的那类「写了但没生效还不报错」。收回 run,让 task 落到可见的终态。 if (runId && (getRun(runId) || {}).run_status === 'ready') { @@ -312,7 +336,7 @@ function tickInner(companyId) { } } - return { companyId, active, started, blocked, requeued }; + return { companyId, active, max, started, blocked, requeued }; } /** @@ -384,6 +408,7 @@ module.exports = { sweep, startSweeper, stopSweeper, - // 转发 work-graph-db 的那一份,别在这里存第二个副本 - get MAX_PARALLEL_TASKS() { return maxParallelTasks(); }, + // 批 4 删掉了 `get MAX_PARALLEL_TASKS()`:它无参,表达不了 per-company 的额度, + // 而且全仓零调用方 —— 一个「导出了、没人用、还恰好是错的」的形状。 + // 要额度就调 `work-graph-db.maxActiveRootRuns(companyId)`,那是唯一持有者。 }; diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index 1698c61..64faa64 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -32,27 +32,90 @@ const { validateGraph, normalizeContracts } = require('./org-graph-service'); /** * 「这条 root run 正占着公司的执行坑」的取值域。 * - * **它同时是 `idx_graph_runs_one_active_root` 那个唯一索引的 WHERE 子句**(下面 getDb 里直接 - * 拼进去),所以这里是全仓库唯一的定义处,调度器 / 路由复用它而不是再抄一份字面量。 + * 全仓库唯一的定义处,调度器 / 路由 / 准入事务复用它而不是再抄一份字面量。 * 为什么强调这件事:`canDispatch` 那次事故就是前端把这三个值复述了一遍、还多写了一个 * `blocked`,于是公司失败一次之后再也下不了任务 —— 取值域一旦有第二个持有者就会分叉。 * * ⚠️ **`queued` 故意不在里面**:排队中的 run 不占坑,一家公司可以同时躺着任意多条。 - * 这正是「出队原子性」的**全部依赖** —— 出队那一跳 `queued → ready` 会撞上这个唯一索引, - * 超发的那一条被 DB 直接拒(SqliteError),而不是靠应用层「先查后插」的乐观检查。 - * 改这个数组之前先读 27-P3C §四.3 与 §五.2;批 4 放开并行时要连索引一起动。 + * 这是「入队不占坑、出队才占坑」的全部依据 —— 出队那一跳 `queued → ready` 是「从活跃集外 + * 进入活跃集」,会被 `updateRunStatus` 的准入事务数一次坑。 + * 改这个数组之前先读 27-P3C §四.3 与 §五.2。 + * + * 批 4 之前这个数组还兼任 `idx_graph_runs_one_active_root` 的 WHERE 子句(拼进建表 SQL)。 + * 那条索引已 DROP(见 getDb 的迁移块),现在它只有一个用途:准入事务的判据。 */ const ACTIVE_ROOT_RUN_STATUS = ['ready', 'running', 'paused']; /** - * 一家公司同时能占几个执行坑。 + * 一家公司同时能占几个执行坑 —— 缺省值。 + * **缺省必须是 1**:读不到配置的公司(没建目录的单测、被 delete() 改名的公司、`_unknown`) + * 一律回落到批 3 的行为。回落成 0 是「永远不出队 = 静默全停」,回落成 Infinity 是超发。 + */ +const DEFAULT_MAX_ACTIVE_ROOT_RUNS = 1; + +/** + * 硬上限。配置里写个 100000 不该被当真 —— 每个坑对应一条真在跑的 root run(一个 Claude CLI + * 子进程量级的开销),机器级闸门(SessionPool.maxSessions)至今不存在(登记在 28-followups)。 + * 超过就按上限截断并打日志,不静默当成无限。 + */ +const MAX_ACTIVE_ROOT_RUNS_CAP = 20; + +/** 同一份非法配置不必每次准入都刷一行日志 */ +const warnedMaxParallel = new Set(); + +function warnMaxParallelOnce(companyId, raw, why) { + const key = `${companyId}:${raw}`; + if (warnedMaxParallel.has(key)) return; + warnedMaxParallel.add(key); + console.warn(`[work-graph-db] company ${companyId} 的 max_parallel_tasks=${JSON.stringify(raw)} ${why}`); +} + +/** + * 这家公司同时能占几个执行坑(27-P3C 批 4 / 27-P4 §一.3)。 + * + * **调用时读,不在模块加载时快照**。`company-paths.js` 顶上那条教训就是这个:镜像时代两个 + * 模块各自快照 GRAPH_RUNS_DIR,加载顺序不同就算出不同的根。这里同理 —— 公司配置随时可改, + * 快照一次就意味着「改了配置要重启才生效」,而且不报错。 + * + * 读的是 `companyRootDir(companyId)/company.json` 的 `max_parallel_tasks`(snake_case, + * 与 27-P4 §一.3 的字段名逐字一致;**不认 camelCase 的 maxParallelTasks** —— 两个键并存 + * 就是又一个双持有者)。走 `company-paths.companyRootDir` 而不是 `company-service.get`: + * · 前者认 GRAPH_RUNS_DIR,后者不认。准入判据一旦去读 company.json,设了 GRAPH_RUNS_DIR + * 的单测就会去读**真仓库**的 data/companies/,撞上同名公司就读到真配置(§六 缺陷 A 同型); + * · 顺带避开 `company-service → graph-service → graph-db` 那条 require 环。 + * 两者在生产下算出的是同一个路径(都是 REPO_ROOT/data/companies/{cid})。 + * + * 非法值(0 / 负数 / 小数 / 非数字 / 文件坏了)一律回落 DEFAULT 并打一次日志: + * 「配置写错了就当没配」必须是能看见的,否则就是一次静默降级。 * - * ponytail: 批 3 硬编码为 1,与 `idx_graph_runs_one_active_root`(UNIQUE(company_id)) - * 表达的是同一件事。批 4 放开并行时这里改成读 `company.json.max_parallel_tasks` - * 并 DROP 那个索引 —— 到那时 `updateRunStatus` 里的准入事务就是**唯一**的守卫, - * 所以它今天就得是对的,而不是等索引没了再回头补。 + * ponytail: 每次准入读一次盘(一个几百字节的 JSON)。准入是低频事件(一次出队/恢复一次), + * 没加缓存。真到高频时加一层 mtime 缓存,别改成模块级快照。 */ -const MAX_ACTIVE_ROOT_RUNS = 1; +function maxActiveRootRuns(companyId) { + if (!companyId) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; + let raw; + try { + const { companyRootDir } = require('./company-paths'); + const fp = require('path').join(companyRootDir(companyId), 'company.json'); + const fs = require('fs'); + if (!fs.existsSync(fp)) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; + raw = JSON.parse(fs.readFileSync(fp, 'utf-8')).max_parallel_tasks; + } catch (err) { + // company.json 坏了不该让一次出队变成 500:回落缺省,但必须留声 + warnMaxParallelOnce(companyId, null, `读取失败(${err.message}),按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`); + return DEFAULT_MAX_ACTIVE_ROOT_RUNS; + } + if (raw === undefined || raw === null) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; + if (!Number.isInteger(raw) || raw < 1) { + warnMaxParallelOnce(companyId, raw, `不是 ≥1 的整数,按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`); + return DEFAULT_MAX_ACTIVE_ROOT_RUNS; + } + if (raw > MAX_ACTIVE_ROOT_RUNS_CAP) { + warnMaxParallelOnce(companyId, raw, `超过硬上限,按 ${MAX_ACTIVE_ROOT_RUNS_CAP} 处理`); + return MAX_ACTIVE_ROOT_RUNS_CAP; + } + return raw; +} /** * 坑满:一条 root run 想从活跃集外进入活跃集,但公司的执行坑已经占满。 @@ -71,6 +134,11 @@ const MAX_ACTIVE_ROOT_RUNS = 1; * 那个形状(§十四.3 第 3 条:注释是对的、代码没兑现;这次反过来)。 * 现在文本不再是任何人的判据:唯一的判据是 `isSlotRaceError`(下面那个函数),按类型判。 * message 里那句 `active-root-run constraint` 只当人读的说明留着,改它不会弄坏任何分支。 + * + * ⚠️ **待拆(批 4 留下的)**:批 4 已 DROP 掉同名的唯一索引,`constraint` 这个词会让读者 + * 以为 DB 层还有一道约束 —— 该换成 `limit`。本批**故意不动这个词**:路由层正由另一个 agent + * 并行改造,万一那边还留着按文本嗅探的分支,改早了会把一次 409 退化成 500。等路由那批落地、 + * 确认全仓只剩 `instanceof` / `isSlotRaceError` 之后换掉它并删本段。 */ class RunSlotBusyError extends Error { /** @@ -103,17 +171,29 @@ class RunSlotBusyError extends Error { * 一次正常竞争当成故障去 `abandonRun`(run 收成 stopped、task 收成 cancelled = 丢任务), * 而路由那边照常 409。这正是 `canDispatch` 那次事故的形状,只是换了个取值域。 * - * 三种形态,三道防线各一种: - * · `RunSlotBusyError` 主守卫:准入事务在 BEGIN IMMEDIATE 里数出坑已满; - * · `SQLITE_CONSTRAINT_UNIQUE` 兜底:真有两条同时写进活跃集时唯一索引直接拒(批 4 DROP 它后消失)。 - * 判 `code` 而不是判 message:better-sqlite3 的 SqliteError 自带这个字段,文案换了它不变; - * · `InvalidTransitionError` 这条 run 已经被别人从 queued 推走了。 + * **现在只有一种形态**:`RunSlotBusyError` —— 准入事务在 `BEGIN IMMEDIATE` 里数出坑已满。 + * + * ⚠️ 批 3 那版还把 `InvalidTransitionError` 算作竞态,注释写着「这条 run 已经被别人从 queued + * 推走了」。**探针实测证伪了这句话**:被别人推走时 `updateRunStatus` 走的是条件更新 + * `WHERE id=? AND run_status=@from`,**返回 0、不抛异常**。真正抛 `InvalidTransitionError` 的 + * 只有一种情况 —— **调用方声明了一个非法转换**(`expected = from` 是调用方传进来的声明值, + * `canTransitionRun` judge 的是 `expected → to`,不是 `cur.run_status → to`)。 + * 也就是说那一支把「调用方写错了转换」当成「这轮没抢到」吞掉,还不留日志: + * 调度器会 `continue`,任务永远排队,界面与正常排队一模一样 —— 又一条「写不进去但不报错」。 + * 已删除该分支,理由与下面删 UNIQUE 那支完全相同:让它吵起来。 + * + * ⚠️ 批 4 **删掉了 `err.code === 'SQLITE_CONSTRAINT_UNIQUE'` 那一支**,这是一次故意的行为改变。 + * 它当初的语义是「兜底那道唯一索引拒了」。索引 DROP 之后,这一跳再吐出 UNIQUE 冲突只剩一个 + * 可能:**索引被建回来了**(旧版本进程启动时又跑了一遍它的 `CREATE UNIQUE INDEX IF NOT EXISTS`, + * 见 getDb 迁移块那段告示)。那种情况下 max>1 会被悄悄压回 1 —— 留着这一支的话,调度器把它 + * 当「这轮没抢到」`continue` 掉,任务永远排队、日志一行没有、界面和正常排队一模一样, + * 正是要消灭的「写不进去但不报错」。删掉之后它会一路抛到 `abandonRun` / 500,吵得人能看见。 + * + * 判类型而不是判 message:改一句报错文案不该改变任何分支(这是批 3 那次分叉的根因)。 */ function isSlotRaceError(err) { if (!err) return false; - return err instanceof InvalidTransitionError - || err instanceof RunSlotBusyError - || err.code === 'SQLITE_CONSTRAINT_UNIQUE'; + return err instanceof RunSlotBusyError; } // ──────────────────────────────────────────────────────────────── @@ -237,13 +317,10 @@ function getDb() { created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); - -- v1 约束:同一 company 至多一个「活跃」root run。取值域从 ACTIVE_ROOT_RUN_STATUS 拼进来, - -- 不再手抄一份 —— 索引的 WHERE 和调度器判「坑满没满」的判据必须是同一个数组(§五.1)。 - -- 注意 queued 不在其中:排队的 run 不占坑,这是出队 CAS 的依据(见该常量注释)。 - CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_runs_one_active_root - ON graph_runs(company_id) - WHERE parent_run_id IS NULL - AND run_status IN (${ACTIVE_ROOT_RUN_STATUS.map((s) => `'${s}'`).join(',')}); + -- 批 4 之前这里建过 idx_graph_runs_one_active_root(UNIQUE(company_id) WHERE 活跃且是 root), + -- 即「一家公司同时只有一条活跃 root run」那条 v1 硬约束。它与 max_parallel_tasks > 1 直接冲突, + -- 已删除,并在下面的迁移块里显式 DROP。**别把它加回来**:加回来 = 把并行悄悄压回 1 + -- (症状见 isSlotRaceError 那段注释)。守卫现在只有一处,在 updateRunStatus 的准入事务里。 CREATE TABLE IF NOT EXISTS graph_run_nodes ( id TEXT PRIMARY KEY, @@ -433,6 +510,29 @@ function getDb() { // 存量库迁移(写法照抄 graph-db.js migrate():hasColumn 判断 + ALTER TABLE ADD COLUMN, // 新建库走上面的 CREATE TABLE 就已经是目标形态,这里是 no-op)。 + + // ── 批 4:DROP 掉「一家公司只许一条活跃 root run」的唯一索引 ──────────────── + // + // 从建表块里删掉那条 CREATE **对存量库无效**(`IF NOT EXISTS` 只管不重复建,不会把已有的 + // 删掉),所以必须在这里显式 DROP —— 与 graph-db.js:76 那处 + // `DROP INDEX IF EXISTS idx_org_graph_nodes_company_role` 同一写法。 + // 不套 hasColumn:那个函数 pragma 的是 table_info,承载不了索引;`DROP INDEX IF EXISTS` + // 本身就是幂等的,重复跑、并发跑都安全(SQLite 把 DDL 序列化,慢的那个等 busy_timeout)。 + // + // ⚠️ 删索引 = 删掉最后一道 DB 兜底。删之前先确认主守卫是**真的**主守卫 —— + // `updateRunStatus` 的准入是四步一体、缺一步就重新出现超发窗口: + // 1. `BEGIN IMMEDIATE`:读之前就拿写锁,杜绝两个连接各自数出「还有空位」; + // 2. 事务内**重读** run_status:状态已被别人推走时返回 0(幂等 no-op),不误报坑满; + // 3. 事务内 `listActiveRootRunIds` 计数,判据是「从活跃集外进入活跃集」(集内移动不重复占坑); + // 4. 同一事务内做条件更新 `WHERE id=? AND run_status=@from`(写进去的 to 不在 WHERE 里)。 + // 三个测试文件钉着这四步:task-scheduler.test.js「准入守卫」那组、task-queue-acceptance.test.js + // 的超发用例、graph-run-slot-recovery.test.js 的拒绝-退队列路径。 + // + // ⚠️ **上线要求全停再起**:DROP 之后只要有**旧版本代码**的进程再调一次 getDb(), + // 它那条 `CREATE UNIQUE INDEX IF NOT EXISTS` 会把索引原样建回来,于是 max>1 的第二条准入 + // 吃一句 SQLite 原文的 500(isSlotRaceError 已不再吞它,就是为了让这一幕吵起来)。 + db.exec(`DROP INDEX IF EXISTS idx_graph_runs_one_active_root`); + // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); @@ -837,8 +937,9 @@ function readDefGraph(kind, graphId) { * root run:不传 parentRunId,root_run_id = 自身 id,budget_json 只在这里非空(§四.A.3)。 * 子 run: 传 parentRunId + parentNodeRunId,root_run_id 从父 run 继承(冗余存储避免上溯)。 * - * 同一 company 只允许一个 active root run(idx_graph_runs_one_active_root); - * 冲突时抛 SqliteError,调用方自己决定是拒绝还是复用已有 run。 + * **建 run 本身不占坑,也不做额度检查**(批 4 起唯一的坑约束是 `updateRunStatus` 的准入事务)。 + * 建出来的 run 只有走「进入活跃集」那一跳时才会被数一次坑 —— 计数守卫**故意不放在这里**: + * 建 run 与进活跃集之间隔着排队,放在这里等于把「此刻有没有位置」当成「以后有没有位置」。 * * @returns {{ok: true, id, nodeRunIds: Map}} */ @@ -1137,9 +1238,9 @@ const RUN_PATCH_COLUMNS = { /** * 当前占着这家公司执行坑的 root run 数。 * - * WHERE 与 `idx_graph_runs_one_active_root` **逐字同源**(同一个 ACTIVE_ROOT_RUN_STATUS 拼出来)。 - * 一旦两边分叉,症状是「事务守卫放行、索引拒绝」的 500 —— 一个把关的说行、另一个说不行, - * 而报出来的只有一句 SQLite 原文。所以计数只有这一个持有者,调度器也调它,不许自己再写一遍 SQL。 + * WHERE 从 ACTIVE_ROOT_RUN_STATUS 拼出来,与准入事务的判据同源。计数只有这一个持有者, + * 调度器 / 路由都调它,不许自己再写一遍 SQL —— 批 4 DROP 掉唯一索引之后这条 SQL 就是 + * 「坑满没满」的**全部**依据,多一个持有者就多一个能悄悄分叉的地方。 * * @param {string} companyId * @param {object} [db] 传进来是为了让准入的计数与随后的 UPDATE 落在**同一个事务**里 @@ -1222,10 +1323,17 @@ function updateRunStatus(runId, from, to, patch = {}) { && !ACTIVE_ROOT_RUN_STATUS.includes(expected); if (!admitting) return write(); - // BEGIN IMMEDIATE:**读之前**就拿到写锁,于是「数出 0 个活跃」到「自己占上」之间 - // 不可能有第二个连接插进来各自数出 0(check-then-act 的经典超发)。 + // 额度在**进事务之前**解析:它要读一次盘(company.json)+ JSON.parse,塞进 BEGIN IMMEDIATE + // 就是拿着 SQLite 写锁去做 syscall,而且文件坏掉时会在事务里抛。 + // 这一步不需要在事务里:需要原子的是「数坑 + 占坑」,不是「读配置」—— + // 这一刻读到的额度即使下一毫秒被人改了,最多影响这一次调度,不影响任何不变量。 + const max = maxActiveRootRuns(cur.company_id); + + // BEGIN IMMEDIATE:**读之前**就拿到写锁,于是「数出还有空位」到「自己占上」之间 + // 不可能有第二个连接插进来各自数出还有空位(check-then-act 的经典超发)。 // 同进程内 better-sqlite3 是同步单写者本来就串行,这把锁防的是多进程 / 多连接。 - // 唯一索引仍在,是兜底那一层:主守卫(本事务)与兜底(DB 约束)并存,批 4 只 DROP 后者。 + // 批 4 起这是**唯一**的守卫:`idx_graph_runs_one_active_root` 那道 DB 兜底已经 DROP + // (理由与四步论证见 getDb 迁移块),所以下面这四行今天就得是对的,没有第二道网接着。 return db.transaction(() => { // 事务内重读才是权威的。状态已经不是 expected → 这次调用本来就是幂等 no-op // (下面 UPDATE 的 WHERE 同样匹配不到),返回 0。**绝不能**当成坑满抛错: @@ -1234,8 +1342,8 @@ function updateRunStatus(runId, from, to, patch = {}) { if (!fresh || fresh.run_status !== expected) return 0; const heldBy = listActiveRootRunIds(cur.company_id, db); - if (heldBy.length >= MAX_ACTIVE_ROOT_RUNS) { - throw new RunSlotBusyError(cur.company_id, runId, heldBy, MAX_ACTIVE_ROOT_RUNS); + if (heldBy.length >= max) { + throw new RunSlotBusyError(cur.company_id, runId, heldBy, max); } return write(); }).immediate(); @@ -2285,7 +2393,7 @@ module.exports = { updateNodeStatus, bumpNodeAttempt, updateRunStatus, - // 「坑满没满」的唯一计数处(WHERE 与唯一索引同源),调度器复用它 + // 「坑满没满」的唯一计数处(WHERE 与准入事务同源),调度器 / 路由复用它 countActiveRootRuns, listActiveRootRunIds, setRunFlags, @@ -2347,10 +2455,14 @@ module.exports = { // 状态机 NODE_TRANSITIONS, RUN_TRANSITIONS, - // 唯一索引的 WHERE 与调度器的「坑满没满」判据共用这一个数组,别再抄字面量 + // 准入事务与调度器的「坑满没满」判据共用这一个数组,别再抄字面量 ACTIVE_ROOT_RUN_STATUS, - // 并行度上限:批 3 恒为 1,批 4 改成读 company.json。调度器取这里,不许再写一个 1 - MAX_ACTIVE_ROOT_RUNS, + // 并行度上限:per-company,读 company.json.max_parallel_tasks。 + // **故意不导出一个常量**:批 3 那个 `MAX_ACTIVE_ROOT_RUNS = 1` 一旦留下来, + // 就会有人拿它当「上限」用,而它对一家 max=3 的公司是错的(且不报错)。 + maxActiveRootRuns, + DEFAULT_MAX_ACTIVE_ROOT_RUNS, + MAX_ACTIVE_ROOT_RUNS_CAP, canTransitionNode, canTransitionRun, InvalidTransitionError, diff --git a/server/tests/unit/goal-task-acceptance.test.js b/server/tests/unit/goal-task-acceptance.test.js index 2a0d0ea..5420600 100644 --- a/server/tests/unit/goal-task-acceptance.test.js +++ b/server/tests/unit/goal-task-acceptance.test.js @@ -1549,7 +1549,7 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { for (let t = 0; t < 3; t++) { const task = wdb.createTask({ goalId: goal.id, title: `T${g}-${t}`, description: `活 ${g}-${t}` }); // 每条 task 挂一条 run + 2 个节点行:没有 run 的 task 走不到进度查询,挡不住 N+1。 - // run_status 用 completed —— idx_graph_runs_one_active_root 只许一条活跃 root run, + // run_status 用 completed —— 活跃 root run 是要占公司执行坑的(批 4 前还有一条同名唯一索引), // 而这里要的是 15 条历史 run 同时出现在列表上(那正是 N+1 最疼的形态)。 const runId = `run_n1_${g}_${t}`; db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at) diff --git a/server/tests/unit/goal-task-model.test.js b/server/tests/unit/goal-task-model.test.js index 1f094f6..839768a 100644 --- a/server/tests/unit/goal-task-model.test.js +++ b/server/tests/unit/goal-task-model.test.js @@ -23,7 +23,7 @@ import { dependsLabel, pickDefaultSelection, findTask, runStateToTaskPatch, RUN_TERMINAL_TO_TASK_STATUS, queueBadge, parseBlockedReason, blockedLabel, taskActions, queueStat, queueNoteText, - dispatchedTaskId, pendingRunHint, hasStartedRun, UPSTREAM_BLOCK_PREFIX, + dispatchedTaskId, pendingRunHint, hasStartedRun, UPSTREAM_BLOCK_PREFIX, readSlots, } from '../../../web/src/components/graph-console/goal-task-model.js'; const T0 = Date.parse('2026-08-18T10:00:00.000Z'); @@ -332,6 +332,139 @@ describe('「新目标」的队列提示', () => { }); }); +describe('执行位(批 4 · 一家公司可以同时跑几条)', () => { + it('三个数全部读服务端的,free 连减法都不做 —— 一做减法就是第二个持有者', () => { + expect(readSlots({ max: 3, used: 2, free: 1 })).toEqual({ max: 3, used: 2, free: 1 }); + expect(readSlots({ max: 1, used: 0, free: 1 })).toEqual({ max: 1, used: 0, free: 1 }); + // max 刚被改小时服务端给的是截断过的 free(slotState 里的 Math.max(0, …)),原样带走 + expect(readSlots({ max: 3, used: 5, free: 0 })).toEqual({ max: 3, used: 5, free: 0 }); + // holders 一起来也不碍事:这里只读三个数 + expect(readSlots({ max: 3, used: 3, free: 0, holders: [{ runId: 'r1' }] })) + .toEqual({ max: 3, used: 3, free: 0 }); + }); + + it('形状不对一律 null —— 界面宁可少说一句,也不能凭空说出一个执行位总数', () => { + expect(readSlots(null)).toBeNull(); + expect(readSlots({})).toBeNull(); + expect(readSlots({ max: 3, used: 1 })).toBeNull(); // 缺 free + expect(readSlots({ used: 1, free: 2 })).toBeNull(); // 缺 max + expect(readSlots({ max: '3', used: '1', free: '2' })).toBeNull(); // 字符串不认 + expect(readSlots({ max: 3, used: 1.5, free: 1.5 })).toBeNull(); + expect(readSlots({ max: 0, used: 0, free: 0 })).toBeNull(); // max=0 = 永远不出队,不是能显示的值 + // 这两条钉的是「不许走 Number() 转换」:Number(null)/Number('') 都是 0, + // 一个字段缺失的响应会被读成「0 个执行位在用」并原样显示出去 + expect(readSlots({ max: 3, used: null, free: 3 })).toBeNull(); + expect(readSlots({ max: 3, used: '', free: 3 })).toBeNull(); + }); + + it('形状逐字同服务端唯一那一份(routes/graph-run.js#slotState 的返回值)', () => { + // slotState(companyId) → { max, used, free, holders };这里连字段名都不翻译 + const fromServer = { max: 3, used: 3, free: 0, holders: [{ runId: 'r1', taskId: 'T-1' }] }; + expect(readSlots(fromServer)).toEqual({ max: 3, used: 3, free: 0 }); + }); +}); + +describe('并行下的队列文案', () => { + it('有 slots 就报「几分之几个执行位」——「1 个在跑」在 3 坑公司里读起来像满了', () => { + const txt = queueNoteText({ running: 1, queued: 2, blocked: 0 }, { max: 3, used: 1, free: 2 }); + expect(txt).toContain('1/3 个执行位在用'); + expect(txt).toContain('2 个在排队'); + expect(txt).toContain('还空着 2 个执行位'); + expect(txt).not.toMatch(/第\s*\d+\s*位/); // 位次照旧一个都不编 + }); + + it('坑满时不说「还空着」,退回「排在它们后面」', () => { + const txt = queueNoteText({ running: 3, queued: 1, blocked: 0 }, { max: 3, used: 3, free: 0 }); + expect(txt).toContain('3/3 个执行位在用'); + expect(txt).not.toContain('还空着'); + expect(txt).toContain('坑一空就自动开跑'); + }); + + it('slots 给不出时逐字退回批 3 原样 —— 今天服务端还没吐这个字段,走的就是这一支', () => { + const before = queueNoteText({ running: 1, queued: 2, blocked: 1 }); + expect(before).toBe(queueNoteText({ running: 1, queued: 2, blocked: 1 }, null)); + expect(before).toBe(queueNoteText({ running: 1, queued: 2, blocked: 1 }, { max: 3 })); + expect(before).toContain('1 个任务在跑'); + expect(before).not.toContain('执行位'); + }); + + it('used 与 running 对不上时两个都报,不替服务端断言它们该相等', () => { + // used 数的是活跃 root run(含 ready/paused),running 数的是 task 行 —— 不是同一件事 + const txt = queueNoteText({ running: 1, queued: 0, blocked: 0 }, { max: 3, used: 2, free: 1 }); + expect(txt).toContain('2/3 个执行位在用'); + expect(txt).toContain('1 个任务在跑'); + }); + + it('队里空但有执行位在用时也要说话(在跑的那条不在 queueStat 的三个数里也算数)', () => { + expect(queueNoteText({ running: 0, queued: 0, blocked: 0 }, { max: 3, used: 1, free: 2 })) + .toContain('1/3 个执行位在用'); + // 真的一个都没有:还是不说话 + expect(queueNoteText({ running: 0, queued: 0, blocked: 0 }, { max: 3, used: 0, free: 3 })).toBe(''); + }); +}); + +describe('排队中的任务:还要等多久', () => { + it('位次 ≤ 空位 = 下一轮就开跑,不是「要等前面的收敛」', () => { + const h = pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 2 }, { max: 3, used: 1, free: 2 }); + expect(h).toContain('排在第 2 位'); + expect(h).toContain('还空着 2 个'); + expect(h).toContain('下一轮调度'); + expect(h).not.toContain('要等'); + }); + + it('坑满时说「还要等几个执行位腾出来」,不是「前面还有几个任务」', () => { + // 位次 4、3 个坑全满:空位会先被前 3 条占掉,我要等 4 个坑腾出来才轮到 + const h = pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 4 }, { max: 3, used: 3, free: 0 }); + expect(h).toContain('还要等 4 个执行位腾出来'); + expect(h).not.toContain('要等 0 个'); + // 队首 + 满坑 = 等 1 个(旧文案在这里会说「前面还有 0 个」) + expect(pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 1 }, { max: 1, used: 1, free: 0 })) + .toContain('还要等 1 个执行位腾出来'); + }); + + it('manual 的排队任务:空位再多也不会自己动 —— 调度器只出队 auto', () => { + const h = pendingRunHint({ status: 'queued', mode: 'manual', queuePosition: 1 }, { max: 3, used: 0, free: 3 }); + expect(h).toContain('手动'); + expect(h).toContain('立即开始'); + expect(h).not.toContain('自动开跑'); + // 没有 slots 时同样要说,这条与并行无关 + expect(pendingRunHint({ status: 'queued', mode: 'manual', queuePosition: 1 })).toContain('立即开始'); + }); + + it('slots 给不出时逐字退回批 3 原样', () => { + expect(pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 3 })) + .toContain('前面还有 2 个任务'); + expect(pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 1 })) + .toContain('排在队首'); + expect(pendingRunHint({ status: 'queued', mode: 'auto' }, { max: 3, used: 1, free: 2 })) + .toContain('服务端没给位次'); + }); +}); + +describe('并行下左栏每一行', () => { + it('位次只对 queued 有意义:N 条同时 running 一个位次都不标', () => { + expect(queueBadge({ status: 'running', queuePosition: 1 })).toBeNull(); + expect(queueBadge({ status: 'running', queuePosition: 2 })).toBeNull(); + expect(queueBadge({ status: 'blocked', queuePosition: 3 })).toBeNull(); + expect(queueBadge({ status: 'queued', queuePosition: 3 }).text).toBe('排队 #3'); + }); + + it('N 条并行时默认选中最新那条,且重复调用结果稳定(不会每轮轮询跳一条)', () => { + const goals = [{ + goalId: 'G-1', + tasks: [ + { taskId: 'T-a', status: 'running', createdAt: '2026-08-18T10:00:00Z' }, + { taskId: 'T-b', status: 'running', createdAt: '2026-08-18T12:00:00Z' }, + { taskId: 'T-c', status: 'running', createdAt: '2026-08-18T11:00:00Z' }, + ], + }]; + expect(pickDefaultSelection(goals)).toEqual({ goalId: 'G-1', taskId: 'T-b' }); + // 数组顺序变了也选同一条:排序只看 createdAt,不吃调用方的顺序 + const shuffled = [{ goalId: 'G-1', tasks: [goals[0].tasks[1], goals[0].tasks[2], goals[0].tasks[0]] }]; + expect(pickDefaultSelection(shuffled)).toEqual({ goalId: 'G-1', taskId: 'T-b' }); + }); +}); + describe('dispatch 响应 → 选中态', () => { it('起了 run(started)与入了队(queued[])两种形状都认;认不出就返回空串,不猜一条出来', () => { expect(dispatchedTaskId({ started: { taskId: 'T-1' } })).toBe('T-1'); diff --git a/server/tests/unit/graph-run-route-slot-busy.test.js b/server/tests/unit/graph-run-route-slot-busy.test.js index 618557c..1cf3665 100644 --- a/server/tests/unit/graph-run-route-slot-busy.test.js +++ b/server/tests/unit/graph-run-route-slot-busy.test.js @@ -111,7 +111,10 @@ describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => expect(r.status).toBe(409); // 修之前:500 + 一句 "company … 的执行坑已满" 原文 expect(body.reason).toBe('run-slot-busy'); expect(body.companyId).toBe(CID); - expect(body.max).toBe(wdb.MAX_ACTIVE_ROOT_RUNS); + // 批 4 起额度是 per-company 的(company.json.max_parallel_tasks,这家没配 → 缺省 1)。 + // 钉的是「409 里报的额度 = 准入真的用的那个额度」,不是钉某个常量。 + expect(body.max).toBe(wdb.maxActiveRootRuns(CID)); + expect(body.max).toBe(wdb.DEFAULT_MAX_ACTIVE_ROOT_RUNS); // 「挡我的是谁」:run id 与 task id 都要给得出来,否则界面上只剩「坑满」 expect(body.holders).toEqual([ diff --git a/server/tests/unit/task-queue-acceptance.test.js b/server/tests/unit/task-queue-acceptance.test.js index f9de4e8..2582fbf 100644 --- a/server/tests/unit/task-queue-acceptance.test.js +++ b/server/tests/unit/task-queue-acceptance.test.js @@ -276,20 +276,37 @@ describe('批 3 排队 · §八 验收', () => { const runStatus = (id) => wdb.getRun(id).run_status; // ────────────────────────────────────────────────────────────── - // 唯一索引:queued 不在 WHERE 里 + // 批 4:唯一索引已 DROP —— 事务准入是唯一的守卫 // ────────────────────────────────────────────────────────────── - it('唯一索引的 WHERE 里没有 queued —— 出队原子性的全部依赖,直接对着建表 SQL 断言', () => { + /** + * 批 3 这一条钉的是「唯一索引的 WHERE 里没有 queued」(当时出队原子性靠它兜底)。 + * 批 4 把索引 DROP 了 —— 但要钉的事情**没变**:超发必须拦得住。所以这一条整体反转: + * · 索引必须**不存在**(`CREATE ... IF NOT EXISTS` 对存量库不生效,所以必须显式 DROP。 + * 它要是还在,max>1 的第二条准入会在事务放行之后吃一句 SQLite 原文的 500); + * · 主守卫必须**真的**是主守卫,而且此刻就在拦超发(下面直接演示)。 + * 断言索引不在,而不只是断言 JS 常量没了:两者一旦分叉,只看常量的用例照样绿。 + */ + it('唯一索引已 DROP,且主守卫独自拦得住超发 —— 删兜底之后 max=1 的行为一个字不变', () => { const row = wdb.getDb() .prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_graph_runs_one_active_root'`) .get(); - expect(row).toBeTruthy(); - // 断言的是**索引自己的 SQL**,不是 JS 常量:常量与索引一旦分叉,只看常量的用例照样绿 - expect(row.sql).toMatch(/parent_run_id IS NULL/); - expect(row.sql).not.toMatch(/queued/); - for (const s of ['ready', 'running', 'paused']) expect(row.sql).toContain(`'${s}'`); - // 反过来也要成立:JS 侧那份取值域与索引逐字同源 + expect(row).toBeUndefined(); + + // 取值域仍是准入判据的唯一持有者(queued 不在里面 = 排队不占坑) expect(wdb.ACTIVE_ROOT_RUN_STATUS).toEqual(['ready', 'running', 'paused']); - expect(wdb.MAX_ACTIVE_ROOT_RUNS).toBe(1); + expect(wdb.ACTIVE_ROOT_RUN_STATUS).not.toContain('queued'); + // 没配 max_parallel_tasks 的公司回落 1 —— 存量行为不变的那条保证 + const companyId = nextCompany(); + expect(wdb.maxActiveRootRuns(companyId)).toBe(1); + + // 主守卫单独上阵:第一条进坑,第二条被事务拒(RunSlotBusyError,不是 SqliteError) + const goalId = newGoal(companyId); + const a = enqueue(companyId, goalId, { title: 'a' }); + const b = enqueue(companyId, goalId, { title: 'b' }); + expect(wdb.updateRunStatus(a.runId, 'queued', 'ready')).toBe(1); + expect(() => wdb.updateRunStatus(b.runId, 'queued', 'ready')).toThrow(wdb.RunSlotBusyError); + expect(wdb.getRun(b.runId).run_status).toBe('queued'); // 拒绝是干净的:一个字都没写 + expect(wdb.countActiveRootRuns(companyId)).toBe(1); }); // ────────────────────────────────────────────────────────────── @@ -585,6 +602,43 @@ describe('批 3 排队 · §八 验收', () => { expect(wdb.countActiveRootRuns(companyId)).toBe(1); }); + it('L1:人亲手起过的 manual 被退回队列后,坑一空自己接回来(不用再点一次)', async () => { + const companyId = nextCompany(); + const goalId = newGoal(companyId); + hangingExecutor(); + + // 人亲手起跑一条 manual —— 这一步就是「授权」,started_at 在这里被写上 + const m = enqueue(companyId, goalId, { title: 'm', mode: 'manual', priority: 'P0' }); + expect(humanStart(wdb.getTask(m.taskId))).toBeTruthy(); + expect(wdb.getTask(m.taskId).attempt).toBeGreaterThan(0); // 授权的事实记在 attempt 上 + + // 它撞上预算/挂起被退回队列(直接用数据层那条退路,等价于 admitOrRequeue 走到的分支) + expect(sse.transitionRun(m.runId, 'running', 'blocked', {}, { summary: '用例:预算耗尽' })).toBeTruthy(); + expect(wdb.requeueBlockedRootRun(m.runId)).toBeTruthy(); + expect(taskStatus(m.taskId)).toBe('queued'); + expect(runStatus(m.runId)).toBe('queued'); + + // 坑是空的 —— 批 3 那版这里会永远躺着(调度器 filter 只认 auto),人得**另外**再点一次 + expect(wdb.countActiveRootRuns(companyId)).toBe(0); + scheduler.tick(companyId); + + // 现在它自己回来了:人刚才那次点击被兑现,而不是被吞掉 + expect(taskStatus(m.taskId)).toBe('running'); + expect(runStatus(m.runId)).toBe('running'); + }); + + it('L1 反面:从没被人起过的 manual 仍然不许自动出队(首次起跑的授权不能省)', () => { + const companyId = nextCompany(); + const goalId = newGoal(companyId); + hangingExecutor(); + const m = enqueue(companyId, goalId, { title: 'm', mode: 'manual', priority: 'P0' }); + expect(wdb.getTask(m.taskId).attempt).toBe(0); // 从没被人起过 + scheduler.tick(companyId); + scheduler.tick(companyId); + expect(taskStatus(m.taskId)).toBe('queued'); + expect(wdb.countActiveRootRuns(companyId)).toBe(0); + }); + // ────────────────────────────────────────────────────────────── // 验收 23:blocked 是推导出来的,不是落死的 // ────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/task-queue-routes.test.js b/server/tests/unit/task-queue-routes.test.js index d32c09d..38f7ca9 100644 --- a/server/tests/unit/task-queue-routes.test.js +++ b/server/tests/unit/task-queue-routes.test.js @@ -226,17 +226,24 @@ describe('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { // /start —— manual 任务唯一的起跑路径(§五.2bis) // ──────────────────────────────────────────────────────────────── - it('/start 把排队中的 manual 拉起来;坑被占着时回 409 + 当前位次,绝不假装已开始', async () => { + it('/start 把排队中的 manual 拉起来;执行位满时回 409 + 当前位次,绝不假装已开始', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }]); const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); const runningId = dispatched.started.taskId; const queuedId = goal.tasks.find((t) => t.taskId !== runningId).taskId; - // 坑被占着:如实回 409。回 200 的话界面会显示「已开始」而实际什么都没跑 + // 执行位被占满:如实回 409。回 200 的话界面会显示「已开始」而实际什么都没跑 const [busy, err] = await call('POST', `/api/company/${cid}/tasks/${queuedId}/start`); expect(busy).toBe(409); - expect(err.activeRunId).toBe(dispatched.started.runId); + // 批 4:形状与 graph-run.js 的 run-slot-busy 逐字同源(那边 holders 从批 3 起就是数组)。 + // 这里以前是 activeRunId 一个单数字段 —— N 个执行位时只点名一条,用户停掉它发现还是起不来。 + expect(err.reason).toBe('run-slot-busy'); + expect(err.max).toBe(wdb.maxActiveRootRuns(cid)); // 这家没配 max_parallel_tasks → 缺省 1 + expect(err.active).toBe(1); + expect(err.holders).toEqual([ + expect.objectContaining({ runId: dispatched.started.runId, taskId: runningId, runStatus: 'running' }), + ]); expect(err.queuePosition).toBe(1); expect(wdb.getTask(queuedId).status).toBe('queued'); // 状态一个字节都没动 @@ -250,6 +257,57 @@ describe('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { expect(wdb.getTask(queuedId).attempt).toBe(1); }); + it('max_parallel_tasks=3:一次下达把 3 个执行位填满并全部报出来;满了才 409,空一个就起得来', async () => { + const cid = newCompany(); + // 额度是 per-company 的,写在 company.json 里(走 company-paths,认 GRAPH_RUNS_DIR)。 + // 别用 CompanyService:那份 stub 不认这个字段,而准入读的也不是它。 + const dir = path.join(runsDir, '_companies', cid); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: cid, max_parallel_tasks: 3 })); + expect(wdb.maxActiveRootRuns(cid)).toBe(3); + + const goal = await newGoal(cid, [ + { description: 'a' }, { description: 'b' }, { description: 'c' }, { description: 'd' }, + ]); + const [st, body] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); + expect(st).toBe(201); + + // 批 4 之前这里只会起 1 条(`if (activeRootRun) return null`),而且只报第一条。 + // 两个毛病都是「不报错的少做 / 少说」,界面上与正常排队一模一样。 + expect(body.startedTasks).toHaveLength(3); + expect(body.started).toEqual(body.startedTasks[0]); // 单数字段与数组同源,不是另算一遍 + expect(body.queued.filter((q) => q.queuePosition).map((q) => q.queuePosition)).toEqual([1]); + + // 左栏轮询的两个端点要给得出「几分之几个执行位」——前端 readSlots 读的就是这三个数, + // 它**不自己算 free**(free 带着「max 被改小时截断到 0」的语义)。给不出 = 文案退回批 3。 + const [gs, tree] = await call('GET', `/api/company/${cid}/goals?withTasks=1`); + expect(gs).toBe(200); + expect(tree.slots).toEqual({ max: 3, used: 3, free: 0 }); + const [ts, list] = await call('GET', `/api/company/${cid}/tasks`); + expect(ts).toBe(200); + expect(list.slots).toEqual({ max: 3, used: 3, free: 0 }); + + const startedIds = body.startedTasks.map((s) => s.taskId); + const waiting = goal.tasks.find((t) => !startedIds.includes(t.taskId)).taskId; + + // 位子真满了才 409,而且要说得出「几条在跑 / 上限几 / 谁占着」 + const [busy, err] = await call('POST', `/api/company/${cid}/tasks/${waiting}/start`); + expect(busy).toBe(409); + expect(err.reason).toBe('run-slot-busy'); + expect(err.active).toBe(3); + expect(err.max).toBe(3); + expect(err.holders.map((h) => h.runId).sort()).toEqual(body.startedTasks.map((s) => s.runId).sort()); + // hint 要点名**所有**停得掉的那几条:只给一条的话,用户停掉它发现还是起不来 + for (const s of body.startedTasks) expect(err.hint).toContain(`/api/graph-runs/${s.runId}/stop`); + + // 腾出一个位 → manual 立刻起得来(3 个位空着 1 个还拒绝,正是批 4 要消灭的那个 bug) + sse.transitionRun(body.startedTasks[0].runId, 'running', 'completed', {}, { summary: '用例直推终态' }); + const [ok, res] = await call('POST', `/api/company/${cid}/tasks/${waiting}/start`); + expect(ok).toBe(201); + expect(res.started.taskId).toBe(waiting); + expect(wdb.countActiveRootRuns(cid)).toBe(3); + }); + it('/start 对 draft 与「上游没做完」各给各的 409,并点名挡我的是谁', async () => { const cid = newCompany(); const goal = await newGoal(cid, [ @@ -272,7 +330,8 @@ describe('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { const [depStatus, depErr] = await call('POST', `/api/company/${cid}/tasks/${deploy.taskId}/start`); expect(depStatus).toBe(409); expect(depErr.blockedBy).toEqual([build.taskId]); - expect(depErr.activeRunId).toBeUndefined(); + expect(depErr.reason).toBeUndefined(); // 不是 run-slot-busy:别把人引去 stop 那条没用的路 + expect(depErr.holders).toBeUndefined(); // 上游被停掉(cancelled = 永远不会 completed)之后,下一轮 tick 把它推导成 blocked // 并写明是谁挡的 —— 不许让它继续以 queued 的样子躺在队里假装在排队(§五.1.2) diff --git a/server/tests/unit/task-scheduler.test.js b/server/tests/unit/task-scheduler.test.js index 9d89521..9c63dbf 100644 --- a/server/tests/unit/task-scheduler.test.js +++ b/server/tests/unit/task-scheduler.test.js @@ -7,7 +7,7 @@ * 15 排队期间 replan → 出队后按新图跑(syncRunNodesToDef) * 23 上游失败 → 下游 blocked,上游恢复后自动回 queued(blocked 是推导出来的,不落死) * 外加 §九.1 那张「queued 混进 run_status」的回归表里点名要钉死的三条断言: - * · 唯一索引不覆盖 queued(出队原子性的全部依赖) + * · queued 不在活跃集里(排队不占坑,出队那一跳才被准入事务数一次) * · advanceOne 挡住 queued run(防止 queued run 被误推进的唯一一道闸) * · startRun 拒绝 queued run(出队顺序不能反过来) * @@ -105,7 +105,7 @@ describe('task-scheduler', () => { // ── 夹具 ────────────────────────────────────────────────────── let seq = 0; - /** 每个用例一家独立公司:唯一索引是按 company 约束的 */ + /** 每个用例一家独立公司:执行坑是按 company 数的 */ const nextCompany = () => `sched-co-${++seq}`; function ensureAgents(ids) { @@ -172,7 +172,7 @@ describe('task-scheduler', () => { // §九.1 回归表:queued 不被误当可执行 // ────────────────────────────────────────────────────────────── describe('queued 混进 run_status 的三道闸', () => { - it('唯一索引不覆盖 queued:同一公司可以躺任意多条 queued root run,而出队时超发被 DB 拒', () => { + it('queued 不占坑:同一公司可以躺任意多条 queued root run,而出队时超发被准入事务拒', () => { const companyId = nextCompany(); const goalId = newGoal(companyId); @@ -190,12 +190,17 @@ describe('task-scheduler', () => { // 第二条被**主守卫**(准入事务里数坑)拒:抛 RunSlotBusyError,**不是** changes = 0 expect(() => wdb.updateRunStatus(b.runId, 'queued', 'ready')).toThrow(wdb.RunSlotBusyError); expect(wdb.getRun(b.runId).run_status).toBe('queued'); - // 兜底那一层必须真的还在(注释里承诺了「两道守卫并存」,就得能演示出来): - // 绕开 updateRunStatus 直接写库,唯一索引照样拒。批 4 DROP 它之后这一条要一起删。 + // 批 3 这里演示的是「兜底那一层(唯一索引)也还在」:绕开 updateRunStatus 直接写库照样被拒。 + // 批 4 DROP 了那条索引,所以这一段反过来钉两件事 —— + // ① 索引确实没了:裸写库现在**写得进去**(这正是「删掉了最后一道 DB 兜底」的样子, + // 要能演示出来,否则「已 DROP」只是一句注释); + // ② 于是唯一的守卫只剩上面那个准入事务,它今天就得是对的。 expect(() => wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'ready' WHERE id = ?`).run(b.runId) - ).toThrow(/UNIQUE constraint failed/); - expect(wdb.getRun(b.runId).run_status).toBe('queued'); + ).not.toThrow(); + expect(wdb.getRun(b.runId).run_status).toBe('ready'); + // 把裸写的这条放回 queued,别让它污染后面的断言(它是绕过守卫写进去的,不是准入放行的) + wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'queued' WHERE id = ?`).run(b.runId); // 同一条重复出队 = changes 0(幂等 no-op,不抛) expect(wdb.updateRunStatus(a.runId, 'queued', 'ready')).toBe(0); // queued 有出路:撤出队列 @@ -262,9 +267,11 @@ describe('task-scheduler', () => { * 理由写着「routes/company.js 那两处 catch 至今是按文本嗅探的」—— **批 3 把那两处 * catch 整段删了**(下达不再因坑满失败,改成入队)。于是这条用例一直绿着,钉的却是 * 一个没有任何调用方的契约,还反过来禁止清理那句报文。 - * 现在钉真正的那条:判据只有 `work-graph-db.isSlotRaceError` 一个持有者,按类型判, - * 三种形态一个都不能漏 —— 漏一种,调度器就会把一次正常竞争当故障去 abandonRun(丢任务), + * 现在钉真正的那条:判据只有 `work-graph-db.isSlotRaceError` 一个持有者,按类型判。 + * 该认的一个都不能漏 —— 漏一种,调度器就会把一次正常竞争当故障去 abandonRun(丢任务), * 而路由那边照常 409,两边对同一件事给出相反结论。 + * 不该认的也一个都不能多认(批 4 起 SQLITE_CONSTRAINT_UNIQUE 就在这一侧,见下): + * 认宽了是把真 bug 当正常竞争 continue 掉,那是静默。 */ it('「没抢到坑」的判据只有一个持有者,且按类型判 —— 改一句报错文案不该改变任何分支', () => { const companyId = nextCompany(); @@ -278,16 +285,23 @@ describe('task-scheduler', () => { expect(caught).toBeInstanceOf(wdb.RunSlotBusyError); expect(wdb.isSlotRaceError(caught)).toBe(true); - // 兜底那一层(唯一索引)也必须被认出来:它抛的是 SqliteError,带 code 不带类型 - let dbErr; - try { - wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'ready' WHERE id = ?`).run(b.runId); - } catch (e) { dbErr = e; } - expect(dbErr.code).toBe('SQLITE_CONSTRAINT_UNIQUE'); - expect(wdb.isSlotRaceError(dbErr)).toBe(true); - - // 第三种:这条 run 已经被别人推走了 - expect(wdb.isSlotRaceError(new wdb.InvalidTransitionError('run', 'x', 'queued', 'ready'))).toBe(true); + // 批 3 这里钉的是「兜底那一层(唯一索引)抛的 SqliteError 也必须被认成『没抢到』」。 + // 批 4 DROP 了索引,于是这条判据**反过来**:一个 SQLITE_CONSTRAINT_UNIQUE 再出现在 + // 这条路上,只可能是索引被旧版本进程建回来了(max>1 被悄悄压回 1)。 + // 认成「没抢到」的话调度器会 continue 掉,任务永远排队、日志一行没有 —— + // 正是要消灭的「写不进去但不报错」。所以它必须**不**算竞争,一路抛到人能看见的地方。 + const fakeUniqueErr = Object.assign(new Error('UNIQUE constraint failed: graph_runs.company_id'), { + code: 'SQLITE_CONSTRAINT_UNIQUE', + }); + expect(wdb.isSlotRaceError(fakeUniqueErr)).toBe(false); + + // ⚠️ 批 3 这里断言 InvalidTransitionError 也算「没抢到」,注释写着「这条 run 已经被别人 + // 从 queued 推走了」。**探针实测证伪**:被别人推走时 updateRunStatus 走的是条件更新 + // `WHERE id=? AND run_status=@from`,**返回 0、不抛异常**;真正抛 InvalidTransitionError + // 的只有「调用方声明了非法转换」(expected 是调用方传进来的声明值,canTransitionRun + // judge 的是 expected→to 而不是 cur.run_status→to)。 + // 把调用方的 bug 当成正常竞争吞掉、还不留日志 —— 与上面那条 UNIQUE 同一个病。 + expect(wdb.isSlotRaceError(new wdb.InvalidTransitionError('run', 'x', 'queued', 'ready'))).toBe(false); // 别的错误一律不算「没抢到」—— 认宽了就是把真 bug 当成正常竞争 continue 掉 expect(wdb.isSlotRaceError(new Error('UNIQUE constraint failed: 别处的错'))).toBe(false); expect(wdb.isSlotRaceError(null)).toBe(false); @@ -315,6 +329,116 @@ describe('task-scheduler', () => { }); }); + /** + * 批 4:额度从「模块级常量 1」变成 per-company 的 `company.json.max_parallel_tasks`。 + * + * 这组用例的重点不是「并行能跑」,而是**回落**那一条:全仓几乎所有单测的公司都没有 + * company.json(`sched-co-*` 只是个字符串),一旦回落算错,1195 条测试会一起变色 —— + * 所以「读不到 = 1」是保护存量的那条断言,必须先钉。 + * + * 配置写在 `company-paths.companyRootDir()` 下而不是 `data/companies/` 下: + * 前者认 GRAPH_RUNS_DIR,本文件设了它,于是这组用例不会去碰仓库里的真公司目录 + * (准入判据一旦去读真 data/,撞上同名公司就读到真配置 —— §六 缺陷 A 同型)。 + */ + describe('每家公司自己的执行坑数(company.json.max_parallel_tasks)', () => { + /** 给这家公司写一份 company.json;`n === null` 表示写一个没有该字段的配置 */ + function setMaxParallel(companyId, n) { + const { companyRootDir } = require('../../services/company-paths'); + const dir = companyRootDir(companyId); + fs.mkdirSync(dir, { recursive: true }); + const cfg = { id: companyId, name: companyId }; + if (n !== null) cfg.max_parallel_tasks = n; + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify(cfg)); + return companyId; + } + + it('没有 company.json 的公司回落 1 —— 存量单测与存量公司的行为一个字不变', () => { + const companyId = nextCompany(); + expect(wdb.maxActiveRootRuns(companyId)).toBe(wdb.DEFAULT_MAX_ACTIVE_ROOT_RUNS); + expect(wdb.DEFAULT_MAX_ACTIVE_ROOT_RUNS).toBe(1); + // 有 company.json 但没写这个字段,同样回落 + expect(wdb.maxActiveRootRuns(setMaxParallel(nextCompany(), null))).toBe(1); + }); + + it('非法值一律回落 1,不静默当成无限:0 / 负数 / 小数 / 字符串 / 超上限', () => { + for (const bad of [0, -3, 1.5, '3', true, null]) { + expect(wdb.maxActiveRootRuns(setMaxParallel(nextCompany(), bad))).toBe(1); + } + // 超上限按上限截断(不是回落 1,也不是当真) + const huge = setMaxParallel(nextCompany(), 100000); + expect(wdb.maxActiveRootRuns(huge)).toBe(wdb.MAX_ACTIVE_ROOT_RUNS_CAP); + }); + + it('max=3:前三条准入放行,第 4 条被拒且 active / max / heldBy 三个字段都对', () => { + const companyId = setMaxParallel(nextCompany(), 3); + const goalId = newGoal(companyId); + const runs = ['a', 'b', 'c', 'd'].map((title) => enqueue(companyId, goalId, { title }).runId); + + for (const [i, runId] of runs.slice(0, 3).entries()) { + expect(wdb.updateRunStatus(runId, 'queued', 'ready')).toBe(1); + expect(wdb.countActiveRootRuns(companyId)).toBe(i + 1); + } + + let caught; + try { wdb.updateRunStatus(runs[3], 'queued', 'ready'); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(wdb.RunSlotBusyError); + expect(caught.max).toBe(3); + expect(caught.active).toBe(3); + // heldBy 点得出**具体是谁**占着 —— 少了它 409 上只剩「坑满」,人无从判断该等谁 + expect(caught.heldBy.slice().sort()).toEqual(runs.slice(0, 3).slice().sort()); + expect(wdb.getRun(runs[3]).run_status).toBe('queued'); // 拒绝是干净的 + + // 让出一个坑,第 4 条同一跳立刻过(额度是「同时几条」,不是「一共几条」) + expect(wdb.updateRunStatus(runs[0], 'ready', 'running')).toBe(1); // 集内移动,不占新坑 + expect(wdb.updateRunStatus(runs[0], 'running', 'completed')).toBe(1); + expect(wdb.updateRunStatus(runs[3], 'queued', 'ready')).toBe(1); + }); + + it('额度是 per-company 的:A 公司放开到 3,B 公司照样只有 1', () => { + const a = setMaxParallel(nextCompany(), 3); + const b = nextCompany(); // 没配 → 1 + const aRuns = ['a1', 'a2'].map((t) => enqueue(a, newGoal(a), { title: t }).runId); + const bRuns = ['b1', 'b2'].map((t) => enqueue(b, newGoal(b), { title: t }).runId); + + for (const runId of aRuns) expect(wdb.updateRunStatus(runId, 'queued', 'ready')).toBe(1); + expect(wdb.updateRunStatus(bRuns[0], 'queued', 'ready')).toBe(1); + expect(() => wdb.updateRunStatus(bRuns[1], 'queued', 'ready')).toThrow(wdb.RunSlotBusyError); + expect(wdb.countActiveRootRuns(a)).toBe(2); + expect(wdb.countActiveRootRuns(b)).toBe(1); + }); + + it('一次 tick 把空坑填满,不是一条一条来(max=3 → 三条同时起跑)', async () => { + const companyId = setMaxParallel(nextCompany(), 3); + const goalId = newGoal(companyId); + hangingExecutor(); + const tasks = ['a', 'b', 'c', 'd'].map((title) => enqueue(companyId, goalId, { title })); + + const res = scheduler.tick(companyId); + expect(res.max).toBe(3); + expect(res.started.map((s) => s.taskId)).toEqual(tasks.slice(0, 3).map((t) => t.taskId)); + await flush(); + expect(wdb.countActiveRootRuns(companyId)).toBe(3); + expect(wdb.getTask(tasks[3].taskId).status).toBe('queued'); // 第 4 条还在队里 + // 再 tick 一次不该超发:坑满就原地返回 + expect(scheduler.tick(companyId).skipped).toBe('no-slot'); + }); + + it('候选里有一条起不来时,不该白占一个名额 —— 剩下的坑照样被填满', () => { + const companyId = setMaxParallel(nextCompany(), 2); + const goalId = newGoal(companyId); + hangingExecutor(); + // 队首这条的 run 行被抹掉 = 入队做了一半(5a 那条分支),它必须被跳过而不是吃掉一个坑 + const broken = enqueue(companyId, goalId, { title: 'broken' }); + wdb.getDb().prepare(`UPDATE tasks SET current_run_id = NULL WHERE id = ?`).run(broken.taskId); + const ok = ['a', 'b'].map((title) => enqueue(companyId, goalId, { title })); + + const res = scheduler.tick(companyId); + // 批 3 的 `.slice(0, max - active)` 会把 broken 算进名额,于是只起来一条 + expect(res.started.map((s) => s.taskId)).toEqual(ok.map((t) => t.taskId)); + expect(wdb.getTask(broken.taskId).status).toBe('blocked'); + }); + }); + it('advanceOne 挡住 queued run:不推进、不报错、节点一个都不点亮', async () => { const companyId = nextCompany(); const { runId } = enqueue(companyId, newGoal(companyId), { title: 'a' }); diff --git a/server/tests/unit/task-slots-parallel.test.js b/server/tests/unit/task-slots-parallel.test.js new file mode 100644 index 0000000..046d22f --- /dev/null +++ b/server/tests/unit/task-slots-parallel.test.js @@ -0,0 +1,625 @@ +/** + * Unit Tests — 批 4 · 「一家公司同时只有一个 active root run」解除之后的执行位(27-P3C 批 4 / 27-P4 §一.3) + * + * 与既有三份的分工(都不重复它们已经钉住的形状): + * · `task-scheduler.test.js` —— 单个原语的形状(准入判据、CAS 的 WHERE、per-company 额度的回落) + * · `task-queue-acceptance.test.js` —— 端到端「队伍自己往前走」 + * · `task-queue-routes.test.js` —— HTTP 端点的字段形状 + * 这一份钉的是批 4 拆掉唯一索引之后**只剩一道守卫**的那几条性质,每一条都在「不成立时会静默」的位置上: + * ① max=1(缺省)时行为与批 3 逐字一致 —— 这是回归防线:绝大多数公司没写 max_parallel_tasks, + * 回落算错的话不是报错,而是「并行度悄悄变了」; + * ② max=3 时三条真的同时在跑、第四条排队、收敛一条自动补位(补位那一跳靠漏斗钩子,漏了就是永远排队); + * ③ **超发拦得住**。索引没了,`updateRunStatus` 的 BEGIN IMMEDIATE 是唯一的守卫,所以这里不满足于 + * 串行连打,还越过**进程边界**打一次:8 个独立进程越过同一个时间栅栏同时抢 3 个位。 + * 同进程内 better-sqlite3 是同步单写者,怎么打都串行 —— 那测不到这把锁存在的理由; + * ④ 坑满时人点「启动」拿到的是 409(不是 500)、点得出**全部**占位者,位子一空同一个请求就成功; + * ⑤ 配置非法(0 / 负数 / 字符串 / 缺失)一律回落 1 **并留声**,绝不回落成 0(永远不出队 = 静默全停) + * 或无限(超发); + * ⑥ DROP 迁移:带旧索引的存量库启动一次索引就没了,重复启动、多进程同时启动都不打架。 + * + * 隔离手法同 task-scheduler.test.js:**先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require** + * (graph-run-sse 在模块加载时快照 GRAPH_RUNS_DIR,反过来会往仓库 data/companies/ 写而且不报错)。 + * DB 路径与其它测试文件故意不同:vitest 默认按文件并行,共用一个 sqlite 文件会互相 DELETE 对方的行。 + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawn } from 'child_process'; +import express from 'express'; + +const TEST_DB_PATH = '/tmp/test-task-slots-parallel.sqlite'; +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; +const MODULES = [ + '../../services/graph-db', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/company-paths', + '../../services/graph-run-sse', + '../../services/graph-engine', + '../../services/task-settle', + '../../services/task-scheduler', + '../../routes/graph-run', +]; + +/** 存量库里那条索引的**原样** SQL(从批 4 的 diff 里逐字抄来,用于伪造「升级前的库」) */ +const LEGACY_INDEX_SQL = ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_runs_one_active_root + ON graph_runs(company_id) + WHERE parent_run_id IS NULL + AND run_status IN ('ready','running','paused') +`; +const INDEX_EXISTS_SQL = ` + SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_graph_runs_one_active_root' +`; + +/** + * 子进程脚本:本文件唯一能真正测到「跨进程」的手段。 + * 只依赖 work-graph-db 的公开 API(路径由 WGDB_MODULE 传进来),不碰任何私有函数。 + */ +const WORKER_SRC = ` +const wdb = require(process.env.WGDB_MODULE); +const [mode, ...args] = process.argv.slice(2); +const out = (o) => console.log(JSON.stringify(o)); +try { + if (mode === 'boot') { + // 「一个进程启动了一次」= 跑一遍 getDb()(建表 + 迁移块,DROP INDEX 就在里面) + const db = wdb.getDb(); + out({ index: !!db.prepare(${JSON.stringify(INDEX_EXISTS_SQL)}).get() }); + } else if (mode === 'seed') { + const [cid, n] = args; + const db = wdb.getDb(); + const now = new Date().toISOString(); + db.prepare( + "INSERT OR IGNORE INTO system_agents (id,name,scope,owner_company_id,source_kind,legacy_path,status,created_at,updated_at)" + + " VALUES ('solo','solo','public',NULL,'global_agent','system/agents/solo','active',?,?)" + ).run(now, now); + const g = wdb.createWorkGraph({ companyId: cid, nodes: ['solo'], edges: [] }); + if ((g.errors || []).length) throw new Error(JSON.stringify(g.errors)); + const runs = []; + for (let i = 0; i < Number(n); i += 1) { + runs.push(wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: cid, runStatus: 'queued' }).id); + } + out({ runs }); + } else if (mode === 'race') { + const [runId, startAt] = args; + // 忙等到同一个时间栅栏:sleep 的精度不够,而这里要的正是「同时」 + while (Date.now() < Number(startAt)) { /* spin */ } + out({ changes: wdb.updateRunStatus(runId, 'queued', 'ready') }); + } else if (mode === 'count') { + out({ active: wdb.countActiveRootRuns(args[0]), max: wdb.maxActiveRootRuns(args[0]) }); + } +} catch (err) { + out({ err: err.name, code: err.code, msg: err.message }); +} +`; + +describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { + let wdb; + let engine; + let sse; + let scheduler; + let runsDir; + let workerPath; + let server; + let origin; + /** 挂起的 executor promise,afterAll 统一放掉,免得留下永不 settle 的 driver */ + const pending = []; + + beforeAll(async () => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-slots-parallel-')); + process.env.GRAPH_RUNS_DIR = runsDir; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + + wdb = require('../../services/work-graph-db'); + sse = require('../../services/graph-run-sse'); + engine = require('../../services/graph-engine'); + scheduler = require('../../services/task-scheduler'); + + workerPath = path.join(runsDir, 'slot-worker.cjs'); + fs.writeFileSync(workerPath, WORKER_SRC); + + const app = express(); + app.use(express.json()); + app.use('/api/graph-runs', require('../../routes/graph-run')); + await new Promise((resolve) => { server = app.listen(0, resolve); }); + origin = `http://127.0.0.1:${server.address().port}`; + }); + + /** + * `advanceOne` 的 ensureProjectRoot 会在**仓库的** data/companies/ 下给每家公司建项目根 + * (它不认 GRAPH_RUNS_DIR)。只删本文件造的那些前缀目录,且必须确认里面没有 company.json + * —— 那是真公司,删了就是删用户数据。(写法与 task-scheduler.test.js 一致) + */ + function cleanupLeakedCompanyDirs() { + const root = path.resolve(__dirname, '../../../data/companies'); + if (!fs.existsSync(root)) return; + for (const cid of fs.readdirSync(root)) { + if (!cid.startsWith('slots-co-')) continue; + const dir = path.join(root, cid); + if (fs.existsSync(path.join(dir, 'company.json'))) continue; + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ } + } + } + + afterAll(async () => { + // 先放掉挂起的 executor 再等一拍:auto driver 恢复后还会跑一轮 advanceOne, + // 而 ensureProjectRoot 会把刚删掉的公司目录原样建回来 —— 顺序反了就等于没清理 + for (const p of pending) p(); + await new Promise((r) => setTimeout(r, 120)); + scheduler.stopSweeper(); + if (server) server.close(); + cleanupLeakedCompanyDirs(); + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } } + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* ignore */ } + delete process.env.GRAPH_RUNS_DIR; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + }); + + beforeEach(() => { + const db = wdb.getDb(); + db.pragma('foreign_keys = OFF'); + db.exec(` + DELETE FROM graph_run_loop_records; + DELETE FROM graph_run_nodes; + DELETE FROM graph_runs; + DELETE FROM tasks; + DELETE FROM goals; + DELETE FROM work_graph_edges; + DELETE FROM work_graph_nodes; + DELETE FROM work_graphs; + DELETE FROM system_agents; + `); + db.pragma('foreign_keys = ON'); + engine.setAgentExecutor(null); + }); + + // ── 夹具 ────────────────────────────────────────────────────── + let seq = 0; + /** 每个用例一家独立公司:执行位是按 company 数的 */ + const nextCompany = () => `slots-co-${++seq}`; + + function ensureAgents(ids) { + const now = new Date().toISOString(); + const ins = wdb.getDb().prepare(` + INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?) + `); + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); + } + + function buildGraph(companyId, nodes = ['solo']) { + ensureAgents(nodes); + const edges = nodes.slice(1).map((to, i) => ({ from: nodes[i], to })); + const g = wdb.createWorkGraph({ companyId, nodes, edges }); + expect(g.errors || []).toEqual([]); + return g.id; + } + + const newGoal = (companyId) => wdb.createGoal({ companyId, title: 'g', description: 'g' }).id; + + /** 入队:建 task 行 → 建 queued 的 run 行(不占位)→ task 标 queued + 挂 current_run_id */ + function enqueue(companyId, goalId, { title, mode = 'auto' } = {}) { + const task = wdb.createTask({ + goalId, companyId, title, description: `${title} 的描述`, executionMode: mode, + }); + const run = wdb.createRun({ + graphKind: 'work', + graphId: buildGraph(companyId), + companyId, + goalId, + taskId: task.id, + executionMode: mode, + runStatus: 'queued', + }); + wdb.updateTask(task.id, { status: 'queued', currentRunId: run.id, queuedAt: new Date().toISOString() }); + return { taskId: task.id, runId: run.id }; + } + + /** 永不 resolve 的 executor:让起跑的 run 稳稳停在 running,好观察执行位 */ + function hangingExecutor(onCall) { + engine.setAgentExecutor((ctx) => { + if (onCall) onCall(ctx); + return new Promise((resolve) => pending.push(() => resolve({ status: 'success' }))); + }); + } + + const flush = () => new Promise((r) => setImmediate(() => setImmediate(r))); + + /** 给这家公司写一份 company.json(走 company-paths,认 GRAPH_RUNS_DIR,绝不碰真 data/companies/) */ + function setMaxParallel(companyId, n) { + const { companyRootDir } = require('../../services/company-paths'); + const dir = companyRootDir(companyId); + fs.mkdirSync(dir, { recursive: true }); + const cfg = { id: companyId, name: companyId }; + if (n !== undefined) cfg.max_parallel_tasks = n; + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify(cfg)); + return companyId; + } + + const statuses = (ids) => ids.map((id) => wdb.getTask(id).status); + + /** 起一个子进程跑 worker,拿回 { code, json, stdout, stderr } */ + function runWorker(args, { dbPath = TEST_DB_PATH } = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [workerPath, ...args], { + env: { + ...process.env, + GRAPH_DB_PATH: dbPath, + GRAPH_RUNS_DIR: runsDir, + WGDB_MODULE: require.resolve('../../services/work-graph-db'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + child.on('close', (code) => { + let json = null; + try { json = JSON.parse(stdout.trim().split('\n').pop()); } catch { /* 留给断言去报 */ } + resolve({ code, json, stdout, stderr }); + }); + }); + } + + // ──────────────────────────────────────────────────────────────── + // ① max=1:默认配置下的行为必须与批 3 逐字一致 + // ──────────────────────────────────────────────────────────────── + describe('① max=1(缺省):批 4 之后一个字都不该变', () => { + it('没配 company.json 的公司:一次只放一条、其余照排、收敛后自动接力,全程 active ≤ 1', async () => { + const companyId = nextCompany(); + const goalId = newGoal(companyId); + hangingExecutor(); + // 「读不到配置 = 1」是保护全仓存量公司与存量单测的那条断言 + expect(wdb.maxActiveRootRuns(companyId)).toBe(1); + + const ids = ['a', 'b', 'c'].map((title) => enqueue(companyId, goalId, { title })); + const first = scheduler.tick(companyId); + expect(first.max).toBe(1); + expect(first.started.map((s) => s.taskId)).toEqual([ids[0].taskId]); + await flush(); + expect(statuses(ids.map((t) => t.taskId))).toEqual(['running', 'queued', 'queued']); + expect(wdb.countActiveRootRuns(companyId)).toBe(1); + + // 再 tick 一次绝不能多起一条(批 3 这里就是 no-slot) + expect(scheduler.tick(companyId).skipped).toBe('no-slot'); + expect(wdb.countActiveRootRuns(companyId)).toBe(1); + // 位次口径没变:跑着的那条不在队里,b 是第 1 + expect(scheduler.queuePosition(companyId, ids[1].taskId)).toBe(1); + expect(scheduler.queuePosition(companyId, ids[2].taskId)).toBe(2); + + // 收敛一条 → 漏斗钩子自己把下一条拉起来,**没有人再 tick 一次** + sse.transitionRun(ids[0].runId, 'running', 'completed', {}, { summary: '用例直推终态' }); + await flush(); + expect(statuses(ids.map((t) => t.taskId))).toEqual(['completed', 'running', 'queued']); + expect(wdb.countActiveRootRuns(companyId)).toBe(1); + + sse.transitionRun(ids[1].runId, 'running', 'completed', {}, { summary: '用例直推终态' }); + await flush(); + expect(statuses(ids.map((t) => t.taskId))).toEqual(['completed', 'completed', 'running']); + expect(wdb.countActiveRootRuns(companyId)).toBe(1); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // ② max=3:三条同时在跑 + // ──────────────────────────────────────────────────────────────── + describe('② max=3:三条无依赖的任务同时进入 running', () => { + it('前三条同时跑、第四条排队;收敛一条之后第四条自动补位(补位靠漏斗钩子,不靠人再点一次)', async () => { + const companyId = setMaxParallel(nextCompany(), 3); + const goalId = newGoal(companyId); + hangingExecutor(); + const ids = ['a', 'b', 'c', 'd'].map((title) => enqueue(companyId, goalId, { title })); + + const res = scheduler.tick(companyId); + expect(res.max).toBe(3); + expect(res.started.map((s) => s.taskId)).toEqual(ids.slice(0, 3).map((t) => t.taskId)); + await flush(); + + // 「同时在跑」要三条都验:任务行是 running,run 行在活跃集里,计数是 3 + expect(statuses(ids.map((t) => t.taskId))).toEqual(['running', 'running', 'running', 'queued']); + for (const t of ids.slice(0, 3)) { + expect(wdb.ACTIVE_ROOT_RUN_STATUS).toContain(wdb.getRun(t.runId).run_status); + } + expect(wdb.countActiveRootRuns(companyId)).toBe(3); + expect(wdb.getRun(ids[3].runId).run_status).toBe('queued'); // 第四条一个位都没占 + expect(scheduler.queuePosition(companyId, ids[3].taskId)).toBe(1); + expect(scheduler.tick(companyId).skipped).toBe('no-slot'); // 满了就是满了 + + // 收敛一条 → 第四条自己顶上来,且顶上来之后仍然是 3(不是 4) + sse.transitionRun(ids[0].runId, 'running', 'completed', {}, { summary: '用例直推终态' }); + await flush(); + expect(statuses(ids.map((t) => t.taskId))).toEqual(['completed', 'running', 'running', 'running']); + expect(wdb.countActiveRootRuns(companyId)).toBe(3); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // ③ 超发拦得住 —— 索引已 DROP,这一节是全批最重要的一条 + // ──────────────────────────────────────────────────────────────── + describe('③ 超发:唯一索引没了之后,活跃数永不超过 max', () => { + it('8 条抢 3 个位:恰好 3 条进得去,其余全是干净的拒绝(一个字都没写进去)', () => { + const companyId = setMaxParallel(nextCompany(), 3); + const goalId = newGoal(companyId); + const runs = Array.from({ length: 8 }, (_, i) => enqueue(companyId, goalId, { title: `t${i}` }).runId); + + const results = runs.map((runId) => { + try { + return { ok: wdb.updateRunStatus(runId, 'queued', 'ready') }; + } catch (err) { + return { err: err.name, active: err.active, max: err.max }; + } + }); + expect(results.filter((r) => r.ok === 1)).toHaveLength(3); + // 被拒的每一条都必须是 RunSlotBusyError(别的错 = 别的毛病,不许混过去), + // 而且拒绝当下报的 active 恒等于 max —— 报成别的数就说明计数和判据不同源 + for (const r of results.filter((x) => !x.ok)) { + expect(r).toEqual({ err: 'RunSlotBusyError', active: 3, max: 3 }); + } + expect(wdb.countActiveRootRuns(companyId)).toBe(3); + // 拒绝是干净的:没进去的那 5 条一条都没被改写 + expect(runs.filter((id) => wdb.getRun(id).run_status === 'queued')).toHaveLength(5); + }); + + it('出队过程中反手再 tick 一次(漏斗钩子的真实形状):活跃数一刻都没超过 max', async () => { + const companyId = setMaxParallel(nextCompany(), 3); + const goalId = newGoal(companyId); + const observed = []; + const nested = []; + // executor 在起跑的栈里被调用 —— 这正是「tick 起 run → run 转状态 → 钩子又 tick」那条回路 + hangingExecutor(() => { + observed.push(wdb.countActiveRootRuns(companyId)); + nested.push(scheduler.tick(companyId)); + }); + const ids = Array.from({ length: 8 }, (_, i) => enqueue(companyId, goalId, { title: `t${i}` })); + + scheduler.tick(companyId); + await flush(); + await flush(); + + expect(observed.length).toBeGreaterThan(0); + expect(Math.max(...observed)).toBeLessThanOrEqual(3); // 中途一刻都没超发 + expect(wdb.countActiveRootRuns(companyId)).toBe(3); + expect(statuses(ids.map((t) => t.taskId)).filter((s) => s === 'running')).toHaveLength(3); + // 嵌套那次要么被重入闸门挡回(同一次 tick 的栈里),要么老老实实报坑满 —— 都不许多起一条 + for (const r of nested) { + expect(['reentrant', 'no-slot', undefined]).toContain(r.skipped); + if (!r.skipped) expect(r.started).toEqual([]); + } + }); + + it('多进程同时抢:8 个独立进程越过同一个时间栅栏,活跃数仍然只有 3', async () => { + // 单独一个库文件:跨进程这一节要测的是 BEGIN IMMEDIATE 那把写锁, + // 与本文件其它用例共用一个库只会让计数互相污染。 + const dbPath = path.join(runsDir, 'mp-race.sqlite'); + const companyId = setMaxParallel(`slots-mp-${++seq}`, 3); + + const seeded = await runWorker(['seed', companyId, '8'], { dbPath }); + expect(seeded.code, seeded.stderr).toBe(0); + const runIds = seeded.json.runs; + expect(runIds).toHaveLength(8); + + // 同一个时间栅栏:子进程各自忙等到这一刻再打(起进程本身要几十毫秒,不留余量就变成串行) + const startAt = Date.now() + 1500; + const results = await Promise.all(runIds.map((runId) => runWorker(['race', runId, String(startAt)], { dbPath }))); + for (const r of results) expect(r.code, r.stderr).toBe(0); + + const admitted = results.filter((r) => r.json && r.json.changes === 1); + const refused = results.filter((r) => r.json && r.json.err); + expect(admitted).toHaveLength(3); + // 拒绝的一律是 RunSlotBusyError —— 出现 SQLITE_BUSY / UNIQUE 就说明守卫不是靠事务成立的 + expect(refused.map((r) => r.json.err)).toEqual(['RunSlotBusyError', 'RunSlotBusyError', + 'RunSlotBusyError', 'RunSlotBusyError', 'RunSlotBusyError']); + const counted = await runWorker(['count', companyId], { dbPath }); + expect(counted.json).toEqual({ active: 3, max: 3 }); + }, 60000); + + it('旧版本进程把唯一索引建回来时必须吵:SQLITE_CONSTRAINT_UNIQUE 不算「没抢到」', () => { + const companyId = setMaxParallel(nextCompany(), 3); + const goalId = newGoal(companyId); + const a = enqueue(companyId, goalId, { title: 'a' }); + const b = enqueue(companyId, goalId, { title: 'b' }); + const db = wdb.getDb(); + + db.exec(LEGACY_INDEX_SQL); // 伪造「旧版本进程启动了一次」 + try { + expect(wdb.updateRunStatus(a.runId, 'queued', 'ready')).toBe(1); + // max=3 明明还有位,索引却把第二条按 v1 的规矩拒了 —— 并行被悄悄压回 1 + let caught; + try { wdb.updateRunStatus(b.runId, 'queued', 'ready'); } catch (e) { caught = e; } + expect(caught).toBeDefined(); + expect(caught.code).toBe('SQLITE_CONSTRAINT_UNIQUE'); + // 关键:**不能**被当成正常竞争。当成竞争的话调度器 continue 掉, + // 这条任务永远排队、日志一行没有、界面与正常排队一模一样。 + expect(wdb.isSlotRaceError(caught)).toBe(false); + } finally { + db.exec('DROP INDEX IF EXISTS idx_graph_runs_one_active_root'); + } + // 索引一没,同一跳立刻过 —— 证明刚才那次拒绝确实是索引干的,不是额度不够 + expect(wdb.updateRunStatus(b.runId, 'queued', 'ready')).toBe(1); + expect(wdb.countActiveRootRuns(companyId)).toBe(2); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // ④ 恢复路径:坑满 → 409(不是 500)→ 位子一空同一个请求就成功 + // ──────────────────────────────────────────────────────────────── + describe('④ 位满时人点「启动」:409 且点名全部占位者,位子一空能回来', () => { + it('max=3 全占满 → 409 + holders 三条都在 + hint 指向每一条真停得掉的;腾一个位就 200', async () => { + const companyId = setMaxParallel(nextCompany(), 3); + const goalId = newGoal(companyId); + // 占位的三条:直推 running(用例不需要真跑 agent) + const holders = ['h1', 'h2', 'h3'].map((title) => { + const { taskId, runId } = enqueue(companyId, goalId, { title }); + wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'running' WHERE id = ?`).run(runId); + return { taskId, runId }; + }); + expect(wdb.countActiveRootRuns(companyId)).toBe(3); + + // 被挡的那条:**没有 task** 的已收敛 run(裸 run 退不回队列,错误才会一路抛到路由层) + const dead = wdb.createRun({ graphKind: 'work', graphId: buildGraph(companyId, ['solo2']), companyId }); + wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(dead.id); + const nodeId = wdb.getRunState(dead.id).nodes[0].id; + wdb.getDb().prepare(`UPDATE graph_run_nodes SET status = 'fail' WHERE id = ?`).run(nodeId); + + const url = `${origin}/api/graph-runs/${dead.id}/nodes/${nodeId}/retry`; + const r1 = await fetch(url, { method: 'POST' }); + const body = await r1.json(); + expect(r1.status).toBe(409); // 500 会让调用方去重试 / 报故障,而这里唯一有用的动作是等或停 + expect(body.reason).toBe('run-slot-busy'); + expect({ active: body.active, max: body.max }).toEqual({ active: 3, max: 3 }); + // 三条占位者一条都不能少:只点名一条的话,用户停掉它发现还是起不来 + expect(body.holders.map((h) => h.runId).sort()).toEqual(holders.map((h) => h.runId).sort()); + expect(body.holders.map((h) => h.taskId).sort()).toEqual(holders.map((h) => h.taskId).sort()); + for (const h of holders) expect(body.hint).toContain(`/api/graph-runs/${h.runId}/stop`); + // 拒绝是干净的:节点没被动过 + expect(wdb.getRunNode(nodeId).status).toBe('fail'); + expect(wdb.getRunNode(nodeId).attempt).toBe(0); + + // 腾出一个位 → 同一个请求就成功(这才叫「回得来」) + sse.transitionRun(holders[0].runId, 'running', 'completed', {}, { summary: '用例直推终态' }); + await flush(); + const r2 = await fetch(url, { method: 'POST' }); + const ok = await r2.json(); + expect(r2.status).toBe(200); + expect(ok.ok).toBe(true); + // 它真的占上了刚空出来的那个位(ready 还是已经被驱动推到 running 都算 —— 判据是活跃集, + // 不是某个具体状态:按具体状态断言的话,哪天驱动早半拍这条用例就会假红) + expect(wdb.ACTIVE_ROOT_RUN_STATUS).toContain(wdb.getRun(dead.id).run_status); + expect(wdb.countActiveRootRuns(companyId)).toBe(3); // 3 个位:走了一条、进来一条 + }); + }); + + // ──────────────────────────────────────────────────────────────── + // ⑤ 配置非法 → 回落 1 并留声 + // ──────────────────────────────────────────────────────────────── + describe('⑤ 配置非法:回落 1,绝不回落成 0(静默全停)或无限(超发)', () => { + it('0 / -1 / "abc" / true / 小数 / 缺字段 / 没文件 / 坏 JSON 一律 1,且每一次都真的打了日志', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + for (const bad of [0, -1, 'abc', true, 1.5, '3']) { + const cid = setMaxParallel(nextCompany(), bad); + warn.mockClear(); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + // 「配置写错了就当没配」必须是能看见的,否则就是一次静默降级 + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain(cid); + } + + // 坏掉的 JSON:不许把一次出队变成 500,但同样必须留声 + const broken = nextCompany(); + const { companyRootDir } = require('../../services/company-paths'); + fs.mkdirSync(companyRootDir(broken), { recursive: true }); + fs.writeFileSync(path.join(companyRootDir(broken), 'company.json'), '{ 这不是 JSON'); + warn.mockClear(); + expect(wdb.maxActiveRootRuns(broken)).toBe(1); + expect(warn).toHaveBeenCalledTimes(1); + + // 没文件 / 没这个字段:这是绝大多数公司的常态,不该刷日志 + warn.mockClear(); + expect(wdb.maxActiveRootRuns(nextCompany())).toBe(1); + expect(wdb.maxActiveRootRuns(setMaxParallel(nextCompany(), undefined))).toBe(1); + expect(warn).not.toHaveBeenCalled(); + + // 超上限截断到 CAP(不是回落 1,也不是当真)——「无限」在这里同样不许出现 + const huge = setMaxParallel(nextCompany(), 1e6); + warn.mockClear(); + expect(wdb.maxActiveRootRuns(huge)).toBe(wdb.MAX_ACTIVE_ROOT_RUNS_CAP); + expect(Number.isFinite(wdb.MAX_ACTIVE_ROOT_RUNS_CAP)).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + it('同一份非法配置只吵一次(日志不刷屏),但**回落值每次都算**,不是缓存下来的', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const cid = setMaxParallel(nextCompany(), 0); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + expect(warn).toHaveBeenCalledTimes(1); + + // 把配置改对:**不重启、不清缓存**,下一次准入就该按新值走(额度是调用时读盘,不是模块级快照) + setMaxParallel(cid, 2); + expect(wdb.maxActiveRootRuns(cid)).toBe(2); + const goalId = newGoal(cid); + const runs = ['a', 'b', 'c'].map((t) => enqueue(cid, goalId, { title: t }).runId); + expect(wdb.updateRunStatus(runs[0], 'queued', 'ready')).toBe(1); + expect(wdb.updateRunStatus(runs[1], 'queued', 'ready')).toBe(1); + expect(() => wdb.updateRunStatus(runs[2], 'queued', 'ready')).toThrow(wdb.RunSlotBusyError); + + // 再改回非法值:回落 1 立刻生效,坑满的判据跟着变(此刻 2 条在跑 > 1,新的一条必被拒) + setMaxParallel(cid, -5); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + let caught; + try { wdb.updateRunStatus(runs[2], 'queued', 'ready'); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(wdb.RunSlotBusyError); + expect(caught.max).toBe(1); + } finally { + warn.mockRestore(); + } + }); + }); + + // ──────────────────────────────────────────────────────────────── + // ⑥ DROP 迁移:存量库(带旧索引)启动一次就没了 + // ──────────────────────────────────────────────────────────────── + describe('⑥ 迁移:带 idx_graph_runs_one_active_root 的存量库', () => { + const Database = require('better-sqlite3'); + /** 直接开一个连接看索引在不在 —— 不经过 work-graph-db,免得「自己证明自己」 */ + function indexExists(dbPath) { + const db = new Database(dbPath, { readonly: true }); + try { return !!db.prepare(INDEX_EXISTS_SQL).get(); } finally { db.close(); } + } + function createLegacyIndex(dbPath) { + const db = new Database(dbPath); + try { db.exec(LEGACY_INDEX_SQL); } finally { db.close(); } + } + + it('启动一次索引就没了;再启动两次不报错(DROP INDEX IF EXISTS 本身幂等)', async () => { + const dbPath = path.join(runsDir, 'migrate-seq.sqlite'); + const first = await runWorker(['boot'], { dbPath }); + expect(first.code, first.stderr).toBe(0); + + // 伪造存量库:把批 4 删掉的那条索引原样建回去 + createLegacyIndex(dbPath); + expect(indexExists(dbPath)).toBe(true); + + const second = await runWorker(['boot'], { dbPath }); + expect(second.code, second.stderr).toBe(0); + expect(second.json).toEqual({ index: false }); // 进程自己看到的 + expect(indexExists(dbPath)).toBe(false); // 库里真的没了 + + for (let i = 0; i < 2; i += 1) { + const again = await runWorker(['boot'], { dbPath }); + expect(again.code, again.stderr).toBe(0); + expect(again.json).toEqual({ index: false }); + } + }, 30000); + + it('多进程同时启动不打架:5 个进程一起跑迁移,全部退出码 0,索引没了', async () => { + const dbPath = path.join(runsDir, 'migrate-parallel.sqlite'); + expect((await runWorker(['boot'], { dbPath })).code).toBe(0); + createLegacyIndex(dbPath); + expect(indexExists(dbPath)).toBe(true); + + const boots = await Promise.all(Array.from({ length: 5 }, () => runWorker(['boot'], { dbPath }))); + for (const b of boots) { + expect(b.code, b.stderr).toBe(0); // SQLITE_BUSY / "no such index" 都会在这里现形 + expect(b.json).toEqual({ index: false }); + } + expect(indexExists(dbPath)).toBe(false); + }, 30000); + + it('本文件这个库自己也没有那条索引(新建库走 CREATE TABLE 块,那里已经没有它了)', () => { + expect(wdb.getDb().prepare(INDEX_EXISTS_SQL).get()).toBeUndefined(); + }); + }); +}); diff --git a/web/src/components/graph-console/GoalTaskPane.vue b/web/src/components/graph-console/GoalTaskPane.vue index 36e7e9a..b0d6b77 100644 --- a/web/src/components/graph-console/GoalTaskPane.vue +++ b/web/src/components/graph-console/GoalTaskPane.vue @@ -13,6 +13,7 @@ @@ -150,6 +151,8 @@ const props = defineProps({ loading: { type: Boolean, default: false }, selectedTaskId: { type: String, default: '' }, selectedGoalId: { type: String, default: '' }, + /** 执行位 `{ max, used, free }`,服务端原样给的;没给就是 null(见 goal-task-model.readSlots) */ + slots: { type: Object, default: null }, }); const emit = defineEmits(['select', 'refresh']); diff --git a/web/src/components/graph-console/GraphRunPane.vue b/web/src/components/graph-console/GraphRunPane.vue index 74f778d..20c4dd6 100644 --- a/web/src/components/graph-console/GraphRunPane.vue +++ b/web/src/components/graph-console/GraphRunPane.vue @@ -32,6 +32,8 @@

{{ err }}

+ +

{{ notice }}

({})); - err.value = r.ok ? '' : (j.error || `HTTP ${r.status}`); + // ⚠️ 200 不等于「你要的事发生了」。坑满时「启动」会走 admitOrRequeue 把这条 run + // **退回队列**,返回的是 200 + { ok:true, requeued:true, runStatus:'queued' }。 + // 只判 r.ok 的话界面上一个字都不会说,而随后按钮全灰、run 面板换成定义视图 —— + // 用户点了「启动」,得到的是「什么都没说、图不见了」(批 3 复核 N2)。 + if (r.ok && j.requeued) { + err.value = ''; + notice.value = '执行位已满,这条任务已退回队列排队 —— 坑一空会自动接着跑,不用再点。'; + } else { + notice.value = ''; + err.value = r.ok ? '' : (j.error || `HTTP ${r.status}`); + } } catch (e) { err.value = e.message; } finally { @@ -339,6 +353,8 @@ function fmtNum(n) { return (n || 0).toLocaleString(); } .btn.retry { border-color:#c96a00; color:#c96a00; } .next-hint { font-size:11px; color:#666; } .next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; } +.notice { color: var(--text-secondary); background: var(--bg-overlay); border: 1px dashed var(--border-primary); + border-radius: 6px; padding: 6px 8px; font-size: 12px; line-height: 1.5; margin: 6px 0; } .err { color:#c00; font-size:12px; margin:0; background:#fff5f5; border-left:3px solid #c00; padding:5px 8px; } .compact .ctrl .btn { padding:3px 7px; font-size:11px; } diff --git a/web/src/components/graph-console/NewGoalForm.vue b/web/src/components/graph-console/NewGoalForm.vue index 2dbc594..df30045 100644 --- a/web/src/components/graph-console/NewGoalForm.vue +++ b/web/src/components/graph-console/NewGoalForm.vue @@ -159,6 +159,13 @@ const props = defineProps({ companyId: { type: String, required: true }, /** { running, queued, blocked } —— 由父组件从整棵目标树数出来(goal-task-model.queueStat) */ queueStat: { type: Object, default: () => ({ running: 0, queued: 0, blocked: 0 }) }, + /** + * 执行位 `{ max, used, free }` —— **服务端原样给的**(形状逐字同 + * `server/routes/graph-run.js#slotState`,409 `run-slot-busy` 也从它取),前端一个数都不推导。 + * 服务端没给(今天就是这样)时传 null,文案自动退回批 3 原样。 + * 判据与退化分支都钉在 goal-task-model.test.js 的 readSlots / queueNoteText 两组用例里。 + */ + slots: { type: Object, default: null }, }); /** * changed —— 库里的 goal/task 变了,父组件重拉整棵树(建 goal / 改 task / 删 task 都发)。 @@ -175,7 +182,7 @@ const ADD_ROW_DISABLED = * 和「正在提交」):批 3 之后下达就是入队,公司里有任务在跑不再是拒绝的理由, * 把按钮灰掉等于把一条现在走得通的路继续锁着。文案是纯函数,测试在 goal-task-model.test.js。 */ -const queueNote = computed(() => queueNoteText(props.queueStat)); +const queueNote = computed(() => queueNoteText(props.queueStat, props.slots)); /** closed → step1 →(split? splitting : —)→ step2。creating 是 step1 的提交中态 */ const phase = ref('closed'); diff --git a/web/src/components/graph-console/goal-task-model.js b/web/src/components/graph-console/goal-task-model.js index 0cb0b18..c26fba9 100644 --- a/web/src/components/graph-console/goal-task-model.js +++ b/web/src/components/graph-console/goal-task-model.js @@ -228,6 +228,39 @@ export function taskActions(task) { return acts; } +// ──────────────────────────────────────────────────────────────────────────── +// 执行位(批 4:一家公司可以同时跑 max_parallel_tasks 条 root run) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * 服务端给的「执行位」快照 → `{ max, used, free }`,给不出就是 `null`。 + * + * **形状逐字沿用服务端唯一那一份**:`server/routes/graph-run.js#slotState` 返回 + * `{ max, used, free, holders }`(409 `run-slot-busy` 的 body 与 `routes/company.js` + * 的 409 都从它取)。这里连 `free` 都**读**服务端的、不自己 `max - used` —— + * 一做减法就是第二个持有者,而 `slotState` 那边的 free 还带着「max 刚改小」的截断语义。 + * + * 校验从严、给不出就返回 null:`max` 是 per-company 配置(`company.json.max_parallel_tasks`), + * 服务端没吐、吐了个字符串,都不能让界面凭空说出一个「3 个执行位」来。 + * null 时所有文案退回**批 3 的原样**(只说在跑几个、不提总数),少说一句好过说错一句。 + * + * ⚠️ **今天还没有任何端点把它吐给前端**:`slotState` 只喂 409 的 body, + * `GET /:cid/goals` 与 `GET /:cid/tasks` 都还没带 `slots`。所以现在走的恒是 null 那一支 + * = 与批 3 完全一致。服务端补的时候直接把 `slotState(companyId)` 的返回值挂上去即可 + * (holders 可留可去,这里不读),别在那条路上写第二份取值域。 + */ +export function readSlots(raw) { + const s = raw || {}; + // **只认真正的 number**,不走 Number() 转换:`Number(null)` / `Number('')` 都是 0, + // 于是一个字段缺失的响应会被读成「0 个执行位在用」并原样显示出去 —— 那正是本函数要挡的 + // 「凭空说出一个数」。字符串数字同理:服务端吐 '3' 说明那一头的形状已经不对了, + // 该让文案退回批 3 原样并被人看见,而不是替它纠正。 + const { max, used, free } = s; + if (![max, used, free].every(Number.isInteger)) return null; + if (max < 1 || used < 0 || free < 0) return null; + return { max, used, free }; +} + /** * 全公司在途统计,喂「新目标」表单那句提示。 * 只数后端给的 status,不推断位次 —— 一条**还没建出来**的任务排第几,谁都算不准 @@ -259,19 +292,37 @@ export function queueStat(goals = []) { * * @returns {string} 队里空空如也时返回 ''(没有队伍就没什么好说的) */ -export function queueNoteText(stat) { +export function queueNoteText(stat, rawSlots) { const s = stat || {}; const running = Number(s.running) || 0; const queued = Number(s.queued) || 0; const blocked = Number(s.blocked) || 0; - if (!running && !queued && !blocked) return ''; + const slots = readSlots(rawSlots); + if (!running && !queued && !blocked && !(slots && slots.used)) return ''; const parts = []; - if (running) parts.push(`${running} 个任务在跑`); + // 有 slots 就报「几分之几个执行位」——「1 个任务在跑」在 3 坑的公司里读起来像「满了」, + // 而它其实还空着 2 个,这是批 4 之后这句话唯一会骗人的地方。 + // + // **两个数各归各的来源,不互相换算**:执行位用服务端的 slots.used(数的是活跃 root run, + // 含 ready/paused),在跑几条任务用 stat.running(数的是 task 行)。两者绝大多数时候相等, + // 但不是同一件事 —— 拿一个去顶另一个就是又一次「前端自己推导后端的取值域」。 + if (slots) parts.push(`${slots.used}/${slots.max} 个执行位在用`); + else if (running) parts.push(`${running} 个任务在跑`); + // 两个数对不上时**两个都报**:藏掉一个就等于替服务端断言它们该相等 + if (slots && running && running !== slots.used) parts.push(`${running} 个任务在跑`); if (queued) parts.push(`${queued} 个在排队`); if (blocked) parts.push(`${blocked} 个卡在上游`); - return `当前${parts.join('、')}。「全部下达」照常可点:本次的任务会进同一个队列,` - + '排在它们后面,坑一空就自动开跑 —— 确切位次由服务端算,下达后显示在左栏每一行上。'; + + // 尾巴分两支:知道还有几个空位时就说「有空位」(这才是用户此刻真正在问的「我要等吗」), + // 不知道就退回批 3 原样。**不预测本次会不会立刻开跑**:那取决于 priority / 依赖 / 执行模式, + // 三样都只有服务端算得准,写死一句「马上开跑」就是编一个承诺。 + const tail = slots && slots.free > 0 + ? `还空着 ${slots.free} 个执行位。「全部下达」照常可点:本次的任务会进同一个队列,` + + '有空位就会被调度器直接拉起来 —— 确切位次由服务端算,下达后显示在左栏每一行上。' + : '「全部下达」照常可点:本次的任务会进同一个队列,' + + '排在它们后面,坑一空就自动开跑 —— 确切位次由服务端算,下达后显示在左栏每一行上。'; + return `当前${parts.join('、')}。${tail}`; } /** @@ -306,13 +357,42 @@ export function dispatchedTaskId(d) { /** * 选中一条还没起过 run 的 task 时,org 栏顶上那句话。 * 排队中的那条要说清「前面还有几个」—— 这是「为什么点了下达却什么都没发生」的唯一答案。 + * + * 批 4 之后位次单独一个数**答不了「我还要等吗」**:3 个执行位的公司里排第 3 位是「马上就跑」, + * 1 个执行位的公司里排第 2 位就是「要等一整轮」。所以 slots 给得出来时把空位一起说了。 + * `slots` 给不出(服务端还没吐这个字段)就退回批 3 原样 —— 见 readSlots 上那段。 + * + * **manual 的排队任务单独一句**:调度器只出队 `execution_mode==='auto'` 的 + * (`server/services/task-scheduler.js#tickInner`,§五.2bis 明写它故意不碰 manual), + * 所以对一条 manual 任务说「有空位就自动开跑」是纯粹的谎 —— 它会一直排在那里, + * 直到有人点 ⋯ 菜单里的「立即开始」。这一条与并行无关,是批 3 就在的,批 4 顺手补上。 */ -export function pendingRunHint(task) { +export function pendingRunHint(task, rawSlots) { const t = task || {}; const tail = '下面是 org graph 定义本身,节点状态一律 pending。'; if (t.status !== 'queued') return `还没有起过 run(${taskStyle(t.status).label}),${tail}`; + + const slots = readSlots(rawSlots); + const free = slots ? slots.free : null; + // 手动模式:空位有多少都不会让它自己动起来,先把这件事说清楚 + if (t.mode === 'manual') { + return '排队中。这条是「手动」模式,调度器不会自动拉起它' + + `(它只出队自动模式的任务${free ? `,${free} 个执行位空着也一样` : ''});` + + `用任务行的 ⋯ 菜单「立即开始」抢一个执行位,${tail}`; + } + const n = Number(t.queuePosition); if (!Number.isInteger(n) || n <= 0) return `排队中(服务端没给位次),${tail}`; + if (slots && n <= free) { + return `排在第 ${n} 位,${slots.used}/${slots.max} 个执行位在用、还空着 ${free} 个 ——` + + ` 下一轮调度就会被拉起来(最迟 60s 的兜底扫描),${tail}`; + } + if (slots) { + // 「还要等几个」= 还差几个执行位腾出来(n - free),不是「前面还有几个任务」(n-1): + // 空位会先被前 free 条占掉,剩下的每腾出一个坑才轮下去一位。free=0 时这句就是「等 n 个」 + return `排在第 ${n} 位,${slots.used}/${slots.max} 个执行位在用、没有空位 ——` + + ` 还要等 ${n - free} 个执行位腾出来才轮到(按服务端的队列口径),${tail}`; + } if (n === 1) return `排在队首,前面的任务一收敛就自动开跑,${tail}`; return `排队中,前面还有 ${n - 1} 个任务,${tail}`; } @@ -376,6 +456,12 @@ export function dependsLabel(task, siblings = []) { * 默认选中:最近一条 running 的 task,没有就最近创建的 task。 * 「最近」按 createdAt 倒序;goals 列表本身已经是 created_at DESC,但不能依赖调用方的顺序 —— * 轮询回来的数组顺序变了就会让选中态莫名其妙跳一次。 + * + * 批 4(一家公司可以同时跑多条)之后 running 会有 N 条,这里挑「最新那条」。 + * **不会因此抖动**:排序只看 createdAt,与谁先跑完无关;而且调用方只在选中态失效时才问一次 + * (`GraphConsole.ensureSelection`),选中之后 20s 轮询不会重挑。 + * 上限:N 条 createdAt 完全相同时顺序由 localeCompare 的稳定性决定 —— 那种并列本身就没有 + * 「更该选哪条」的答案,不值得为它引入第二个排序键。 */ export function pickDefaultSelection(goals = []) { const all = []; diff --git a/web/src/views/GraphConsole.vue b/web/src/views/GraphConsole.vue index e262129..6cbb263 100644 --- a/web/src/views/GraphConsole.vue +++ b/web/src/views/GraphConsole.vue @@ -49,6 +49,7 @@ :loading="goalsLoading" :selected-task-id="selectedTaskId" :selected-goal-id="selectedGoalId" + :slots="slots" @select="selectTask" @refresh="reloadTree" /> @@ -80,7 +81,7 @@ 跟左栏的中文标签是两套说法 -->