graph-seed-service.js
42.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
/**
* 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<string>, liveCompanyIds: Set<string>, 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<string>} 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<string>} 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")。所以:库里已经脱离未毕业域的行,不许再被推回去;其余照写。
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<string, number> },
* actual: { builtin: number, created: number, nodesPerCompany: Object<string, number> } }}
*/
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,
};