37 · Org Graph 该不该是部门级,而不是公司级
日期:2026-08-24 触发:用户测试 e2e-demo(一家"开发型公司")时发现,公司除了写代码交付这条主线,还需要人事(招·留·管·退)、财务、市场、运营这类"公司自己运转"所需的职能——"麻雀虽小,五脏俱全"。但现状是一家公司只有一张 org graph(
27-P2文档标题原文就是"Org Graph — 公司级 Workflow 的生成与落地"),这张图被设计成"一条交付流水线"(规划→执行→验收),塞不下 HR/财务这类跟"交付什么产物"无关、却是任何公司都需要的常设职能。 结论先说:用户提出的重新定义是对的——org graph 应该是部门级(department-scoped),一家公司可以装配多张,而不是公司级单例。这不是发明新概念,是把代码库里已经写好但从没拧紧的几颗螺丝对齐:system/agents/hr/这个本体已经存在且职责原文就是"招·留·管·退",只是从没被装进任何图;org_graph_nodes.reports_to_node_id这一列在 schema 里存在但零引用;org-graph-templates/(dev.json/content.json/ops.json)本身的形状早就是"一个部门一份",只是今天的用法是"公司建立时选一个"而不是"可以装配多个"。 状态:设计讨论,尚未立项实现。本文档记录缘起、现状证据、提议方案与影响面,供下一步决定要不要排期。
一、缘起(Why)
这次讨论的起点是三层递进的对话(同一会话内,2026-08-24):
- 先确认"org graph vs work graph 的边界在哪"——排查发现 lemonboss 的这套两层划分(角色图/工作图)跟姊妹项目
graphlemon(docs/graphlemon_architecture.md)是同一条设计脉络:Org Graph 人工固定、慢变、可审计;Work Graph 每次任务动态规划、可以快变。这条边界本身是对的,也有先例支撑。 - 再讨论"能不能按 goal/task 现场 plan 出不同的 org graph"——排查发现这个想法如果直接落地,会把"角色图"的慢变属性拉进"工作图"的快变节奏,破坏治理红线;而且引擎的
activateSuccessTargets(server/services/graph-engine.js)对condition='success'的出边是无条件全部点亮,没有fail_reason那种按路由键精确匹配的机制——就算图里画出多条分支,今天也做不到"按任务类型只走一条"。 - 用户指出真实场景:一家开发公司也需要人事、财务、市场、运营——这些不是"交付流水线上的一步",是任何公司都需要的常设职能。这才是问题的正确提法:不是"一张图要不要按任务变形",而是"一家公司本来就不该只有一张图"。
Why 这件事现在值得写下来:不是臆测的未来需求——system/agents/hr/agent.md 已经真实存在、职责描述("招·留·管·退")跟用户这次原话逐字对应,说明这个需求早就被感知到、甚至已经动手做了一半(写了 HR 本体),只是没人把它跟 org graph 打通,它今天完全是编外人员。
二、现状实锤(What is)
按 company_id 单例设计的证据,散在四处,此前从没被合起来看:
- Schema 本身是单例:
org_graph_nodes/org_graph_edges(server/services/graph-db.js:318起)只有company_id一个作用域键,没有部门/命名空间维度。org-graph-service.getGraph(companyId)因此只能返回"这家公司唯一那张图"。 - 规划 prompt 把这个假设写死了:
server/services/task-planning.js:199给拆分 agent 的说明原文是"公司 org graph 摘要(每条 task 都会各自走一遍这条流水线)"——不是"选一条适合的流水线",是无条件全走一遍。 - 就算画出多分支,引擎也不支持按类型选路:
activateSuccessTargets(graph-engine.js§四.D.1)遍历某节点所有condition='success'的出边逐一点亮下游,唯一门槛是 fan-in 汇合;fail_reason精确匹配选边的机制只长在 fail 边上,success 边没有对应物。 - 新雇的角色不在图上就被排除在图外:
company-service.js:114附近的排除规则③明文写着"公司已经有一张成型的组织工作流图,而这次雇的角色不在图上 → 不入图"——hr正是被这条规则挡在图外的典型:它是真实存在、定义完整的本体,却从未出现在任何公司的org_graph_nodes里,今天的"招聘/训练"全靠POST /:companyId/hire触发的 fire-and-forget chat 流水线(server/routes/company.js的 hire 路由 +org-innovatorchat 调用),没有断点续跑、没有图执行那一套审计轨迹,跟org-coder走图执行的路是两套完全不同的机制。 - 半埋下的层级维度:
org_graph_nodes.reports_to_node_id(graph-db.js:324)这一列存在,但org-graph-service.js里零引用——是个没人接上的部件,形态正好能承载"这个节点属于哪个部门"这类信息。 - 模板本身已经是"一个部门一份"的形状:
dev.json/content.json/ops.json(system/org-graph-templates/)都是三节点小图(规划→执行→验收),README.md原文说"每个*.json是一种公司类型的执行流图起点"——设计时想的就是"选一个",不是"可以都要"。 - 连标题都写死了:已实现并归档的
docs/done/system-design/27-p2-org-graph.md标题就是"Org Graph — 公司级 Workflow 的生成与落地",这次要挑战的正是标题里这个前提。
三、跟已有参考设计的关系
graphlemon(docs/graphlemon_architecture.md)和这次翻出来的三篇行业参考文章(docs/reference/)都没有讨论过"一家公司多个部门、按项目动态选用"这个问题——graphlemon 的 MVP 明确把范围锁定成单一"Dev Organizer"工厂类型(Planner→Coder→Validator 三节点,永远固定),它文档里"多 Goal 并发(多个 Org Graph 实例)"指的是同一张图并发跑多个 goal 的多个 run,不是"不同 goal 用不同形状的图"。所以本文档提议的"部门级 org graph"不是抄现成先例,是在已验证过的"角色图慢变可审计 / 工作图快变"这条边界之上,往前多走一步——这一步两边参考资料都没走过,需要自己想清楚。
四、提议方案(How / What)
核心改动:org graph 的作用域从 company_id 单例升级为 (company_id, department_id) 多例。每个部门的图依然保持"人工固定、慢变、可审计"的治理属性——只是一家公司现在可以有好几张这样的图,而不是被迫塞进一张。
- Schema:
org_graph_nodes/org_graph_edges加一个部门作用域键(建议默认值兜住存量单图公司,不破坏兼容——存量公司的这张图归到一个隐式的默认部门,不需要数据迁移)。 - 服务层:
orgGraphService.getGraph/writeGraph/patchGraph/validateGraph从(companyId)升级为(companyId, departmentId)。 - 规划:
orggraph-planning可以对同一家公司规划多张图——建公司时按whenToUse选出的不再是"唯一一张图的模板",而是"这家公司初始要装配哪几个部门",dev.json/content.json/ops.json/未来的hr.json都可以同时被选中、各自独立生成一张图。 - goal 路由:
task-planning.js现在读"公司唯一那张图"喂给拆分 agent,需要改成先做一步"这个 goal 归哪个部门"的分类,再读那张部门图的摘要——这一步本质是复用orggraph-planning建公司时已经在做的whenToUse选型逻辑,只是从"建公司时跑一次"变成"每个 goal 都跑一次"。 - 顺带收编招聘/训练:HR 部门自己有一张 org graph 后,"雇一个新 agent""对某个 agent 做 FDE 训练"就能变成走图执行、可断点恢复、可审计的正经 goal,而不是今天这种 fire-and-forget 的 chat 调用(详见 36-prd-agent-methodology-training-feasibility.md 记录的独立试验训练全靠人工一条条 chat 催的现状)。
治理属性没有丢:这不是把角色图变成动态生成——每张部门图依然要走人工评审、依然慢变。变的只是"一家公司允许持有的图的数量"从 1 变成 N,这恰好更贴近人类公司的真实形态:组织架构(谁向谁汇报、部门怎么设置)是慢变的,但公司可以同时有好几个部门,每个项目只会用到其中相关的几个。
五、影响面(调用点扫描)
2026-08-24 追加:全仓库扫描(fork)已完成。结论——改造面比最初估的两个调用点大约 4 倍,且真正的单点真相不在最初以为的那个文件里。
5.1 服务层(org-graph-service.js)——机械可扩,风险低
导出 validateGraph/writeGraph/patchGraph/getGraph/terminalAgentId/removeNode,全部只按 companyId 作用域,调用方都是浅层引用,加一个 departmentId 参数即可:
server/routes/company.js:564,586,599,611,1866,2074,2276,2515—— CRUD 路由 + 4 处"图存不存在"的入队前置闸门server/services/graph-engine.js:821—— 淘汰过期workgraph-planning雇佣时的removeNodeserver/services/task-settle.js:447——terminalAgentId(companyId)找 goal 汇报人server/services/task-planning.js:242-243—— 读getGraph(companyId)摘要喂进拆分 prompt(已知)
5.2 绕过 service 层的直接 DB 访问——比假设的大得多,这才是真正的单点真相
work-graph-db.js:1074的readDefGraph(kind, graphId):kind==='org'时直接对org_graph_nodes/org_graph_edges跑WHERE company_id = ?的裸 SQL,代码注释原文是"kind === 'org':graphId 就是 company_id(§四.A.2)"——这是一条写进设计文档的架构不变式,不是顺手写的代码。graph-engine.js真正执行时走的defIndex(run)就是调它——引擎的执行路径吃的是这份裸 SQL,不是org-graph-service.js那层。只改服务层 API、不改这里,运行时引擎依然是公司级单例。graph-service.js(跟org-graph-service.js名字接近、是两个不同文件,容易搞混)自己也有一份裸 SQL,还持有一个局部唯一索引idx_org_graph_nodes_company_role_active ON org_graph_nodes(company_id, role_name) WHERE status='active'——这份索引在graph-db.js的migrate()里还有一份必须字节级保持一致的重复定义(代码注释明确警告这两处会打架)。这个文件还持有orgGraphMembership(companyId, roleName)(就是撑住"图已成型、新雇角色被排除"那条规则③的真身)和upsertOrgGraphNode(其幂等靠的ON CONFLICT(company_id, role_name)跟这份索引形状死死绑定)。graph-seed-service.js:一整个独立的"company.json↔ DB 对账"子系统,8 处裸 SQL(公司删除级联、孤儿节点清理、一致性校验,分布在 246-259/311-322/493-533/609-698/756 行),全部按company_id作用域——最初的两点估算完全没算到这一块。
5.3 路由层
GET/POST/PATCH /:companyId/org-graph、POST /:companyId/org-graph/replan 四个端点没有部门维度,需要 ?department= 或嵌套路径 /:companyId/departments/:deptId/org-graph——无论哪种,§5.1 列的 8 个调用点形状都要跟着变。
5.4 goal/task 派发链路
新发现:enqueueTaskRun(company.js:1046-1062)建 org 类型 run 时写的是 wdb.createRun({graphKind:'org', graphId: companyId, ...})——"org run 的 graph_id 字面上就是 companyId" 这条约定的唯一生产写入点就在这里,是 §5.2 那条不变式的源头。work_graph_nodes.owner_org_node_id → org_graph_nodes(id) 这条外键不受影响——只要 org_graph_nodes.id 继续是自增主键,部门只是这一行上多一列,外键完整性不受牵连。
5.5 执行引擎
graph-engine.js 不按公司缓存图定义(每次 advance 都现查 defIndex(run),没有单例缓存要操心)——但它拿"用哪张图"的唯一路径就是 run.graph_id,今天恒等于 companyId(§5.2/5.4)。6 处分支判断 run.graph_kind === 'org'(1017/4059/4114/4203/4252 行),全部是传递性受影响——只要 readDefGraph 的 SQL 改一处(比如 graph_id 改成 ${companyId}:${departmentId} 这种复合键),这 6 处不需要各自单独动。
5.6 招聘流程
排除规则③(company-service.js ~114-140 行)和 _recordHireInGraph/decideOrgNodeRecording 实际调的是 graph-service.orgGraphMembership(§5.2)——真正要改的落点比原以为的更深一层。雇佣本身的 promoteDraftIfComplete 不碰 org graph,不受影响。
5.7 心跳/调度
heartbeat-service.js、company-heartbeat-service.js 对 org_graph/orgGraph 零引用——心跳按 company_agents 枚举雇员,不查 org graph 表,无影响。
5.8 前端消费方
GraphConsole.vue:417、CompanyDashboard.vue:226-268(渲染单条线性 orgGraphChain 数组)、ReplanOrgGraphButton.vue:79,99 —— 三处都是"渲染唯一一张图",需要加部门选择器,没有更深的架构耦合。
5.9 测试守卫
company-hire-org-layer.test.js(146-238 行起,10+ 条专门用例)直接测"图已成型 → 新雇角色被排除"这条不变式本身——这是不变式的正式测试合同,改造时要重写语义(部门感知),不是简单传参。org-graph-service.test.js等约 25 个文件只是顺手调用getGraph/writeGraph做测试前置,机械跟改即可。
5.10 文档与 agent 人设
system/agents/orggraph-planning/agent.md:5-6:自我认知原文是"这家公司唯一一次性的基础设施角色……你的唯一职责:为这家公司规划出一张 org graph"——部门化直接跟这个 agent 的人设文本相矛盾,需要从"一次性、产出一张图"改写成"可按部门重复调用"。system/agents/task-planning/agent.md(24/29/36/52/63/74 行)通篇是单数框架("每条 task 都会各自走一遍完整的 org graph"、"org graph 已经定了")——不只是task-planning.js:199那处代码里的 prompt 片段,人设文档本身也要重写。system/org-graph-templates/README.md:已知,"选一个"式表述。docs/done/system-design/27-p2-org-graph.md(补充:fork 用原路径没找到,实际文件已在 P2 完成后归档进done/,本文档第一节已用正确路径引用过)。
5.11 最大的几个意外
- "org graph = company_id" 真正的单点真相在
work-graph-db.js的readDefGraph裸 SQL 里,不在org-graph-service.js——而且是写进设计文档的不变式(§四.A.2),坐在执行引擎的热路径上。只改服务层 API 不改这里,运行时引擎依然是公司级单例,等于白改。 - 有两个名字相近、容易搞混的服务文件(
org-graph-service.js与graph-service.js)都各自持有对org_graph_nodes/org_graph_edges的裸 SQL,还共同维护一份必须字节级一致、写了警告注释的重复唯一索引——部门化要同时改两份索引定义 +upsertOrgGraphNode的ON CONFLICT目标。 graph-seed-service.js是完全独立的第三个子系统(company.json↔ DB 对账/修复),又是 8 处裸 SQL——最初的两点估算里完全没算到这一块。- 排除规则③有一整套专属测试(
company-hire-org-layer.test.js),外加两份 agent 人设文档把"只有一张图"写进了自我认知(orggraph-planning/task-planning)——这次改造跟代码改动一样重的是 prompt/文档改写,不是"改完代码顺手补几个测试"能打发的。
净结论:改造面大约是最初两个调用点估算的 4 倍,横跨 3 个后端服务(不是 1 个)、执行引擎的裸 SQL 热路径、一份重复维护的 DB 索引、3 个前端组件、一整套专属测试、2 份 agent 人设文档。
六、开放问题
- 一家公司同时有多张图之后,"公司整体健康度/进度"这类跨部门汇总视图(
CompanyDashboard.vue)要不要重新设计,还是简单地按部门分区展示即可? - goal 的部门分类步骤本身会不会分错——分类错误的后果(一个明明该走 HR 图的 goal 被分进了 dev 图)需要有和
body_not_ready/blocked_pending_human同级别的兜底与人工可见性,不能默默按错误部门执行到底。 - 部门之间会不会需要互相产出/消费 artifact(比如 HR 招到的新 agent 要交给 dev 部门"验收"),跨部门的边算不算这次设计范围内,还是留到部门内部图先跑顺再看要不要扩展成跨部门边。
七、建议推进顺序
2026-08-24 追加:第五节扫描结果显示改造面是当初估算的 4 倍、横跨 3 个后端服务 + 执行引擎热路径 + 2 份 agent 人设文档,不是一个中等改动,不适合直接对存量公司/存量图动手。调整后的顺序:
- 先钉死唯一入口:部门化的落点不是
org-graph-service.js,是work-graph-db.js:readDefGraph那条graph_id === company_id的不变式(§5.2/5.4)——enqueueTaskRun是这条约定的唯一生产写入点。任何验证都必须先改这一处(比如把graph_id换成${companyId}:${departmentId}复合键),否则表面上服务层/路由层都改完了,运行时引擎依然在按公司单例执行。 - 最小验证仍然成立,但要绕开排除规则③和它的专属测试:只给一家全新的测试公司加一张
hr部门图(复用现成的system/agents/hr/),跑通"HR 部门收到一个招聘类 goal → 走图执行 → 完成雇佣"这一条链路——不要碰company-hire-org-layer.test.js守着的存量单图公司行为,两边先并存,不急着让旧公司迁移。 org-graph-service.js与graph-service.js的裸 SQL + 那份必须字节级一致的重复索引(§5.2)建议借这次机会合并成一处持有,不要在部门化时把"两份定义要保持一致"这条隐患原样搬进新维度——不合并的话,以后改索引形状要记得改两个文件,是新的技术债。orggraph-planning/task-planning两份 agent 人设文档的重写,跟代码改动同批验收,不要留到"代码先上、文档以后补"——这两份文档目前把"只有一张图"写进了自我认知,代码改完而文档没跟上,agent 会带着错误的自我认知继续按旧假设规划。- 验证通过后再决定要不要把"部门分类"这一步做成通用机制、推广到存量公司;
graph-seed-service.js那 8 处对账 SQL 放在最后处理——它是修复性质的子系统,不在 goal 执行的关键路径上,可以等主链路验证稳定后再补。