37-org-graph-department-scoping.md 18.8 KB

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):

  1. 先确认"org graph vs work graph 的边界在哪"——排查发现 lemonboss 的这套两层划分(角色图/工作图)跟姊妹项目 graphlemon(docs/graphlemon_architecture.md)是同一条设计脉络:Org Graph 人工固定、慢变、可审计;Work Graph 每次任务动态规划、可以快变。这条边界本身是对的,也有先例支撑。
  2. 再讨论"能不能按 goal/task 现场 plan 出不同的 org graph"——排查发现这个想法如果直接落地,会把"角色图"的慢变属性拉进"工作图"的快变节奏,破坏治理红线;而且引擎的 activateSuccessTargets(server/services/graph-engine.js)对 condition='success' 的出边是无条件全部点亮,没有 fail_reason 那种按路由键精确匹配的机制——就算图里画出多条分支,今天也做不到"按任务类型只走一条"。
  3. 用户指出真实场景:一家开发公司也需要人事、财务、市场、运营——这些不是"交付流水线上的一步",是任何公司都需要的常设职能。这才是问题的正确提法:不是"一张图要不要按任务变形",而是"一家公司本来就不该只有一张图"。

Why 这件事现在值得写下来:不是臆测的未来需求——system/agents/hr/agent.md 已经真实存在、职责描述("招·留·管·退")跟用户这次原话逐字对应,说明这个需求早就被感知到、甚至已经动手做了一半(写了 HR 本体),只是没人把它跟 org graph 打通,它今天完全是编外人员。

二、现状实锤(What is)

