Commit 58a28650bdcd45563c2614a08f35cbd4cee9b3b5
1 parent
69d45521
fix(p3b): 修三条挡着真跑的缺陷——idle 超时、W7 零边图、下达按钮显隐
测试 788/788 全绿(76 文件,起点 786),未改任何既有断言。 ## 1. idleTimeoutMs 硬编码 120s(「work 节点 → success」跑不通的唯一技术原因) agent-manager.js 里 `this.config?...?.idleTimeout || 120_000`,且 `...options` 摊在前面被下一行覆盖,两个后果叠加: - 默认 120 秒,比 stream-executor 自己 JSDoc 写的 30 分钟(1_800_000)小一个数量级, 也和同进程 ClaudeCliProtocol 早已放到 24h 的 idle 上限完全对不上 - 调用方**无法覆写** —— 传了 options.idleTimeoutMs 也会被盖掉 真 CLI 下的后果:任何「先读几千行源码再动笔」的节点都在动笔前被杀 (readme-writer: idle timeout: no stream data for 120000ms)。 改成 调用方 > agent 配置 > DEFAULT_IDLE_TIMEOUT_MS(30min),并把常量真的定义出来 —— 上一轮 Master agent 自己诊断出这个问题也动手改了,但引用了未定义的 DEFAULT_IDLE_TIMEOUT_MS,跑起来是 ReferenceError。 ## 2. W7 判据在零边最小图上失效 判「org graph 成型了没有」原先看**边数**,理由是「节点 hire() 自己就会造, 拿节点数当信号会废掉『连雇两个人第二个不入图』」——理由成立,但边只是 「writeGraph 跑过」的代理,而真跑里 orggraph-planning 裁出过一张**零条边的单节点图**, 代理当场失效:三条判据全落空 → work 层本体被写进 org_graph_nodes, 下一次下达任务时被 advanceOne 当成 org 节点执行。 改成记录**来源**而不是猜:org_graph_nodes 加 authored_by(idempotent ALTER), writeGraph 写 'planner'、hire 写 'hire',判据看有没有 planner 写下的节点。 hire 先记的行随后被 planner 纳入时升格,反向不降级。 存量行 authored_by 为 NULL,回落到原来的边数代理,行为不变。 新增两条用例(既有 9 例造的全是有边的成型图,正好绕开这个形状), 并做过变异验证:把判据改回纯边数,两条精确变红,还原后逐字一致。 ## 3. 前后端「能不能下达任务」的规则各写一份且不一致 前端 ACTIVE_RUN 含 blocked、后端 ACTIVE_ROOT_RUN_STATUS 不含(唯一索引 idx_graph_runs_one_active_root 里也没有)。后果:一家公司只要失败过一次, 控制台上就再也下不了第二个任务,而后端其实放行。 规则只留服务端一处:GET /graph-runs 返回 canDispatch / activeRootRunId, 前端照抄结果,不再自己复述取值域。字段缺失时保守放行,交给端点 409 兜底。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
7 changed files
with
134 additions
and
16 deletions
server/routes/graph-run.js
| ... | ... | @@ -275,11 +275,26 @@ router.get('/:id/stream', (req, res) => { |
| 275 | 275 | * query: rootOnly=1 只列 root run(v1 一家公司同时只有一个 active root run,见 §一非目标) |
| 276 | 276 | * 公司无 run 时返回空数组,不报错(与 GET /api/company/:cid/org-graph 的处理一致)。 |
| 277 | 277 | */ |
| 278 | +/** | |
| 279 | + * 「同一 company 只允许一个 active root run」的取值域,与 | |
| 280 | + * work-graph-db 的 idx_graph_runs_one_active_root 逐字一致 | |
| 281 | + * (`WHERE parent_run_id IS NULL AND run_status IN ('ready','running','paused')`)。 | |
| 282 | + * **注意 blocked 不在里面** —— blocked 的 run 不占坑,可以再下一个任务。 | |
| 283 | + */ | |
| 284 | +const ACTIVE_ROOT_RUN_STATUS = new Set(['ready', 'running', 'paused']); | |
| 285 | + | |
| 278 | 286 | companyRouter.get('/:companyId/graph-runs', h((req, res) => { |
| 279 | 287 | const rootOnly = req.query.rootOnly === '1' || req.query.rootOnly === 'true'; |
| 288 | + const runs = listCompanyRuns(req.params.companyId, { rootOnly }); | |
| 289 | + // canDispatch 由**服务端**算:前端此前自己复述了一份取值域并且多写了一个 blocked, | |
| 290 | + // 于是一家公司只要失败过一次,控制台上就再也下不了第二个任务(后端其实放行)。 | |
| 291 | + // 规则只留一处,前端照抄结果即可(P3B 第三次真跑发现)。 | |
| 292 | + const activeRoot = runs.find((r) => !r.parent_run_id && ACTIVE_ROOT_RUN_STATUS.has(r.run_status)); | |
| 280 | 293 | res.json({ |
| 281 | 294 | companyId: req.params.companyId, |
| 282 | - runs: listCompanyRuns(req.params.companyId, { rootOnly }), | |
| 295 | + runs, | |
| 296 | + canDispatch: !activeRoot, | |
| 297 | + activeRootRunId: activeRoot ? activeRoot.id : null, | |
| 283 | 298 | }); |
| 284 | 299 | })); |
| 285 | 300 | ... | ... |
server/services/graph-db.js
| ... | ... | @@ -77,6 +77,20 @@ function migrate(database) { |
| 77 | 77 | database.exec(`ALTER TABLE org_graph_edges ADD COLUMN fail_reason TEXT`); |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | + // 4b. P3B W7-fix:节点的来源。 | |
| 81 | + // 判「这家公司的 org graph 成型了没有」原先看的是**边数**,理由是 | |
| 82 | + // 「节点 hire() 自己就会造,拿节点当信号会废掉『连雇两个人第二个不入图』」。 | |
| 83 | + // 但真跑里 orggraph-planning 裁出过一张**零条边的单节点图**,边数代理当场失效: | |
| 84 | + // 三条判据全落空 → workgraph-planning 现雇的 work 层本体被写进 org_graph_nodes, | |
| 85 | + // 下一次下达任务时它会被 advanceOne 当成 org 节点执行。 | |
| 86 | + // 所以把「谁写的」显式记下来,不再靠边数猜。 | |
| 87 | + // 'planner' = org-graph-service.writeGraph(规划 agent 提交的整张图) | |
| 88 | + // 'hire' = company-service.hire 顺手记的一行 | |
| 89 | + // NULL = 本列之前的存量行,判定时回落到边数代理,存量行为不变 | |
| 90 | + if (!hasColumn(database, 'org_graph_nodes', 'authored_by')) { | |
| 91 | + database.exec(`ALTER TABLE org_graph_nodes ADD COLUMN authored_by TEXT`); | |
| 92 | + } | |
| 93 | + | |
| 80 | 94 | // 5. P3 R3-F2:去重键补上 fail_reason,否则同一对 from/to 上的「精确 fail 边 + 兜底边」 |
| 81 | 95 | // 只能活一条(insertEdge 用 ON CONFLICT DO NOTHING,被吞的那条不报错)。 |
| 82 | 96 | // | ... | ... |
server/services/graph-service.js
| ... | ... | @@ -94,10 +94,17 @@ function ensureSystemAgentRow(companyId, systemAgentId, roleName) { |
| 94 | 94 | /** |
| 95 | 95 | * 这家公司的 org graph 有没有成型,以及某个角色在不在图上(P3B §四 W7 的判据之一)。 |
| 96 | 96 | * |
| 97 | - * `hasWorkflow` 看的是**边**不是节点:节点 hire() 自己就会造(P1/P2 语义),拿节点当 | |
| 98 | - * 「图已存在」的信号会把「同一家公司连雇两个人,第二个不入图」这条既有语义废掉。 | |
| 99 | - * 有 active 边则说明 orggraph-planning 提交过一张连起来的图(POST /org-graph → | |
| 100 | - * org-graph-service.writeGraph 是唯一写边的地方),此后这张图的作者就是它, | |
| 97 | + * `hasWorkflow` 判的是「orggraph-planning 提交过一张图没有」,而**不是**「有没有节点」—— | |
| 98 | + * 节点 hire() 自己就会造(P1/P2 语义),拿节点数当信号会把「同一家公司连雇两个人, | |
| 99 | + * 第二个不入图」这条既有语义废掉。 | |
| 100 | + * | |
| 101 | + * 主判据是 `authored_by='planner'` 的节点(writeGraph 是唯一写它的地方)。 | |
| 102 | + * 早先用的是**边数**代理,而真跑里 orggraph-planning 裁出过一张**零条边的单节点图**, | |
| 103 | + * 代理当场失效:三条判据全落空 → work 层本体被写进 org_graph_nodes, | |
| 104 | + * 下一次下达任务时被 advanceOne 当 org 节点执行(P3B W7-fix)。 | |
| 105 | + * 边数保留为存量行(authored_by IS NULL)的回落判据。 | |
| 106 | + * | |
| 107 | + * 此后这张图的作者就是 orggraph-planning, | |
| 101 | 108 | * hire() 不再往上追加节点:`work-graph-db.js:473` 起 org run 时读的正是 org_graph_nodes |
| 102 | 109 | * 的 active 行,混进去的 work 层本体不是画歪一张图,而是真的多出一个会被执行的 org 节点。 |
| 103 | 110 | * |
| ... | ... | @@ -107,6 +114,12 @@ function ensureSystemAgentRow(companyId, systemAgentId, roleName) { |
| 107 | 114 | */ |
| 108 | 115 | function orgGraphMembership(companyId, roleName) { |
| 109 | 116 | const db = getDb(); |
| 117 | + // 主判据:有没有 planner 写下的节点。这是「orggraph-planning 提交过一张图」的**直接**证据。 | |
| 118 | + const planned = db.prepare( | |
| 119 | + `SELECT COUNT(*) AS c FROM org_graph_nodes | |
| 120 | + WHERE company_id = ? AND status = 'active' AND authored_by = 'planner'` | |
| 121 | + ).get(companyId); | |
| 122 | + // 回落判据:本列之前的存量行 authored_by 是 NULL,对它们仍用原来的边数代理,行为不变。 | |
| 110 | 123 | const edges = db.prepare( |
| 111 | 124 | `SELECT COUNT(*) AS c FROM org_graph_edges WHERE company_id = ? AND status = 'active'` |
| 112 | 125 | ).get(companyId); |
| ... | ... | @@ -114,7 +127,8 @@ function orgGraphMembership(companyId, roleName) { |
| 114 | 127 | `SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'` |
| 115 | 128 | ).get(companyId, roleName); |
| 116 | 129 | |
| 117 | - return { hasWorkflow: ((edges && edges.c) || 0) > 0, isNode: ((node && node.c) || 0) > 0 }; | |
| 130 | + const hasWorkflow = ((planned && planned.c) || 0) > 0 || ((edges && edges.c) || 0) > 0; | |
| 131 | + return { hasWorkflow, isNode: ((node && node.c) || 0) > 0 }; | |
| 118 | 132 | } |
| 119 | 133 | |
| 120 | 134 | /** |
| ... | ... | @@ -136,7 +150,7 @@ function orgGraphMembership(companyId, roleName) { |
| 136 | 150 | * @param {string} roleName |
| 137 | 151 | * @returns {void} |
| 138 | 152 | */ |
| 139 | -function upsertOrgGraphNode(companyId, systemAgentId, roleName) { | |
| 153 | +function upsertOrgGraphNode(companyId, systemAgentId, roleName, authoredBy = 'hire') { | |
| 140 | 154 | const db = getDb(); |
| 141 | 155 | ensureOrgGraphNodeIndex(db); |
| 142 | 156 | |
| ... | ... | @@ -146,15 +160,26 @@ function upsertOrgGraphNode(companyId, systemAgentId, roleName) { |
| 146 | 160 | ensureSystemAgentRow(companyId, systemAgentId, roleName); |
| 147 | 161 | |
| 148 | 162 | db.prepare(` |
| 149 | - INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at) | |
| 150 | - VALUES (@company_id, @system_agent_id, @role_name, 'active', @created_at) | |
| 163 | + INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at, authored_by) | |
| 164 | + VALUES (@company_id, @system_agent_id, @role_name, 'active', @created_at, @authored_by) | |
| 151 | 165 | ON CONFLICT(company_id, role_name) WHERE status = 'active' DO NOTHING |
| 152 | 166 | `).run({ |
| 153 | 167 | company_id: companyId, |
| 154 | 168 | system_agent_id: systemAgentId, |
| 155 | 169 | role_name: roleName, |
| 156 | 170 | created_at: now, |
| 171 | + authored_by: authoredBy, | |
| 157 | 172 | }); |
| 173 | + | |
| 174 | + // 冲突被 DO NOTHING 吞掉时行还在,但来源可能需要升格: | |
| 175 | + // hire 先记了一行(authored_by='hire'),随后 orggraph-planning 提交整张图把它纳入, | |
| 176 | + // 这行就该算 planner 写的。反向**不升格** —— planner 写过的图不会因为后续 hire 降级。 | |
| 177 | + if (authoredBy === 'planner') { | |
| 178 | + db.prepare(` | |
| 179 | + UPDATE org_graph_nodes SET authored_by = 'planner' | |
| 180 | + WHERE company_id = ? AND role_name = ? AND status = 'active' | |
| 181 | + `).run(companyId, roleName); | |
| 182 | + } | |
| 158 | 183 | }); |
| 159 | 184 | |
| 160 | 185 | tx(); | ... | ... |
server/services/org-graph-service.js
| ... | ... | @@ -273,7 +273,10 @@ function insertNode(db, companyId, node) { |
| 273 | 273 | .get(companyId, node.roleName); |
| 274 | 274 | |
| 275 | 275 | const systemAgentId = node.systemAgentId || (known && known.system_agent_id) || node.roleName; |
| 276 | - upsertOrgGraphNode(companyId, systemAgentId, node.roleName); | |
| 276 | + // 标 'planner':这一行是规划 agent 提交整张图写下的,不是 hire 顺手记的。 | |
| 277 | + // decideOrgNodeRecording 靠它判「这家公司的 org graph 成型了没有」—— | |
| 278 | + // 早先用边数代理,被一张零条边的单节点图打穿(P3B W7-fix)。 | |
| 279 | + upsertOrgGraphNode(companyId, systemAgentId, node.roleName, 'planner'); | |
| 277 | 280 | |
| 278 | 281 | const row = db |
| 279 | 282 | .prepare( | ... | ... |
server/src/core/agent-manager.js
| ... | ... | @@ -28,6 +28,13 @@ const { |
| 28 | 28 | FailoverError, |
| 29 | 29 | } = require('../engine'); |
| 30 | 30 | |
| 31 | +/** | |
| 32 | + * 无流式数据的空闲上限,与 stream-executor.runPromptInSession 的 JSDoc 契约一致(30 分钟)。 | |
| 33 | + * 真正的超时管理由上层心跳监控负责,这里只是兜底 —— 一个 agent 读几千行源码再动笔, | |
| 34 | + * 中间静默十几分钟是正常的,不该被当成卡死。 | |
| 35 | + */ | |
| 36 | +const DEFAULT_IDLE_TIMEOUT_MS = 1_800_000; | |
| 37 | + | |
| 31 | 38 | class AgentManager extends EventEmitter { |
| 32 | 39 | constructor(config, workDir, options = {}) { |
| 33 | 40 | super(); |
| ... | ... | @@ -321,7 +328,23 @@ class AgentManager extends EventEmitter { |
| 321 | 328 | try { |
| 322 | 329 | this.sessionPool.activeKey = session.key; |
| 323 | 330 | |
| 324 | - const idleTimeoutMs = this.config?.agents?.[agentName]?.idleTimeout || 120_000; | |
| 331 | + // 优先级:调用方显式传入 > 该 agent 的配置 > 默认值。 | |
| 332 | + // | |
| 333 | + // 修之前这里是 `this.config?...?.idleTimeout || 120_000`,且 `...options` 摊在前面、 | |
| 334 | + // 被下一行的 idleTimeoutMs 覆盖 —— 两个后果叠在一起: | |
| 335 | + // ① 默认 120 秒,比 stream-executor 自己 JSDoc 写的 30 分钟(1_800_000)小一个数量级, | |
| 336 | + // 也和同进程 ClaudeCliProtocol 早已放到 24h 的 idle 上限完全对不上; | |
| 337 | + // ② 调用方**无法覆写** —— 传了 options.idleTimeoutMs 也会被这一行盖掉。 | |
| 338 | + // 真 CLI 下的实际后果:任何「先读几千行源码再动笔」的节点都在动笔前被杀 | |
| 339 | + // (readme-writer 就是这么死的:idle timeout: no stream data for 120000ms), | |
| 340 | + // 这是 27-P3B 第三次真跑「work 节点 → success」跑不通的唯一技术原因。 | |
| 341 | + // | |
| 342 | + // 「按静默时长杀 agent」在本仓库已经被判过一次死刑(ClaudeCliProtocol 那两个 | |
| 343 | + // IDLE_TIMEOUT 常量的注释写着「已取消」),这里是漏网的一处。默认值对齐 | |
| 344 | + // stream-executor 的契约,真正的超时管理交给上层心跳监控。 | |
| 345 | + const idleTimeoutMs = options.idleTimeoutMs | |
| 346 | + || this.config?.agents?.[agentName]?.idleTimeout | |
| 347 | + || DEFAULT_IDLE_TIMEOUT_MS; | |
| 325 | 348 | const stdout = await runPromptInSession(session, fullPrompt, { |
| 326 | 349 | ...options, logSession, idleTimeoutMs, |
| 327 | 350 | }); | ... | ... |
server/tests/unit/company-hire-org-layer.test.js
| ... | ... | @@ -36,6 +36,7 @@ describe('hire() 的 org / work 分层(27-P3B W7)', () => { |
| 36 | 36 | |
| 37 | 37 | const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; |
| 38 | 38 | const CO_PLANNED = `tmp-w7-planned-${RUN_ID}`; // orggraph-planning 已提交过成型的图 |
| 39 | + const CO_ZEROEDGE = `tmp-w7-zeroedge-${RUN_ID}`; // 真跑里 orggraph-planning 裁出过的**零条边单节点图** | |
| 39 | 40 | const CO_BLANK = `tmp-w7-blank-${RUN_ID}`; // 从没有过图(P1/P2 的老场景) |
| 40 | 41 | const CO_BOOTSTRAP = `tmp-w7-bootstrap-${RUN_ID}`; // 刚建的公司,也没有图 |
| 41 | 42 | |
| ... | ... | @@ -93,7 +94,7 @@ describe('hire() 的 org / work 分层(27-P3B W7)', () => { |
| 93 | 94 | `).run(id, id, `system/templates/${id}`, now, now); |
| 94 | 95 | } |
| 95 | 96 | |
| 96 | - for (const [id, name] of [[CO_PLANNED, 'W7 已有组织图'], [CO_BLANK, 'W7 空图公司'], [CO_BOOTSTRAP, 'W7 新建公司']]) { | |
| 97 | + for (const [id, name] of [[CO_PLANNED, 'W7 已有组织图'], [CO_BLANK, 'W7 空图公司'], [CO_BOOTSTRAP, 'W7 新建公司'], [CO_ZEROEDGE, 'W7 零边图公司']]) { | |
| 97 | 98 | companyService.create({ id, name, description: 'W7 分层测试' }); |
| 98 | 99 | } |
| 99 | 100 | |
| ... | ... | @@ -102,10 +103,17 @@ describe('hire() 的 org / work 分层(27-P3B W7)', () => { |
| 102 | 103 | expect(written.ok).toBe(true); |
| 103 | 104 | expect(written.nodeCount).toBe(3); |
| 104 | 105 | expect(written.edgeCount).toBe(3); |
| 106 | + | |
| 107 | + // 零边单节点图 —— 这个形状是真跑里 orggraph-planning 自己裁出来的, | |
| 108 | + // 而上面那 9 例造的全是**有边**的成型图,正好绕开它(P3B 第三次真跑发现)。 | |
| 109 | + const zero = orgGraphService.writeGraph(CO_ZEROEDGE, { nodes: ['org-writer'], edges: [] }); | |
| 110 | + expect(zero.ok).toBe(true); | |
| 111 | + expect(zero.nodeCount).toBe(1); | |
| 112 | + expect(zero.edgeCount).toBe(0); | |
| 105 | 113 | }); |
| 106 | 114 | |
| 107 | 115 | afterAll(() => { |
| 108 | - for (const id of [CO_PLANNED, CO_BLANK, CO_BOOTSTRAP]) { | |
| 116 | + for (const id of [CO_PLANNED, CO_BLANK, CO_BOOTSTRAP, CO_ZEROEDGE]) { | |
| 109 | 117 | try { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); } catch {} |
| 110 | 118 | } |
| 111 | 119 | try { fs.unlinkSync(TEST_DB_PATH); } catch {} |
| ... | ... | @@ -187,6 +195,27 @@ describe('hire() 的 org / work 分层(27-P3B W7)', () => { |
| 187 | 195 | expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES); |
| 188 | 196 | }); |
| 189 | 197 | |
| 198 | + it('W7-fix:零条边的单节点图也算「成型」—— work 层本体不得入图', () => { | |
| 199 | + // 判据原先看的是**边数**,零边图上三条判据全落空 → work 层本体被写进 org_graph_nodes, | |
| 200 | + // 下一次下达任务时它会被 advanceOne 当成 org 节点执行。真跑里 readme-writer 就是这么混进去的。 | |
| 201 | + expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']); | |
| 202 | + | |
| 203 | + expect(['hired_from_template', 'already_exists']).toContain( | |
| 204 | + companyService.hire(CO_ZEROEDGE, 'coder-validator', {}).status | |
| 205 | + ); | |
| 206 | + | |
| 207 | + // 雇到位了,但组织图一个节点都没多 | |
| 208 | + expect(fs.existsSync(path.join(DATA_DIR, CO_ZEROEDGE, 'agents/coder-validator'))).toBe(true); | |
| 209 | + expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']); | |
| 210 | + }); | |
| 211 | + | |
| 212 | + it('W7-fix:零边图上,图里点名的那个 org 节点重复雇仍然入图(P2 语义不被误伤)', () => { | |
| 213 | + expect(['hired_from_template', 'hired_from_base', 'already_exists']).toContain( | |
| 214 | + companyService.hire(CO_ZEROEDGE, 'org-writer', {}).status | |
| 215 | + ); | |
| 216 | + expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']); | |
| 217 | + }); | |
| 218 | + | |
| 190 | 219 | it('org 节点本身重复雇不产生新节点(P2 幂等语义不变)', () => { |
| 191 | 220 | const res = companyService.hire(CO_PLANNED, 'org-coder', {}); |
| 192 | 221 | // 模板库里没有 org-coder → 走 base 元 agent 继承分支,同样要过 _recordHireInGraph | ... | ... |
web/src/views/GraphConsole.vue
| ... | ... | @@ -179,9 +179,16 @@ const midState = ref({ loopRecords: [], events: [] }); |
| 179 | 179 | const selectedState = computed(() => (selectedFrom.value === 'root' ? rootState.value : midState.value)); |
| 180 | 180 | |
| 181 | 181 | const hasOrgGraph = computed(() => defNodes.value.length > 0); |
| 182 | -/** run 在跑时端点会按 v1 并发约束 409,所以下达入口这时候直接不出现(W14) */ | |
| 183 | -const ACTIVE_RUN = new Set(['ready', 'running', 'paused', 'blocked']); | |
| 184 | -const rootRunActive = computed(() => !!rootRun.value && ACTIVE_RUN.has(rootRun.value.run_status)); | |
| 182 | +/** | |
| 183 | + * 能不能下达任务,**由服务端算**(GET /graph-runs 的 canDispatch)。 | |
| 184 | + * | |
| 185 | + * 这里原来自己复述了一份取值域,而且比后端多写了一个 blocked —— | |
| 186 | + * 后果是一家公司只要失败过一次(run 收敛成 blocked),控制台上就再也下不了第二个任务, | |
| 187 | + * 而后端其实是放行的。规则只留服务端一处,前端照抄结果(P3B 第三次真跑发现)。 | |
| 188 | + * 拿不到字段时保守放行,让端点的 409 去兜底 —— 那条路至少有明确报错。 | |
| 189 | + */ | |
| 190 | +const canDispatch = ref(true); | |
| 191 | +const rootRunActive = computed(() => !canDispatch.value); | |
| 185 | 192 | const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null)); |
| 186 | 193 | const budget = computed(() => rootState.value.budget || null); |
| 187 | 194 | const spent = computed(() => budget.value?.spent || {}); |
| ... | ... | @@ -220,6 +227,8 @@ async function loadRuns() { |
| 220 | 227 | if (!r.ok) return; |
| 221 | 228 | const j = await r.json(); |
| 222 | 229 | allRuns.value = j.runs || []; |
| 230 | + // 服务端算好的「能不能下达」;字段缺失(旧后端)时保守放行,交给端点 409 兜底 | |
| 231 | + canDispatch.value = j.canDispatch !== false; | |
| 223 | 232 | // v1 一家公司同时只有一个 active root run(§一非目标);没有 active 的就取最近一个 root |
| 224 | 233 | const roots = allRuns.value.filter((x) => !x.parent_run_id); |
| 225 | 234 | rootRun.value = roots.find((x) => ['ready', 'running', 'paused'].includes(x.run_status)) || roots[0] || null; | ... | ... |
-
mentioned in commit 0f22ddab132fdb466d29e6cf7ba22dcc410272e2