Commit 58a28650bdcd45563c2614a08f35cbd4cee9b3b5

Authored by yibo
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>
server/routes/graph-run.js
... ... @@ -275,11 +275,26 @@ router.get(&#39;/:id/stream&#39;, (req, res) =&gt; {
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(&#39;hire() 的 org / work 分层(27-P3B W7)&#39;, () =&gt; {
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(&#39;hire() 的 org / work 分层(27-P3B W7)&#39;, () =&gt; {
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(&#39;hire() 的 org / work 分层(27-P3B W7)&#39;, () =&gt; {
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(&#39;hire() 的 org / work 分层(27-P3B W7)&#39;, () =&gt; {
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;
... ...