/** * Graph Seed Service — populates `data/graph.sqlite` from existing on-disk * agent/company sources (P0, read-only w.r.t. the legacy sources). * * seedAgents() — system/agents/* → system_agents (origin='builtin') * seedCompanyAgents() — data/companies/{id}/company.json.agents[] → system_agents (origin='created') + org_graph_nodes * verifyCounts() — 独立重扫目录做行数交叉核对(T4) * reconcileDanglingRows() — 回收指向已删目录的 created 行 / 已删公司的 org 节点(P3B W1) * + 磁盘上已无本体的 builtin 行(28 批次 1 目录改名的残渣) * + 图基础设施本体的孤儿 org 节点 + 存量 training 行的毕业对账(28 三轮) * runStartupSeed() — 服务启动时跑的那一组(reconcile + seedAgents),见 server/index.js * * P0 阶段:只加不改,不涉及任何现有执行路径(company-service.js / agent-manager.js 等)。 * P3B W1 起本模块被接进服务启动流程(runStartupSeed),不再只是手动脚本。 * * 28 批次 1(`system/templates/` 并入 `system/agents/`):原来的 seedGlobalAgents() + * seedTemplates() 两个扫描源合成一个 seedAgents(),`source_kind` 的三分收成 `origin` 二分。 * 「同一个 id 同时存在于两个本体目录、模板那份永远种不进去」这条失效链随目录合并一起消失, * 所以本文件不再有跨源的 id 冲突保护 —— 只剩「活着的 created 行不被 builtin 覆盖」这一条。 */ const fs = require('fs'); const path = require('path'); const { getDb } = require('./graph-db'); // 两个取值域的持有者都在 graph-service(理由见那边 isUngraduatedStatus / NON_ORG_AGENT_TYPES 的注释): // 本文件只调用判据,绝不在这里抄一份 type / status 的集合 —— 抄一份的后果是 // seed 与对账**一致地错**,exit 0 且零告警。 const { isNonOrgAgentType, isUngraduatedStatus } = require('./graph-service'); const { missingBodyDefinitionIn } = require('./agent-locator'); // GRAPH_SEED_ROOT 只是测试用的落地缝(与 graph-db.js 的 GRAPH_DB_PATH 同一套路): // 单测把它指向临时目录,就能在完全受控的 system/ + data/companies/ 上跑种子与对账, // 不再依赖真实仓库里有多少公司。生产不设这个变量,走仓库根。 const ROOT = path.resolve(process.env.GRAPH_SEED_ROOT || path.resolve(__dirname, '../..')); const AGENTS_DIR = path.join(ROOT, 'system', 'agents'); const AGENTS_REGISTRY_PATH = path.join(AGENTS_DIR, '_registry.json'); const COMPANIES_DIR = path.join(ROOT, 'data', 'companies'); /** * 这个目录名算不算一个可调用的 agent 本体。 * * `_` 开头是**保留 id**(`_base` 元 agent 模板、`_registry.json`),不是 agent; * 没有 agent.md 的目录是数据碎片(历史上 `system/agents/templates/` 就是这么混进来的)。 * 28 §5.1 要求四处扫描统一按这条判据过滤,这里是其中之一。 */ function isAgentBodyDir(name) { return !name.startsWith('_') && !name.startsWith('.') && fs.existsSync(path.join(AGENTS_DIR, name, 'agent.md')); } /** 本体的 config.json,读不到 / 坏了返回 null(判据一律退化成「按普通本体处理」) */ function readBodyConfig(agentId) { try { return JSON.parse(fs.readFileSync(path.join(AGENTS_DIR, agentId, 'config.json'), 'utf-8')); } catch { return null; } } /** * 这个角色是不是**图基础设施本体**(永不作为 org 节点)。判据的持有者是 * `graph-service.NON_ORG_AGENT_TYPES` —— 本文件只问它,不复述取值域。 * * 三个调用点必须共用这一个函数:seedCompanyAgents(写 org_graph_nodes)、 * verifyCounts(算期望值)、reconcileDanglingRows(清孤儿节点)。改造前前两处共用一个 * **手抄**的 `isMetaBootstrap()`(只认 meta-bootstrap),于是 W7 放宽到 meta-planning 之后, * 补种把 hire() 排除掉的规划本体塞回图里、对账又跟着一起算错 —— 一致地错 = 不会被发现。 * * 28 批次 4:config.json 只剩本体那一份(公司私有目录已删),所以按 roleName 读本体目录。 * companyId 保留形参:调用点都是按 (公司, 角色) 遍历的,签名少一个只会让调用点更绕。 */ function isNonOrgAgentRole(companyId, roleName) { const cfg = readBodyConfig(roleName); return isNonOrgAgentType(cfg && cfg.type); } // ───────────────────────── 悬空行回收(P3B W1)───────────────────────── // // 背景:`data/companies/{cid}/` 被手工删掉之后,seedCompanyAgents()/hire() 当初写进 // system_agents 的 `created`(原 company_hired)行留在库里,legacy_path 指着一个早已不存在的目录。 // 这些行占着 id 命名空间:`seedAgents()` 扫到同名本体目录时会因为「id 已被一个还活着的 // created 行占用」而跳过,于是 `lookupSharedAgentMd()` 对这些 id 恒返回 null, // company-service.hire() 静默从「引用模式」退回「legacy 全量拷贝」—— // 结果就是本体改动对任何已雇公司都不生效。所以这不是洁癖,是一条真实的失效链。 /** * 收集**所有还活着的公司**声明用到的角色名。 * * 这是判断「一行 created 是不是还有人认领」的唯一依据,比只看 owner_company_id 宽: * 同一个 agent id 可能被多家公司复用(seedCompanyAgents 对已存在的 id 只建节点、不重复建行), * 只要还有任何一家活着的公司点名它,就不能回收。 * * 「活着」仍然按磁盘判(有 `data/companies/{cid}/company.json`)—— 库里的 company_agents * 行不会因为公司目录被手工删掉而消失,拿它判存活等于永远回收不掉任何东西。 * 「点名了谁」则按**两个源取并集**(28 批次 4-19): * - `company_agents` 的 active 行 —— 真相来源; * - `company.json.agents[]` —— 只写不读的兼容名册;全仓只剩本文件还读它 * (这里 + seedCompanyAgents,理由各自写在原地)。 * 取并集而不是切换:这道判据的作用是**防误伤**(注释「宁可少回收」),两个源里 * 任何一个还认这个 id,就都不该删。 * * @returns {{ claimedRoles: Set, liveCompanyIds: Set, companiesDirPresent: boolean }} */ function collectLiveCompanyClaims() { const companiesDirPresent = fs.existsSync(COMPANIES_DIR); const claimedRoles = new Set(); const liveCompanyIds = new Set(); if (!companiesDirPresent) return { claimedRoles, liveCompanyIds, companiesDirPresent }; for (const entry of fs.readdirSync(COMPANIES_DIR, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const companyJsonPath = path.join(COMPANIES_DIR, entry.name, 'company.json'); if (!fs.existsSync(companyJsonPath)) continue; // 没有 company.json → 还不是一家公司 liveCompanyIds.add(entry.name); try { const companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); if (Array.isArray(companyJson.agents)) { for (const roleName of companyJson.agents) claimedRoles.add(roleName); } } catch { // company.json 坏了 → 公司仍然算活着(目录在),但读不出 agents[]。 // 这时宁可少回收:把整个 claim 判断放宽是危险的,只能退而求其次靠 fs 存在性兜底。 } } // company_agents 的 active 行(只算还活着的公司,否则幽灵公司的行会让回收永远不生效) for (const row of getDb() .prepare(`SELECT company_id, system_agent_id, role_name FROM company_agents WHERE status = 'active'`) .all()) { if (!liveCompanyIds.has(row.company_id)) continue; claimedRoles.add(row.system_agent_id); claimedRoles.add(row.role_name); } return { claimedRoles, liveCompanyIds, companiesDirPresent }; } /** * 一行 system_agents 是不是「悬空的 created 行」。三个条件必须同时成立,缺一不回收: * 1. origin === 'created'(builtin 行永不参与回收 —— 那是仓库自带的本体, * 磁盘上有没有目录轮不到这里判); * 2. legacy_path 指向的目录**不存在**(真实存在的公司私有 agent 一律不动 —— 这是防误伤的主闸); * 3. 没有任何活着的公司点名这个 id(判据见 collectLiveCompanyClaims) * (目录一时缺失但公司还声明着它 → 视为安装损坏,保留原行,交给人处理,不擅自改语义)。 * * 条件 2 仍然读 legacy_path 而不是从 id 推路径:存量行里还留着批次 4 之前写下的公司侧路径, * 而那正是这条判据要认出来的「指着一个早已不存在的目录」。新写入的行 legacy_path 一律是 * `system/agents/{id}`,与从 id 推导出的结果一致,两种形态在这里的判定结果相同。 * * @param {{id: string, origin: string, legacy_path: string}} row * @param {Set} claimedRoles collectLiveCompanyClaims().claimedRoles */ function isStaleCreatedRow(row, claimedRoles) { if (!row || row.origin !== 'created') return false; if (claimedRoles.has(row.id)) return false; if (!row.legacy_path) return true; return !fs.existsSync(path.resolve(ROOT, row.legacy_path)); } /** * 一行 builtin 是不是「本体在磁盘上哪儿都找不到」。 * * 「builtin 行永不参与回收」那条原则(见 isStaleCreatedRow 的条件 1)成立的前提是 * **builtin 行只可能由目录扫描种出来**,所以目录一时读不到就当安装损坏、不擅自删。 * 28 批次 1 的**目录改名**打破了这个前提:`system/templates/base` → `system/agents/_base` * 之后,库里那行 `id='base'`(legacy_path 指着已经不存在的 `system/templates/`) * 再也不会被任何一次扫描碰到 —— 没有任何一条路径能改它、也没有任何一条能删它, * 而 verifyCounts() 每跑一次都会因为它多报一行 builtin。改名残渣只能在这里收。 * * 判据故意收得很窄,两条任一成立才算残渣: * 1. id 是**保留 id**(`_` / `.` 开头)—— 按 §5.1 它压根不是可调用 agent,入表即错; * 2. `system/agents/{id}/` **和** legacy_path 指向的目录**都不存在** —— 本体的两个可能 * 落点(新地址 + 存量地址)同时落空,才敢判定它没有磁盘背书。 * 目录在、只是缺 agent.md 的(历史上的 `system/agents/templates/` 那类数据碎片)**不删**: * 那是「本体不完整」,该由人去补,不是这里替他做决定;verifyCounts 会把它报成 mismatch。 * 同样沿用 created 那条防误伤闸门:还有活着的公司点名这个 id 就不动。 * * @param {{id: string, origin: string, legacy_path: string}} row * @param {Set} claimedRoles collectLiveCompanyClaims().claimedRoles */ function isOrphanBuiltinRow(row, claimedRoles) { if (!row || row.origin !== 'builtin') return false; if (row.id.startsWith('_') || row.id.startsWith('.')) return true; if (claimedRoles.has(row.id)) return false; if (fs.existsSync(path.join(AGENTS_DIR, row.id))) return false; if (row.legacy_path && fs.existsSync(path.resolve(ROOT, row.legacy_path))) return false; return true; } /** * 幂等的维护步骤:清掉三类悬空数据 + 两项存量对账。跑第二遍时所有计数都是 0。 * * A. **已删公司的 org_graph_nodes / org_graph_edges / company_agents**:company_id 在磁盘上 * 已经没有 `data/companies/{cid}/company.json`。先清这一类,否则 B 里要删的 system_agents 行 * 还被这些幽灵节点外键引着,删不掉。 * `company_agents` 是批次 4-19 之后**唯一被读的名册**(listCompanyAgents), * 而 `heartbeat-service.start()` 拿它挂心跳时不查公司还在不在 —— 漏了这张表的话, * 一家被删掉的公司会在**每次服务启动**时把自己的员工重新挂上定时心跳, * 每个定时器到点都真起一次 Claude CLI。实测生产库里已经积了 10 行这样的幽灵 * (单测建的 tmp-* 公司,目录删了、行还在),而且没有任何一处会报错。 * collectLiveCompanyClaims 早就知道这些行是脏的(它按 liveCompanyIds 把它们跳过), * 只是从来没人把它们删掉。 * B. **悬空的 created 行**(判定见 isStaleCreatedRow)。 * C. **磁盘上没有本体的 builtin 行**(判定见 isOrphanBuiltinRow)——28 批次 1 目录改名的残渣。 * D. **图基础设施本体的孤儿 org 节点**(`authored_by IS NULL` + type ∈ NON_ORG_AGENT_TYPES)—— * 守卫漂移期间被 seedCompanyAgents 塞回来的那批,见该分支的注释。 * E. **存量本体的毕业对账**:磁盘上定义已齐全、库里还挂着 'training' 的行转成 'active'。 * * D/E 不是「顺手清洁」:它们各自堵住一条**新建公司必踩**的失效 —— * D 会让一个谁也点不亮的节点混进 org run,E 会让 code-planner 这类现役本体永远雇不到。 * * 安全阀: * - `data/companies/` 整个目录都不存在时**跳过 A**。分不清「一家公司都没有」和 * 「目录没挂上」,宁可不删。 * - 每行删除单独 try/catch:真被别处外键引着(work_graph_nodes.system_agent_id、 * work_graphs.owner_org_node_id 等)就保留并记进 retained,不让一行删不掉拖垮整轮启动。 * 这样以后新增引用表也不需要回来改这里。 * * @returns {{ orgNodesRemoved: number, orgEdgesRemoved: number, companyAgentsRemoved: number, ghostCompanyIds: string[], * agentsRemoved: string[], builtinsRemoved: string[], * orphanNonOrgNodesRemoved: string[], graduated: string[], * retained: {id: string, reason: string}[] }} */ function reconcileDanglingRows() { const db = getDb(); const { claimedRoles, liveCompanyIds, companiesDirPresent } = collectLiveCompanyClaims(); const ghostCompanyIds = []; let orgNodesRemoved = 0; let orgEdgesRemoved = 0; let companyAgentsRemoved = 0; const agentsRemoved = []; const builtinsRemoved = []; const orphanNonOrgNodesRemoved = []; const graduated = []; const retained = []; // ── A. 已删公司的 org 节点/边 ── if (companiesDirPresent) { // 两张表各自的 company_id 都要扫:一家公司可能只留下 company_agents 行而没有 org 节点 // (基础设施角色不入图,见 decideOrgNodeRecording),只扫 org_graph_nodes 会整家漏掉 —— // 而漏掉的恰恰是心跳会去挂的那张表。 const ghostIds = new Set(); for (const t of ['org_graph_nodes', 'company_agents']) { for (const r of db.prepare(`SELECT DISTINCT company_id FROM ${t}`).all()) { if (!liveCompanyIds.has(r.company_id)) ghostIds.add(r.company_id); } } for (const companyId of ghostIds) { ghostCompanyIds.push(companyId); try { db.transaction(() => { orgEdgesRemoved += db.prepare(`DELETE FROM org_graph_edges WHERE company_id = ?`).run(companyId).changes; // reports_to_node_id 是同表自引用,整批删之前先解开,免得删除顺序踩到外键 db.prepare(`UPDATE org_graph_nodes SET reports_to_node_id = NULL WHERE company_id = ?`).run(companyId); orgNodesRemoved += db.prepare(`DELETE FROM org_graph_nodes WHERE company_id = ?`).run(companyId).changes; companyAgentsRemoved += db.prepare(`DELETE FROM company_agents WHERE company_id = ?`).run(companyId).changes; })(); } catch (err) { retained.push({ id: `ghost_company[${companyId}]`, reason: err.message }); } } } // ── B. 悬空 created 行 ── const createdRows = db .prepare(`SELECT id, origin, legacy_path FROM system_agents WHERE origin = 'created'`) .all(); for (const row of createdRows) { if (!isStaleCreatedRow(row, claimedRoles)) continue; try { db.prepare(`DELETE FROM system_agents WHERE id = ? AND origin = 'created'`).run(row.id); agentsRemoved.push(row.id); } catch (err) { // 还被别的表外键引着 → 留着。seedAgents() 的接管分支能把其中有本体目录的 id 就地转正, // 不需要先删成功。 retained.push({ id: row.id, reason: err.message }); } } // ── C. 磁盘上没有本体的 builtin 行(目录改名残渣)── const builtinRows = db .prepare(`SELECT id, origin, legacy_path FROM system_agents WHERE origin = 'builtin'`) .all(); for (const row of builtinRows) { if (!isOrphanBuiltinRow(row, claimedRoles)) continue; try { db.prepare(`DELETE FROM system_agents WHERE id = ? AND origin = 'builtin'`).run(row.id); builtinsRemoved.push(row.id); } catch (err) { retained.push({ id: row.id, reason: err.message }); } } // ── D. 图基础设施本体的孤儿 org 节点(本轮修复)── // // 存量:`seedCompanyAgents()` 在守卫漂移期间(只认 meta-bootstrap)把 meta-planning 本体 // 写进了 org_graph_nodes。实测生产库里 mycompany 有 5 行这样的节点 // (workgraph-planning / brd-planning / prd-planning / sdd-planning / goal-benchmark)。 // 判据收窄到 `authored_by IS NULL`:那是「没有任何一次显式动作认领过它」的形态 // (hire 写 'hire'、writeGraph 写 'planner',补种脚本两个都不写)。planner 真把某个 // meta 本体画进图里是一次人/规划 agent 的显式决定,不在这里替他撤销。 // 不按 status 过滤(软删的 'removed' 行也收):verifyCounts 的节点计数本来就不分状态, // 留着一行软删的孤儿会让对账继续报 mismatch —— 清理和对账必须看到同一批行。 for (const node of db.prepare( `SELECT id, company_id, role_name FROM org_graph_nodes WHERE authored_by IS NULL` ).all()) { if (!isNonOrgAgentRole(node.company_id, node.role_name)) continue; try { db.transaction(() => { // reports_to_node_id 是同表自引用,删之前先解开(与 A 分支同一个坑) db.prepare(`UPDATE org_graph_nodes SET reports_to_node_id = NULL WHERE reports_to_node_id = ?`).run(node.id); db.prepare(`DELETE FROM org_graph_nodes WHERE id = ?`).run(node.id); })(); orphanNonOrgNodesRemoved.push(`${node.company_id}/${node.role_name}`); } catch (err) { // 还被 org_graph_edges / work_graphs.owner_org_node_id 引着 → 留着交给人处理。 // 真被边引着说明它已经在某张图的执行流上,删掉会把那张图打断,比留着更糟。 retained.push({ id: `org_node[${node.company_id}/${node.role_name}]`, reason: err.message }); } } // ── E. 存量本体的毕业对账(本轮修复)── // // 背景:闸门判据里 SKILL.md 从「必需」放宽成「存在才查」之后,一批 config.json 写着 // status='training' 的**存量**行其实早就齐全了 —— `code-planner` 是被这条救回来的那个: // 它是 p2-verify 在跑的现役本体,老公司靠 company_agents 已有行走 `already_exists` 分支 // 才没炸,而**任何新建的公司都雇不到它**(hire 阶段 A 直接 body_not_ready)。 // // 只转 'training' 不转 'draft':'draft' 是 hire 热路径上 ensureSystemAgentRow 刚铸的行, // 它的转正归 promoteDraftIfComplete(「谁来问上岗资格,就现场数一次文件」), // seed 是个后台批处理,不该替一个可能正在被 agent-creator 写着的新本体做上岗判定。 // 而 'training' 的来源是磁盘上的 config.json,seed 本来就是它的入库通道。 // 兜底还有哨兵:刚从 `_base` 拷出来的本体三份定义都带 BASE_SKELETON_SENTINEL, // missingBodyDefinitionIn 一律判缺 —— 上一轮刚立起来的闸门不会被这条重新废掉。 for (const row of db.prepare(`SELECT id, status FROM system_agents WHERE status = 'training'`).all()) { const missing = missingBodyDefinitionIn(path.join(AGENTS_DIR, row.id)); if (missing.length > 0) continue; db.prepare(`UPDATE system_agents SET status = 'active', updated_at = ? WHERE id = ? AND status = 'training'`) .run(new Date().toISOString(), row.id); graduated.push(row.id); } return { orgNodesRemoved, orgEdgesRemoved, companyAgentsRemoved, ghostCompanyIds, agentsRemoved, builtinsRemoved, orphanNonOrgNodesRemoved, graduated, retained, }; } /** * 扫描 system/agents/*(目录扫描才是数据源头),逐个 upsert 到 system_agents, * scope='public',origin='builtin'。判据见 isAgentBodyDir:`_` 开头的保留 id 与 * 没有 agent.md 的数据碎片都不算本体。 * * `_registry.json` 只用来补充可选的 benchmark_score:按 id 匹配上的条目用其 * `benchmarkScore` 字段(registry 里该字段本身可能是 null),匹配不上的一律留 NULL。 * 容忍本体目录完全没有 config.json:缺失时 name 默认目录名、status 默认 'active'。 * status 是**只写不降级**的(见循环里那段注释):已经毕业的行不会被 config.json 的 * 出生默认值推回 draft/training。 * * Idempotent: 用 INSERT ... ON CONFLICT(id) DO UPDATE,重复执行不产生重复行。 * * **id 被 created 行占着时**(批次 4 之前在公司私有目录里雇出来的 agent,与本体重名): * - 那行还活着(目录在,或某家活着的公司仍在 agents[] 里点名它)→ **跳过**,不覆盖。 * 公司正拿着自己那份私有拷贝在跑,把行改成 builtin 会让 lookupSharedAgentMd 开始 * 返回本体路径,等于在公司毫不知情的情况下换掉它的 agent.md。这个死角由批次 4/5 * (把私有拷贝毕业成本体)根治,不在这里硬来。 * - 那行是**悬空的**(isStaleCreatedRow)→ 就地转正成 builtin:scope / owner_company_id / * origin / legacy_path 全部改写。原来的「一律 skip」让污染不可逆: * 公司目录删了、行还占着本体 id,本体永远补不回来。转正而不是删了重建, * 是为了让还引着这行的 org_graph_nodes 外键继续有效。 * * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[], * skipped: {id: string, existingOrigin: string}[], reclaimed: string[] }} */ function seedAgents() { const db = getDb(); const entries = fs.existsSync(AGENTS_DIR) ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) : []; const bodyDirs = entries.filter(d => isAgentBodyDir(d.name)); let registryById = new Map(); if (fs.existsSync(AGENTS_REGISTRY_PATH)) { try { const registry = JSON.parse(fs.readFileSync(AGENTS_REGISTRY_PATH, 'utf-8')); if (Array.isArray(registry)) { registryById = new Map(registry.map(entry => [entry.id, entry])); } } catch { registryById = new Map(); // malformed _registry.json → no enrichment, still seed dirs } } const now = new Date().toISOString(); // status 也要取:ON CONFLICT 分支要拿它判「这行已经毕业了,别被 config.json 推回去」 const findExisting = db.prepare(`SELECT id, origin, legacy_path, status FROM system_agents WHERE id = ?`); // ON CONFLICT 分支必须连 scope/owner_company_id/origin 一起改写,接管才算完成: // 只改 legacy_path 会留下一行「legacy_path 指向 system/agents/ 却仍标着 created」的半吊子记录。 // 对本来就是 builtin 的行这几列是原值写原值。 const upsert = db.prepare(` INSERT INTO system_agents (id, name, scope, owner_company_id, origin, legacy_path, status, benchmark_score, created_at, updated_at) VALUES (@id, @name, 'public', NULL, 'builtin', @legacy_path, @status, @benchmark_score, @created_at, @updated_at) ON CONFLICT(id) DO UPDATE SET name = excluded.name, scope = excluded.scope, owner_company_id = excluded.owner_company_id, origin = excluded.origin, legacy_path = excluded.legacy_path, status = excluded.status, benchmark_score = excluded.benchmark_score, updated_at = excluded.updated_at `); const { claimedRoles } = collectLiveCompanyClaims(); const ids = []; const registryMatched = []; const skipped = []; const reclaimed = []; const seedAll = db.transaction((dirEntries) => { for (const entry of dirEntries) { const id = entry.name; const existing = findExisting.get(id); if (existing && existing.origin !== 'builtin') { if (isStaleCreatedRow(existing, claimedRoles)) { reclaimed.push(id); // 悬空的 created 占位 → 下面的 upsert 就地转正成 builtin } else { skipped.push({ id, existingOrigin: existing.origin }); continue; // id 还被一个活着的公司私有 agent 占着 —— 别动它 } } const dir = path.join(AGENTS_DIR, id); const configPath = path.join(dir, 'config.json'); let config = null; if (fs.existsSync(configPath)) { try { config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } catch { config = null; // malformed config.json → fall back to defaults } } const name = (config && config.name) || id; const configStatus = (config && config.status) || 'active'; // **只写不降级**:config.json 里的 status 是本体的**出生默认值**,而毕业与否是运行时 // 从磁盘定义学到的事实(promoteDraftIfComplete / reconcile 的 E 分支)。原样 upsert 的话, // 每次进程重启 seedAgents 都会把已经转正的行按 config.json 打回 draft/training —— // 闸门抬起来一次、下次启动又落下去,而且一处报错都没有(真实撞过的样本是 code-planner, // 它的 config.json 当时写着 "training",见 `graduation-criteria-reality.test.js`; // 后来 config.json 已经改成了毕业态的 "enabled",这条注释只留问题形状,不再钉死具体值, // 避免又变成一处会随源码变化过期的死引用)。所以:库里已经脱离未毕业域的行,不许再被推回去;其余照写。 const status = (existing && isUngraduatedStatus(configStatus) && !isUngraduatedStatus(existing.status)) ? existing.status : configStatus; const registryEntry = registryById.get(id); const benchmark_score = registryEntry ? (registryEntry.benchmarkScore ?? null) : null; if (registryEntry) registryMatched.push(id); upsert.run({ id, name, legacy_path: `system/agents/${id}`, status, benchmark_score, created_at: now, updated_at: now, }); ids.push(id); } }); seedAll(bodyDirs); return { scanned: entries.length, seeded: ids.length, ids, registryMatched, skipped, reclaimed }; } /** * 扫描 data/companies/{id}/company.json,对每个 company 的 `agents[]`(角色名字符串 * 扁平数组)逐个: * - 若 system_agents 里已存在同 id 的行(seedAgents() 种下的 builtin 本体)→ * 只建一条 org_graph_nodes 指过去。 * - 若不存在 → 先插入一条新的 system_agents 行(scope='company', * owner_company_id=companyId, origin='created',legacy_path='system/agents/{name}'), * 再建 org_graph_nodes。 * * `org_graph_edges` 本阶段留空——company.json 目前没有汇报关系数据,是预期状态,不是 bug。 * * ⚠️ 28 批次 4 起 `company_agents` 是公司名册的真相来源,本函数仍然读 `company.json.agents[]` * 而**不是**读表 —— 它是**从磁盘往库里补种**的入口(`node scripts/graph-seed.js` 手动跑, * 库丢了/新装时用)。改成读表会让它变成「拿库里的东西种回库里」的同义反复, * 那时它就一行也补不出来了。这是全仓唯一仍以 `agents[]` 为输入的地方,理由就是这个方向性。 * * Idempotent: * - system_agents 用 INSERT ... ON CONFLICT(id) DO NOTHING(created 行一旦建立 * 不应被后续扫描覆盖,行为上等同"首次发现即定型")。 * - org_graph_nodes 没有自然唯一键,靠先查后插(company_id + system_agent_id + role_name * 三元组)去重,避免重复跑脚本时行数上涨。 * * @returns {{ companies: string[], nodesCreated: number, nodesExisting: number, * newSystemAgents: string[], reusedSystemAgents: string[] }} */ function seedCompanyAgents() { const db = getDb(); const companyDirs = fs.existsSync(COMPANIES_DIR) ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) : []; const now = new Date().toISOString(); const findAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`); const insertAgent = db.prepare(` INSERT INTO system_agents (id, name, scope, owner_company_id, origin, legacy_path, status, benchmark_score, created_at, updated_at) VALUES (@id, @name, 'company', @owner_company_id, 'created', @legacy_path, 'active', NULL, @created_at, @updated_at) ON CONFLICT(id) DO NOTHING `); const findNode = db.prepare(` SELECT id FROM org_graph_nodes WHERE company_id = ? AND system_agent_id = ? AND role_name = ? `); const insertNode = db.prepare(` INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at) VALUES (@company_id, @system_agent_id, @role_name, @created_at) `); const companies = []; let nodesCreated = 0; let nodesExisting = 0; const newSystemAgents = []; const reusedSystemAgents = []; const seedAll = db.transaction((dirEntries) => { for (const entry of dirEntries) { const companyId = entry.name; const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json'); if (!fs.existsSync(companyJsonPath)) continue; // no company.json → not a real company yet let companyJson; try { companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); } catch { continue; // malformed company.json → skip, don't seed partial data } const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : []; companies.push(companyId); for (const roleName of roleNames) { // 图基础设施本体(meta-bootstrap 如 orggraph-planning、meta-planning 如 // workgraph-planning / prd-planning)不入 org graph —— 与 company-service.hire() // 的同名 guard 保持一致(判据同源,见 isNonOrgAgentRole),否则这个补种脚本会把 // hire() 特意排除掉的节点再塞回图里(27-p2-org-graph.md §4.1)。 if (isNonOrgAgentRole(companyId, roleName)) continue; const existing = findAgent.get(roleName); if (existing) { reusedSystemAgents.push(roleName); } else { insertAgent.run({ id: roleName, name: roleName, owner_company_id: companyId, // 28 批次 4:本体恒在 system/agents/{id},公司侧不再有第二个落点 legacy_path: `system/agents/${roleName}`, created_at: now, updated_at: now, }); newSystemAgents.push(roleName); } // role_name 目前恒等于 system_agent_id:company.json.agents[] 现在只是纯角色名字符串数组, // 没有独立的"角色名 vs 引用哪个 agent"区分。P2 引入 org_graph 编辑能力后两者会分开。 const existingNode = findNode.get(companyId, roleName, roleName); if (existingNode) { nodesExisting += 1; } else { insertNode.run({ company_id: companyId, system_agent_id: roleName, role_name: roleName, created_at: now, }); nodesCreated += 1; } } } }); seedAll(companyDirs); return { companies, nodesCreated, nodesExisting, newSystemAgents, reusedSystemAgents }; } /** * 独立行数交叉核对(T4)——不调用上面两个 seed 函数、不复用它们的返回计数器, * 而是从零重新扫描 system/agents/、data/companies/*\/company.json, * 按种子逻辑描述的同一套规则(本体目录优先于公司名册)独立推导出"应该"落在 * system_agents/org_graph_nodes 里的行数,再拿去和 DB 里 * `SELECT origin, COUNT(*) ... GROUP BY origin` 以及 * `SELECT company_id, COUNT(*) FROM org_graph_nodes GROUP BY company_id` 的真实结果比对。 * * 这样一来,如果 seedAgents/seedCompanyAgents 内部逻辑跑偏 * (例如 agent.md / `_` 前缀过滤条件写错、占位跳过逻辑失效、company 角色去重出错), * 这里独立算出来的期望值不会跟着一起错,能捕捉到。 * * ⚠️ **created 的口径在 28 二轮修复里改过一次,改的是对账、不是迁移契约**: * 原来的期望值是「company.json 里点了名、却没有本体目录的角色数」,在批次 4/5 落地后 * 恒等于 **0** —— 而 §七 M2 要求毕业出来的 org-coder / org-planner **保持 origin='created'**, * 未来 create-agent 造的本体也一律是 created。两条契约直接打架,结果是这个本来用来发现 * 真实漂移的告警在生产库上**确定性变红**,喊狼来了喊到没人看。 * 现在验的是「**每一行 created 都有磁盘背书**」:要么 `system/agents/{id}/` 就是它的本体目录 * (毕业 / create-agent 的正常形态),要么还有活着的公司在 company.json 里点名它 * (批次 4 之前雇的存量行,reconcile 也是按这条闸门放过它们的,两处口径必须一致)。 * 两者都不成立的 created 行才是真漂移 —— 那正是 reconcileDanglingRows 会回收的那一类。 * * builtin 的期望值随之要**扣掉被 created 行占着的本体 id**:seedAgents 对这些 id 是 skip * (不覆盖活着的 created 行),库里本来就不该有对应的 builtin 行。 * * 计数之外还做两条 **id 级**核对(计数相等不代表内容对得上,一多一少可以互相抵消): * 「builtin 行却没有本体目录」(`id='base'` 这种目录改名残渣就是这么被抓出来的) * 与「本体目录一行都没入库」。两条都把出事的 id 写进 check 字符串,脚本直接打出来。 * * @returns {{ ok: boolean, mismatches: Array<{check: string, expected: number, actual: number}>, * expected: { builtin: number, created: number, nodesPerCompany: Object }, * actual: { builtin: number, created: number, nodesPerCompany: Object } }} */ function verifyCounts() { const db = getDb(); // ── 独立重新扫描 system/agents/(判据与 isAgentBodyDir 一致,但独立实现)── const agentEntries = fs.existsSync(AGENTS_DIR) ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) : []; const bodyIds = new Set( agentEntries .filter(d => !d.name.startsWith('_') && !d.name.startsWith('.')) .filter(d => fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md'))) .map(d => d.name) ); // ── 独立重新扫描 data/companies/*/company.json ── const companyDirs = fs.existsSync(COMPANIES_DIR) ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) : []; const expectedCreatedIds = new Set(); const expectedNodesPerCompany = {}; for (const entry of companyDirs) { const companyId = entry.name; const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json'); if (!fs.existsSync(companyJsonPath)) continue; let companyJson; try { companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); } catch { continue; // 与 seedCompanyAgents 一致:损坏的 company.json 不参与核对 } const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : []; // 必须镜像 seedCompanyAgents 的过滤:那边对图基础设施角色是 `continue`(见 // isNonOrgAgentRole 的调用点),既不落 org_graph_nodes 也不落 system_agents。 // 这里以前把它算进期望值,于是**任何一家公司的 agents[] 里出现一个 meta-bootstrap // 角色,对账就确定性变红**:实际发生过 —— mycompany 的 10 个角色里 orggraph-planning // 是 meta-bootstrap,报 expected 10 / actual 9。 // 复用同一个函数而不是复制那个 type 判断:两份判据迟早会漂 —— 而这次漂的方向更坏, // 是**两边一致地错**(seed 塞回孤儿节点、对账跟着把它算进期望值),全程零告警。 const distinctRoles = new Set(roleNames.filter((r) => !isNonOrgAgentRole(companyId, r))); // org_graph_nodes 按 (company_id, system_agent_id, role_name) 去重 expectedNodesPerCompany[companyId] = distinctRoles.size; for (const roleName of distinctRoles) { if (!bodyIds.has(roleName)) { expectedCreatedIds.add(roleName); } } } // ── DB 里的真实行(要 id 清单,不只是计数:见函数头「id 级核对」)── const rowsByOrigin = db.prepare(`SELECT id, origin FROM system_agents`).all(); const builtinRowIds = new Set(rowsByOrigin.filter(r => r.origin === 'builtin').map(r => r.id)); const createdRowIds = new Set(rowsByOrigin.filter(r => r.origin === 'created').map(r => r.id)); const actualCounts = { builtin: builtinRowIds.size, created: createdRowIds.size }; const nodesByCompany = db.prepare(` SELECT company_id, COUNT(*) AS cnt FROM org_graph_nodes GROUP BY company_id `).all(); const actualNodesPerCompany = {}; for (const row of nodesByCompany) { actualNodesPerCompany[row.company_id] = row.cnt; } // 合法的 created 行:有本体目录(毕业 / create-agent 的正常形态), // 或还被活着的公司名册点名(批次 4 之前的存量行,与 reconcile 的防误伤闸门同口径)。 const expectedCreated = new Set(expectedCreatedIds); for (const id of createdRowIds) { if (bodyIds.has(id)) expectedCreated.add(id); } // 被 created 行占着的本体 id:seedAgents 对它们是 skip,库里不该有对应的 builtin 行 const expectedBuiltin = new Set([...bodyIds].filter(id => !createdRowIds.has(id))); // ── 比对 ── const mismatches = []; if (actualCounts.builtin !== expectedBuiltin.size) { mismatches.push({ check: "system_agents COUNT(*) WHERE origin='builtin'", expected: expectedBuiltin.size, actual: actualCounts.builtin }); } if (actualCounts.created !== expectedCreated.size) { mismatches.push({ check: "system_agents COUNT(*) WHERE origin='created'", expected: expectedCreated.size, actual: actualCounts.created }); } const builtinWithoutBody = [...builtinRowIds].filter(id => !bodyIds.has(id)).sort(); if (builtinWithoutBody.length > 0) { mismatches.push({ check: `system_agents 有 origin='builtin' 却在 system/agents/ 下没有本体(含 agent.md)的行: ${builtinWithoutBody.join(', ')}`, expected: 0, actual: builtinWithoutBody.length, }); } const createdWithoutBacking = [...createdRowIds].filter(id => !expectedCreated.has(id)).sort(); if (createdWithoutBacking.length > 0) { mismatches.push({ check: `system_agents 有 origin='created' 却既无本体目录、也没有活着的公司点名的行: ${createdWithoutBacking.join(', ')}`, expected: 0, actual: createdWithoutBacking.length, }); } const bodyWithoutRow = [...bodyIds].filter(id => !builtinRowIds.has(id) && !createdRowIds.has(id)).sort(); if (bodyWithoutRow.length > 0) { mismatches.push({ check: `system/agents/ 下有本体目录却一行都没入库: ${bodyWithoutRow.join(', ')}`, expected: 0, actual: bodyWithoutRow.length, }); } const allCompanyIds = new Set([...Object.keys(expectedNodesPerCompany), ...Object.keys(actualNodesPerCompany)]); for (const companyId of allCompanyIds) { const expected = expectedNodesPerCompany[companyId] || 0; const actual = actualNodesPerCompany[companyId] || 0; if (expected !== actual) { mismatches.push({ check: `org_graph_nodes COUNT(*) WHERE company_id='${companyId}'`, expected, actual }); } } return { ok: mismatches.length === 0, mismatches, expected: { builtin: expectedBuiltin.size, created: expectedCreated.size, nodesPerCompany: expectedNodesPerCompany, }, actual: { builtin: actualCounts.builtin, created: actualCounts.created, nodesPerCompany: actualNodesPerCompany, }, }; } /** * 服务启动时跑的那一组(P3B W1)。调用方:server/index.js、server/agent-server.js。 * * 跑 reconcileDanglingRows() → seedAgents(),**不跑 seedCompanyAgents()**。 * 取舍理由:前者的数据源是仓库自带的 `system/agents/` 目录,属于「代码的一部分」, * 每次启动按最新目录对齐才是对的;而 seedCompanyAgents() 的数据源是 * `data/companies/*\/company.json`,那是运行时状态,归 company-service 的 hire()/fire() 管, * 启动时从 company.json 反推 org_graph_nodes 会跟这两条写入路径抢所有权。它继续留给 * `node scripts/graph-seed.js` 手动跑。 * * 顺序不能换:reconcile 必须在 seedAgents 之前,否则悬空行还占着本体 id。 * 全同步、全幂等,50 个本体目录 + 20 家公司量级在毫秒级,放在 listen() 之前不影响启动。 * * @returns {{ reconciled: object, agents: object }} */ function runStartupSeed() { const reconciled = reconcileDanglingRows(); const agents = seedAgents(); return { reconciled, agents }; } /** * runStartupSeed() 的「失败不许挡住服务启动」包装 —— 两个 server 入口直接调这个。 * 种子失败只影响 agent 元数据的新鲜度,不该让整个进程起不来;打日志降级即可。 * * @returns {object|null} 成功时是 runStartupSeed() 的汇总,失败时 null */ function runStartupSeedSafely() { try { const summary = runStartupSeed(); const r = summary.reconciled; const parts = [`builtin 本体 ${summary.agents.seeded}`]; if (summary.agents.reclaimed.length) parts.push(`回收占位 id ${summary.agents.reclaimed.join('/')}`); if (summary.agents.skipped.length) parts.push(`⚠️ ${summary.agents.skipped.length} 个 id 仍被公司私有 agent 占着`); if (r.agentsRemoved.length) parts.push(`清悬空 created ${r.agentsRemoved.length} 行`); if (r.builtinsRemoved.length) parts.push(`清无本体 builtin ${r.builtinsRemoved.join('/')}`); if (r.orgNodesRemoved) parts.push(`清已删公司 org 节点 ${r.orgNodesRemoved} 行`); if (r.orphanNonOrgNodesRemoved.length) parts.push(`清图基础设施孤儿节点 ${r.orphanNonOrgNodesRemoved.join('/')}`); if (r.graduated.length) parts.push(`🎓 存量本体转正 ${r.graduated.join('/')}`); if (r.companyAgentsRemoved) parts.push(`清已删公司名册 ${r.companyAgentsRemoved} 行`); if (r.retained.length) parts.push(`⚠️ ${r.retained.length} 行被外键引用暂留`); console.log(`[graph-seed] ${parts.join(',')}`); return summary; } catch (err) { console.error(`[graph-seed] 启动种子失败,已降级跳过(不影响服务启动): ${err.message}`); return null; } } module.exports = { seedAgents, seedCompanyAgents, verifyCounts, reconcileDanglingRows, runStartupSeed, runStartupSeedSafely, };