From 58a28650bdcd45563c2614a08f35cbd4cee9b3b5 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 17 Aug 2026 13:16:17 +0800 Subject: [PATCH] fix(p3b): 修三条挡着真跑的缺陷——idle 超时、W7 零边图、下达按钮显隐 --- server/routes/graph-run.js | 17 ++++++++++++++++- server/services/graph-db.js | 14 ++++++++++++++ server/services/graph-service.js | 41 +++++++++++++++++++++++++++++++++-------- server/services/org-graph-service.js | 5 ++++- server/src/core/agent-manager.js | 25 ++++++++++++++++++++++++- server/tests/unit/company-hire-org-layer.test.js | 33 +++++++++++++++++++++++++++++++-- web/src/views/GraphConsole.vue | 15 ++++++++++++--- 7 files changed, 134 insertions(+), 16 deletions(-) diff --git a/server/routes/graph-run.js b/server/routes/graph-run.js index d9b17ac..553ce19 100644 --- a/server/routes/graph-run.js +++ b/server/routes/graph-run.js @@ -275,11 +275,26 @@ router.get('/:id/stream', (req, res) => { * query: rootOnly=1 只列 root run(v1 一家公司同时只有一个 active root run,见 §一非目标) * 公司无 run 时返回空数组,不报错(与 GET /api/company/:cid/org-graph 的处理一致)。 */ +/** + * 「同一 company 只允许一个 active root run」的取值域,与 + * work-graph-db 的 idx_graph_runs_one_active_root 逐字一致 + * (`WHERE parent_run_id IS NULL AND run_status IN ('ready','running','paused')`)。 + * **注意 blocked 不在里面** —— blocked 的 run 不占坑,可以再下一个任务。 + */ +const ACTIVE_ROOT_RUN_STATUS = new Set(['ready', 'running', 'paused']); + 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: listCompanyRuns(req.params.companyId, { rootOnly }), + runs, + canDispatch: !activeRoot, + activeRootRunId: activeRoot ? activeRoot.id : null, }); })); diff --git a/server/services/graph-db.js b/server/services/graph-db.js index e615299..186bf73 100644 --- a/server/services/graph-db.js +++ b/server/services/graph-db.js @@ -77,6 +77,20 @@ function migrate(database) { database.exec(`ALTER TABLE org_graph_edges ADD COLUMN fail_reason TEXT`); } + // 4b. P3B W7-fix:节点的来源。 + // 判「这家公司的 org graph 成型了没有」原先看的是**边数**,理由是 + // 「节点 hire() 自己就会造,拿节点当信号会废掉『连雇两个人第二个不入图』」。 + // 但真跑里 orggraph-planning 裁出过一张**零条边的单节点图**,边数代理当场失效: + // 三条判据全落空 → workgraph-planning 现雇的 work 层本体被写进 org_graph_nodes, + // 下一次下达任务时它会被 advanceOne 当成 org 节点执行。 + // 所以把「谁写的」显式记下来,不再靠边数猜。 + // 'planner' = org-graph-service.writeGraph(规划 agent 提交的整张图) + // 'hire' = company-service.hire 顺手记的一行 + // NULL = 本列之前的存量行,判定时回落到边数代理,存量行为不变 + if (!hasColumn(database, 'org_graph_nodes', 'authored_by')) { + database.exec(`ALTER TABLE org_graph_nodes ADD COLUMN authored_by TEXT`); + } + // 5. P3 R3-F2:去重键补上 fail_reason,否则同一对 from/to 上的「精确 fail 边 + 兜底边」 // 只能活一条(insertEdge 用 ON CONFLICT DO NOTHING,被吞的那条不报错)。 // diff --git a/server/services/graph-service.js b/server/services/graph-service.js index d43a724..d6ba638 100644 --- a/server/services/graph-service.js +++ b/server/services/graph-service.js @@ -94,10 +94,17 @@ function ensureSystemAgentRow(companyId, systemAgentId, roleName) { /** * 这家公司的 org graph 有没有成型,以及某个角色在不在图上(P3B §四 W7 的判据之一)。 * - * `hasWorkflow` 看的是**边**不是节点:节点 hire() 自己就会造(P1/P2 语义),拿节点当 - * 「图已存在」的信号会把「同一家公司连雇两个人,第二个不入图」这条既有语义废掉。 - * 有 active 边则说明 orggraph-planning 提交过一张连起来的图(POST /org-graph → - * org-graph-service.writeGraph 是唯一写边的地方),此后这张图的作者就是它, + * `hasWorkflow` 判的是「orggraph-planning 提交过一张图没有」,而**不是**「有没有节点」—— + * 节点 hire() 自己就会造(P1/P2 语义),拿节点数当信号会把「同一家公司连雇两个人, + * 第二个不入图」这条既有语义废掉。 + * + * 主判据是 `authored_by='planner'` 的节点(writeGraph 是唯一写它的地方)。 + * 早先用的是**边数**代理,而真跑里 orggraph-planning 裁出过一张**零条边的单节点图**, + * 代理当场失效:三条判据全落空 → work 层本体被写进 org_graph_nodes, + * 下一次下达任务时被 advanceOne 当 org 节点执行(P3B W7-fix)。 + * 边数保留为存量行(authored_by IS NULL)的回落判据。 + * + * 此后这张图的作者就是 orggraph-planning, * hire() 不再往上追加节点:`work-graph-db.js:473` 起 org run 时读的正是 org_graph_nodes * 的 active 行,混进去的 work 层本体不是画歪一张图,而是真的多出一个会被执行的 org 节点。 * @@ -107,6 +114,12 @@ function ensureSystemAgentRow(companyId, systemAgentId, roleName) { */ function orgGraphMembership(companyId, roleName) { const db = getDb(); + // 主判据:有没有 planner 写下的节点。这是「orggraph-planning 提交过一张图」的**直接**证据。 + const planned = db.prepare( + `SELECT COUNT(*) AS c FROM org_graph_nodes + WHERE company_id = ? AND status = 'active' AND authored_by = 'planner'` + ).get(companyId); + // 回落判据:本列之前的存量行 authored_by 是 NULL,对它们仍用原来的边数代理,行为不变。 const edges = db.prepare( `SELECT COUNT(*) AS c FROM org_graph_edges WHERE company_id = ? AND status = 'active'` ).get(companyId); @@ -114,7 +127,8 @@ function orgGraphMembership(companyId, roleName) { `SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'` ).get(companyId, roleName); - return { hasWorkflow: ((edges && edges.c) || 0) > 0, isNode: ((node && node.c) || 0) > 0 }; + const hasWorkflow = ((planned && planned.c) || 0) > 0 || ((edges && edges.c) || 0) > 0; + return { hasWorkflow, isNode: ((node && node.c) || 0) > 0 }; } /** @@ -136,7 +150,7 @@ function orgGraphMembership(companyId, roleName) { * @param {string} roleName * @returns {void} */ -function upsertOrgGraphNode(companyId, systemAgentId, roleName) { +function upsertOrgGraphNode(companyId, systemAgentId, roleName, authoredBy = 'hire') { const db = getDb(); ensureOrgGraphNodeIndex(db); @@ -146,15 +160,26 @@ function upsertOrgGraphNode(companyId, systemAgentId, roleName) { ensureSystemAgentRow(companyId, systemAgentId, roleName); db.prepare(` - INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at) - VALUES (@company_id, @system_agent_id, @role_name, 'active', @created_at) + INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at, authored_by) + VALUES (@company_id, @system_agent_id, @role_name, 'active', @created_at, @authored_by) ON CONFLICT(company_id, role_name) WHERE status = 'active' DO NOTHING `).run({ company_id: companyId, system_agent_id: systemAgentId, role_name: roleName, created_at: now, + authored_by: authoredBy, }); + + // 冲突被 DO NOTHING 吞掉时行还在,但来源可能需要升格: + // hire 先记了一行(authored_by='hire'),随后 orggraph-planning 提交整张图把它纳入, + // 这行就该算 planner 写的。反向**不升格** —— planner 写过的图不会因为后续 hire 降级。 + if (authoredBy === 'planner') { + db.prepare(` + UPDATE org_graph_nodes SET authored_by = 'planner' + WHERE company_id = ? AND role_name = ? AND status = 'active' + `).run(companyId, roleName); + } }); tx(); diff --git a/server/services/org-graph-service.js b/server/services/org-graph-service.js index c02a84b..0133cda 100644 --- a/server/services/org-graph-service.js +++ b/server/services/org-graph-service.js @@ -273,7 +273,10 @@ function insertNode(db, companyId, node) { .get(companyId, node.roleName); const systemAgentId = node.systemAgentId || (known && known.system_agent_id) || node.roleName; - upsertOrgGraphNode(companyId, systemAgentId, node.roleName); + // 标 'planner':这一行是规划 agent 提交整张图写下的,不是 hire 顺手记的。 + // decideOrgNodeRecording 靠它判「这家公司的 org graph 成型了没有」—— + // 早先用边数代理,被一张零条边的单节点图打穿(P3B W7-fix)。 + upsertOrgGraphNode(companyId, systemAgentId, node.roleName, 'planner'); const row = db .prepare( diff --git a/server/src/core/agent-manager.js b/server/src/core/agent-manager.js index 27cf711..3312426 100644 --- a/server/src/core/agent-manager.js +++ b/server/src/core/agent-manager.js @@ -28,6 +28,13 @@ const { FailoverError, } = require('../engine'); +/** + * 无流式数据的空闲上限,与 stream-executor.runPromptInSession 的 JSDoc 契约一致(30 分钟)。 + * 真正的超时管理由上层心跳监控负责,这里只是兜底 —— 一个 agent 读几千行源码再动笔, + * 中间静默十几分钟是正常的,不该被当成卡死。 + */ +const DEFAULT_IDLE_TIMEOUT_MS = 1_800_000; + class AgentManager extends EventEmitter { constructor(config, workDir, options = {}) { super(); @@ -321,7 +328,23 @@ class AgentManager extends EventEmitter { try { this.sessionPool.activeKey = session.key; - const idleTimeoutMs = this.config?.agents?.[agentName]?.idleTimeout || 120_000; + // 优先级:调用方显式传入 > 该 agent 的配置 > 默认值。 + // + // 修之前这里是 `this.config?...?.idleTimeout || 120_000`,且 `...options` 摊在前面、 + // 被下一行的 idleTimeoutMs 覆盖 —— 两个后果叠在一起: + // ① 默认 120 秒,比 stream-executor 自己 JSDoc 写的 30 分钟(1_800_000)小一个数量级, + // 也和同进程 ClaudeCliProtocol 早已放到 24h 的 idle 上限完全对不上; + // ② 调用方**无法覆写** —— 传了 options.idleTimeoutMs 也会被这一行盖掉。 + // 真 CLI 下的实际后果:任何「先读几千行源码再动笔」的节点都在动笔前被杀 + // (readme-writer 就是这么死的:idle timeout: no stream data for 120000ms), + // 这是 27-P3B 第三次真跑「work 节点 → success」跑不通的唯一技术原因。 + // + // 「按静默时长杀 agent」在本仓库已经被判过一次死刑(ClaudeCliProtocol 那两个 + // IDLE_TIMEOUT 常量的注释写着「已取消」),这里是漏网的一处。默认值对齐 + // stream-executor 的契约,真正的超时管理交给上层心跳监控。 + const idleTimeoutMs = options.idleTimeoutMs + || this.config?.agents?.[agentName]?.idleTimeout + || DEFAULT_IDLE_TIMEOUT_MS; const stdout = await runPromptInSession(session, fullPrompt, { ...options, logSession, idleTimeoutMs, }); diff --git a/server/tests/unit/company-hire-org-layer.test.js b/server/tests/unit/company-hire-org-layer.test.js index 9209b71..23f7e4e 100644 --- a/server/tests/unit/company-hire-org-layer.test.js +++ b/server/tests/unit/company-hire-org-layer.test.js @@ -36,6 +36,7 @@ describe('hire() 的 org / work 分层(27-P3B W7)', () => { const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const CO_PLANNED = `tmp-w7-planned-${RUN_ID}`; // orggraph-planning 已提交过成型的图 + const CO_ZEROEDGE = `tmp-w7-zeroedge-${RUN_ID}`; // 真跑里 orggraph-planning 裁出过的**零条边单节点图** const CO_BLANK = `tmp-w7-blank-${RUN_ID}`; // 从没有过图(P1/P2 的老场景) const CO_BOOTSTRAP = `tmp-w7-bootstrap-${RUN_ID}`; // 刚建的公司,也没有图 @@ -93,7 +94,7 @@ describe('hire() 的 org / work 分层(27-P3B W7)', () => { `).run(id, id, `system/templates/${id}`, now, now); } - for (const [id, name] of [[CO_PLANNED, 'W7 已有组织图'], [CO_BLANK, 'W7 空图公司'], [CO_BOOTSTRAP, 'W7 新建公司']]) { + for (const [id, name] of [[CO_PLANNED, 'W7 已有组织图'], [CO_BLANK, 'W7 空图公司'], [CO_BOOTSTRAP, 'W7 新建公司'], [CO_ZEROEDGE, 'W7 零边图公司']]) { companyService.create({ id, name, description: 'W7 分层测试' }); } @@ -102,10 +103,17 @@ describe('hire() 的 org / work 分层(27-P3B W7)', () => { expect(written.ok).toBe(true); expect(written.nodeCount).toBe(3); expect(written.edgeCount).toBe(3); + + // 零边单节点图 —— 这个形状是真跑里 orggraph-planning 自己裁出来的, + // 而上面那 9 例造的全是**有边**的成型图,正好绕开它(P3B 第三次真跑发现)。 + const zero = orgGraphService.writeGraph(CO_ZEROEDGE, { nodes: ['org-writer'], edges: [] }); + expect(zero.ok).toBe(true); + expect(zero.nodeCount).toBe(1); + expect(zero.edgeCount).toBe(0); }); afterAll(() => { - for (const id of [CO_PLANNED, CO_BLANK, CO_BOOTSTRAP]) { + for (const id of [CO_PLANNED, CO_BLANK, CO_BOOTSTRAP, CO_ZEROEDGE]) { try { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); } catch {} } try { fs.unlinkSync(TEST_DB_PATH); } catch {} @@ -187,6 +195,27 @@ describe('hire() 的 org / work 分层(27-P3B W7)', () => { expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES); }); + it('W7-fix:零条边的单节点图也算「成型」—— work 层本体不得入图', () => { + // 判据原先看的是**边数**,零边图上三条判据全落空 → work 层本体被写进 org_graph_nodes, + // 下一次下达任务时它会被 advanceOne 当成 org 节点执行。真跑里 readme-writer 就是这么混进去的。 + expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']); + + expect(['hired_from_template', 'already_exists']).toContain( + companyService.hire(CO_ZEROEDGE, 'coder-validator', {}).status + ); + + // 雇到位了,但组织图一个节点都没多 + expect(fs.existsSync(path.join(DATA_DIR, CO_ZEROEDGE, 'agents/coder-validator'))).toBe(true); + expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']); + }); + + it('W7-fix:零边图上,图里点名的那个 org 节点重复雇仍然入图(P2 语义不被误伤)', () => { + expect(['hired_from_template', 'hired_from_base', 'already_exists']).toContain( + companyService.hire(CO_ZEROEDGE, 'org-writer', {}).status + ); + expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']); + }); + it('org 节点本身重复雇不产生新节点(P2 幂等语义不变)', () => { const res = companyService.hire(CO_PLANNED, 'org-coder', {}); // 模板库里没有 org-coder → 走 base 元 agent 继承分支,同样要过 _recordHireInGraph diff --git a/web/src/views/GraphConsole.vue b/web/src/views/GraphConsole.vue index 5f770d2..25b8e4c 100644 --- a/web/src/views/GraphConsole.vue +++ b/web/src/views/GraphConsole.vue @@ -179,9 +179,16 @@ const midState = ref({ loopRecords: [], events: [] }); const selectedState = computed(() => (selectedFrom.value === 'root' ? rootState.value : midState.value)); const hasOrgGraph = computed(() => defNodes.value.length > 0); -/** run 在跑时端点会按 v1 并发约束 409,所以下达入口这时候直接不出现(W14) */ -const ACTIVE_RUN = new Set(['ready', 'running', 'paused', 'blocked']); -const rootRunActive = computed(() => !!rootRun.value && ACTIVE_RUN.has(rootRun.value.run_status)); +/** + * 能不能下达任务,**由服务端算**(GET /graph-runs 的 canDispatch)。 + * + * 这里原来自己复述了一份取值域,而且比后端多写了一个 blocked —— + * 后果是一家公司只要失败过一次(run 收敛成 blocked),控制台上就再也下不了第二个任务, + * 而后端其实是放行的。规则只留服务端一处,前端照抄结果(P3B 第三次真跑发现)。 + * 拿不到字段时保守放行,让端点的 409 去兜底 —— 那条路至少有明确报错。 + */ +const canDispatch = ref(true); +const rootRunActive = computed(() => !canDispatch.value); const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null)); const budget = computed(() => rootState.value.budget || null); const spent = computed(() => budget.value?.spent || {}); @@ -220,6 +227,8 @@ async function loadRuns() { if (!r.ok) return; const j = await r.json(); allRuns.value = j.runs || []; + // 服务端算好的「能不能下达」;字段缺失(旧后端)时保守放行,交给端点 409 兜底 + canDispatch.value = j.canDispatch !== false; // v1 一家公司同时只有一个 active root run(§一非目标);没有 active 的就取最近一个 root const roots = allRuns.value.filter((x) => !x.parent_run_id); rootRun.value = roots.find((x) => ['ready', 'running', 'paused'].includes(x.run_status)) || roots[0] || null; -- libgit2 0.21.4