按 company_id 单例设计的证据,散在四处,此前从没被合起来看:

  1. Schema 本身是单例:org_graph_nodes/org_graph_edges(server/services/graph-db.js:318 起)只有 company_id 一个作用域键,没有部门/命名空间维度。org-graph-service.getGraph(companyId) 因此只能返回"这家公司唯一那张图"。
  2. 规划 prompt 把这个假设写死了:server/services/task-planning.js:199 给拆分 agent 的说明原文是"公司 org graph 摘要(每条 task 都会各自走一遍这条流水线)"——不是"选一条适合的流水线",是无条件全走一遍。
  3. 就算画出多分支,引擎也不支持按类型选路:activateSuccessTargets(graph-engine.js §四.D.1)遍历某节点所有 condition='success' 的出边逐一点亮下游,唯一门槛是 fan-in 汇合;fail_reason 精确匹配选边的机制只长在 fail 边上,success 边没有对应物。
  4. 新雇的角色不在图上就被排除在图外:company-service.js:114 附近的排除规则③明文写着"公司已经有一张成型的组织工作流图,而这次雇的角色不在图上 → 不入图"——hr 正是被这条规则挡在图外的典型:它是真实存在、定义完整的本体,却从未出现在任何公司的 org_graph_nodes 里,今天的"招聘/训练"全靠 POST /:companyId/hire 触发的 fire-and-forget chat 流水线(server/routes/company.js 的 hire 路由 + org-innovator chat 调用),没有断点续跑、没有图执行那一套审计轨迹,跟 org-coder 走图执行的路是两套完全不同的机制。
  5. 半埋下的层级维度:org_graph_nodes.reports_to_node_id(graph-db.js:324)这一列存在,但 org-graph-service.js 里零引用——是个没人接上的部件,形态正好能承载"这个节点属于哪个部门"这类信息。
  6. 模板本身已经是"一个部门一份"的形状:dev.json/content.json/ops.json(system/org-graph-templates/)都是三节点小图(规划→执行→验收),README.md 原文说"每个 *.json 是一种公司类型的执行流图起点"——设计时想的就是"选一个",不是"可以都要"。
  7. 连标题都写死了:已实现并归档的 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) 多例。每个部门的图依然保持"人工固定、慢变、可审计"的治理属性——只是一家公司现在可以有好几张这样的图,而不是被迫塞进一张。

  1. Schema:org_graph_nodes/org_graph_edges 加一个部门作用域键(建议默认值兜住存量单图公司,不破坏兼容——存量公司的这张图归到一个隐式的默认部门,不需要数据迁移)。
  2. 服务层:orgGraphService.getGraph/writeGraph/patchGraph/validateGraph 从 (companyId) 升级为 (companyId, departmentId)。
  3. 规划:orggraph-planning 可以对同一家公司规划多张图——建公司时按 whenToUse 选出的不再是"唯一一张图的模板",而是"这家公司初始要装配哪几个部门",dev.json/content.json/ops.json/未来的 hr.json 都可以同时被选中、各自独立生成一张图。
  4. goal 路由:task-planning.js 现在读"公司唯一那张图"喂给拆分 agent,需要改成先做一步"这个 goal 归哪个部门"的分类,再读那张部门图的摘要——这一步本质是复用 orggraph-planning 建公司时已经在做的 whenToUse 选型逻辑,只是从"建公司时跑一次"变成"每个 goal 都跑一次"。
  5. 顺带收编招聘/训练: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 雇佣时的 removeNode
  • server/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 最大的几个意外

  1. "org graph = company_id" 真正的单点真相在 work-graph-db.js 的 readDefGraph 裸 SQL 里,不在 org-graph-service.js——而且是写进设计文档的不变式(§四.A.2),坐在执行引擎的热路径上。只改服务层 API 不改这里,运行时引擎依然是公司级单例,等于白改。
  2. 有两个名字相近、容易搞混的服务文件(org-graph-service.js 与 graph-service.js)都各自持有对 org_graph_nodes/org_graph_edges 的裸 SQL,还共同维护一份必须字节级一致、写了警告注释的重复唯一索引——部门化要同时改两份索引定义 + upsertOrgGraphNode 的 ON CONFLICT 目标。
  3. graph-seed-service.js 是完全独立的第三个子系统(company.json ↔ DB 对账/修复),又是 8 处裸 SQL——最初的两点估算里完全没算到这一块。
  4. 排除规则③有一整套专属测试(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 人设文档,不是一个中等改动,不适合直接对存量公司/存量图动手。调整后的顺序:

  1. 先钉死唯一入口:部门化的落点不是 org-graph-service.js,是 work-graph-db.js:readDefGraph 那条 graph_id === company_id 的不变式(§5.2/5.4)——enqueueTaskRun 是这条约定的唯一生产写入点。任何验证都必须先改这一处(比如把 graph_id 换成 ${companyId}:${departmentId} 复合键),否则表面上服务层/路由层都改完了,运行时引擎依然在按公司单例执行。
  2. 最小验证仍然成立,但要绕开排除规则③和它的专属测试:只给一家全新的测试公司加一张 hr 部门图(复用现成的 system/agents/hr/),跑通"HR 部门收到一个招聘类 goal → 走图执行 → 完成雇佣"这一条链路——不要碰 company-hire-org-layer.test.js 守着的存量单图公司行为,两边先并存,不急着让旧公司迁移。
  3. org-graph-service.js 与 graph-service.js 的裸 SQL + 那份必须字节级一致的重复索引(§5.2)建议借这次机会合并成一处持有,不要在部门化时把"两份定义要保持一致"这条隐患原样搬进新维度——不合并的话,以后改索引形状要记得改两个文件,是新的技术债。
  4. orggraph-planning/task-planning 两份 agent 人设文档的重写,跟代码改动同批验收,不要留到"代码先上、文档以后补"——这两份文档目前把"只有一张图"写进了自我认知,代码改完而文档没跟上,agent 会带着错误的自我认知继续按旧假设规划。
  5. 验证通过后再决定要不要把"部门分类"这一步做成通用机制、推广到存量公司;graph-seed-service.js 那 8 处对账 SQL 放在最后处理——它是修复性质的子系统,不在 goal 执行的关键路径上,可以等主链路验证稳定后再补。