Commit edeff2ec18c776c6ae4eed06c52f22b2ac8f7cad

Authored by yibo
1 parent 9891c3b7

fix(heartbeat): 心跳端点的第二个入口 —— 写本体、读公司态,写入静默无效

28-followups §3.3。`PUT /api/agent/:agentId/heartbeat`(前端 Agent 详情页走的就是它)
用 `getBodyDir(id)` = `bodyDir(parseAddress(id).agentId)` 把公司段丢掉,
然后**整份重写本体 config.json**。

两个后果,都不报错:
① 跨租户串写:co-a:ceo 改心跳,co-b:ceo 跟着变(同一份本体);
② 一旦该 (agent, 公司) 有了公司态文件,读侧优先取公司态 →
   **这个端点的写入对它变成静默无效**。界面收到 {success:true},
   实际在跑的还是旧值;GET /info 同理报的是本体缺省,不是在跑的那份。
   症状是「界面写 30 分钟、调度器按 15 分钟在跑」,两边都不报错。

company.js 那三个端点在 28 号已经修好了,这个入口漏了 —— 同一个 bug 的第二扇门。

## 改

读写都走 heartbeat-service 的 readHeartbeatConfig / writeHeartbeatConfig,
与心跳挂载读的是同一个答案。入参校验归服务(intervalMinutes 直接进 setInterval,
NaN / 0 / 负数会让定时器退化成每 tick 触发一次,而每次 tick 真起一轮 LLM),
非法值在写盘前抛,端点翻成 400。

顺带:404 闸门也改走服务的寻址。`cid:role` 里冒号后面是**角色名**,未必等于本体 id
(company_agents 的映射)—— 拿角色名当本体 id 判存在性,凡是角色名 ≠ 本体 id 的
都会被判成「agent 不存在」,而写侧其实找得到。**判 404 和写入必须问同一个问题。**

## 守卫

heartbeat-company-scoped.test.js 补 3 例(放进同一个 describe 而不是另起一个:
夹具共用,另起的话上面那个 afterAll 会先把 server 关掉,实测 ECONNREFUSED):
· 写公司态、本体字节不动,且**写完立刻能从读侧读回来** —— 这条才是「静默无效」的精确形态;
· GET /info 报的是在跑的那份,不是本体缺省;
· 非法 intervalMinutes → 400 且旧值还在。
反向验过:把端点退回旧写法,这 3 条立刻红。

## 记一条没修的

GET /info 的 404 闸门仍是 `getBodyDir(id)`,对 `cid:role` 会直接 404 ——
那是 /info 整体寻址的既有缺口(它还要按同一个目录读 identity / agent.md / cron),
不在心跳这一条的范围内。测试里用裸 agent id 绕开,并在注释里写明别当成修过了。

全量:138 文件 / 1292 测试全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server/routes/agent-chat.js
... ... @@ -1300,42 +1300,58 @@ router.get(&#39;/:agentId/info&#39;, (req, res) =&gt; {
1300 1300 ...identity,
1301 1301 type: config?.type,
1302 1302 status: config?.status,
1303   - heartbeat: config?.heartbeat || { enabled: false, intervalMinutes: 15 },
  1303 + // 心跳读**公司态优先**(28-followups §3.3)—— 与 _setupAgentHeartbeat 读的是同一个函数。
  1304 + // 直接读本体 config.json 的话,(agent, 公司) 一旦设过公司态,这里显示的就不是实际在跑的值:
  1305 + // 界面写「30 分钟」而调度器按本体缺省的 15 分钟在跑,且两边都不报错。
  1306 + heartbeat: require('../services/heartbeat-service').readHeartbeatConfig(req.params.agentId),
1304 1307 cron: cron,
1305 1308 bootstrapFiles: ['agent.md','soul.md','identity.md','user.md','tools.md','heartbeat.md', 'cron.json'].filter(f => loadFile(agentDir, f).trim().length > 0),
1306 1309 });
1307 1310 });
1308 1311  
  1312 +/**
  1313 + * 心跳配置写入 —— **落公司态,不回写本体**(28-followups §3.3)。
  1314 + *
  1315 + * 原来这里是 `getBodyDir(id)` → 整份重写本体 `config.json`,公司段在寻址第一步就丢了。
  1316 + * 两个后果,都不报错:
  1317 + * ① 跨租户串写:`co-a:ceo` 改心跳,`co-b:ceo` 跟着变(同一份本体);
  1318 + * ② 一旦该 (agent, 公司) 有了公司态文件,读侧优先取公司态 → **这个端点的写入静默无效**,
  1319 + * 界面照样收到 `{success:true}`,而实际在跑的还是旧值。
  1320 + *
  1321 + * 现在读写都走 heartbeat-service 那**一对**函数,与心跳挂载读的是同一个答案。
  1322 + * 入参校验也归它(intervalMinutes 直接进 setInterval,NaN / 0 / 负数会让定时器退化成
  1323 + * 每 tick 触发一次)—— 非法值在写盘前抛,这里翻成 400。
  1324 + */
1309 1325 router.put('/:agentId/heartbeat', (req, res) => {
  1326 + const agentId = req.params.agentId;
1310 1327 try {
1311   - const agentDir = getBodyDir(req.params.agentId);
1312   - if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' });
1313   -
1314   - const configPath = path.join(agentDir, 'config.json');
1315   - let config = {};
1316   - if (fs.existsSync(configPath)) {
1317   - config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1318   - }
1319   -
1320   - config.heartbeat = {
1321   - enabled: req.body.enabled || false,
1322   - intervalMinutes: req.body.intervalMinutes || 15
1323   - };
1324   -
1325   - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
1326   -
1327   - // Update heartbeat service if running
1328 1328 const heartbeatService = require('../services/heartbeat-service');
1329   - // Using namespacedId logic from UI (`system:master` is skipped, only `company:role` or pure system agent like `master`)
1330   - const agentId = req.params.agentId;
1331   - if (config.heartbeat.enabled) {
  1329 + // 存在性检查走服务自己的寻址:`cid:role` 里冒号后面是**角色名**,未必等于本体 id。
  1330 + // 用 getBodyDir(id)(= 拿角色名当本体 id)判的话,凡是角色名 ≠ 本体 id 的都会被
  1331 + // 判成「agent 不存在」,而写侧其实找得到 —— 判 404 和写入必须问同一个问题。
  1332 + if (!fs.existsSync(heartbeatService.bodyDirOf(agentId))) {
  1333 + return res.status(404).json({ error: 'Agent not found' });
  1334 + }
  1335 +
  1336 + let heartbeat;
  1337 + try {
  1338 + heartbeat = heartbeatService.writeHeartbeatConfig(agentId, {
  1339 + enabled: req.body.enabled || false,
  1340 + intervalMinutes: req.body.intervalMinutes,
  1341 + });
  1342 + } catch (err) {
  1343 + return res.status(400).json({ error: err.message }); // 入参非法,不是服务器故障
  1344 + }
  1345 +
  1346 + // 定时器跟着改:开着的重挂(_setupAgentHeartbeat 内部会先清旧的),关掉的摘掉
  1347 + if (heartbeat.enabled) {
1332 1348 heartbeatService._setupAgentHeartbeat(agentId);
1333 1349 } else if (heartbeatService.timers.has(agentId)) {
1334 1350 clearInterval(heartbeatService.timers.get(agentId));
1335 1351 heartbeatService.timers.delete(agentId);
1336 1352 }
1337   -
1338   - res.json({ success: true, heartbeat: config.heartbeat });
  1353 +
  1354 + res.json({ success: true, heartbeat });
1339 1355 } catch (err) {
1340 1356 res.status(500).json({ error: err.message });
1341 1357 }
... ...
server/services/heartbeat-service.js
... ... @@ -109,6 +109,16 @@ class HeartbeatService {
109 109 }
110 110  
111 111 /**
  112 + * 这个地址对应的本体目录。**存在性检查必须用它**,不能自己再拼一次 ——
  113 + * `cid:role` 里冒号后面是角色名,未必等于本体 id(company_agents 的映射)。
  114 + * 端点用 `bodyDir(parseAddress(id).agentId)` 判 404 的话,凡是角色名 ≠ 本体 id 的
  115 + * 都会被判成「agent 不存在」,而写侧其实找得到。
  116 + */
  117 + bodyDirOf(addr) {
  118 + return getAgentDir(addr);
  119 + }
  120 +
  121 + /**
112 122 * 这个 `(agent, 公司)` 的有效心跳配置:公司态优先,缺字段回落本体缺省。
113 123 * **读的一侧只有这一份实现** —— 端点和心跳挂载读的必须是同一个答案,
114 124 * 否则会出现「控制台显示 30 分钟、实际按本体的 15 分钟在跑」这种不报错的错位。
... ...
server/tests/unit/heartbeat-company-scoped.test.js
... ... @@ -8,6 +8,11 @@
8 8 * `system/agents/orggraph-planning/config.json` 照样多出一行 `"intervalMinutes": 15`
9 9 * (这条 diff 就是本测试的出处)。同类事故第一轮已经修过一次:公司对话内容写进共享 SKILL.md。
10 10 *
  11 + * **端点有两个入口**(28-followups §3.3):除了 `/api/company/...` 这三个,
  12 + * 还有前端 Agent 详情页走的 `PUT /api/agent/:agentId/heartbeat`。后者当时没改,
  13 + * 症状比串写更隐蔽:公司态文件一旦存在,读侧优先取公司态 → **这个端点的写入静默无效**,
  14 + * 界面收到 `{success:true}`、显示的却不是实际在跑的值。下面 §二 那组专门盯它。
  15 + *
11 16 * 三条断言各锁一个方向:
12 17 * ① 写 co-A 之后**本体 config.json 的 mtime 与内容一个字节没变**(危害全在磁盘上,只看响应码看不出来);
13 18 * ② co-B 读到的是它自己的值,不是 co-A 的(跨公司串味);
... ... @@ -65,14 +70,14 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
65 70 fs.writeFileSync(path.join(BODY_DIR, 'soul.md'), '# 人格\n\n沉默寡言,只负责被测试。\n');
66 71 fs.writeFileSync(BODY_CONFIG, BODY_CONFIG_TEXT);
67 72 // 上一轮跑剩的公司态不能留,否则③的回落断言会读到旧值而空过
68   - for (const cid of [CO_A, CO_B]) fs.rmSync(companyStateDir(cid), { recursive: true, force: true });
  73 + for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true });
69 74  
70 75 // 2. 一次性 graph.sqlite + 两家公司的雇佣关系
71 76 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
72 77 process.env.GRAPH_DB_PATH = TEST_DB_PATH;
73 78 for (const m of ['../../services/graph-db', '../../services/graph-service',
74 79 '../../services/org-graph-service', '../../services/company-service',
75   - '../../services/heartbeat-service', '../../routes/company']) {
  80 + '../../services/heartbeat-service', '../../routes/company', '../../routes/agent-chat']) {
76 81 delete require_.cache[require_.resolve(m)];
77 82 }
78 83 const graphDb = require_('../../services/graph-db');
... ... @@ -92,6 +97,7 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
92 97 const app = express();
93 98 app.use(express.json());
94 99 app.use('/api/company', require_('../../routes/company'));
  100 + app.use('/api/agent', require_('../../routes/agent-chat')); // §二 那组用的第二个入口
95 101 await new Promise((resolve) => {
96 102 srv = app.listen(0, '127.0.0.1', () => {
97 103 base = `http://127.0.0.1:${srv.address().port}`;
... ... @@ -104,7 +110,8 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
104 110 if (srv) await new Promise((r) => srv.close(r));
105 111 heartbeatService?.stop(); // 端点挂上的 setInterval 会吊住 vitest 进程
106 112 // 只清公司态(运行态、gitignore);本体留在 system/agents/ 里当正式注册表条目
107   - for (const cid of [CO_A, CO_B]) fs.rmSync(companyStateDir(cid), { recursive: true, force: true });
  113 + // `_global` 是裸 agent id 的公司态落点(GET /info 那条用它),同样要清
  114 + for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true });
108 115 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
109 116 delete process.env.GRAPH_DB_PATH;
110 117 });
... ... @@ -168,4 +175,60 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
168 175 expect(res400.status).toBe(400);
169 176 expect(heartbeatService.readHeartbeatConfig(`${CO_A}:${ROLE}`).intervalMinutes).toBe(30);
170 177 });
  178 +
  179 + // ── 第二个入口:PUT / GET /api/agent/:agentId/heartbeat(28-followups §3.3)──
  180 + // 放在同一个 describe 里而不是另起一个:夹具(express 实例、本体、公司态)是共用的,
  181 + // 另起 describe 的话上面那个 afterAll 会先把 server 关掉 —— 实测 ECONNREFUSED。
  182 + const addrA = `${CO_A}:${ROLE}`;
  183 +
  184 + it('写公司态、不碰本体,且写完立刻能从读侧读回来(原来是静默无效)', async () => {
  185 + const before = fs.readFileSync(BODY_CONFIG, 'utf-8');
  186 + const beforeMtime = fs.statSync(BODY_CONFIG).mtimeMs;
  187 +
  188 + const res = await fetch(`${base}/api/agent/${addrA}/heartbeat`, {
  189 + method: 'PUT',
  190 + headers: { 'Content-Type': 'application/json' },
  191 + body: JSON.stringify({ enabled: false, intervalMinutes: 7 }),
  192 + });
  193 + expect(res.status).toBe(200);
  194 + expect((await res.json()).heartbeat).toMatchObject({ intervalMinutes: 7 });
  195 +
  196 + // 本体一个字节没动(修之前这里整份被重写,公司段在寻址第一步就丢了)
  197 + expect(fs.readFileSync(BODY_CONFIG, 'utf-8')).toBe(before);
  198 + expect(fs.statSync(BODY_CONFIG).mtimeMs).toBe(beforeMtime);
  199 +
  200 + // **这条才是「静默无效」的精确形态**:写完之后,心跳挂载读到的必须是新值。
  201 + // 修之前写的是本体、读的是公司态,这里会读回 30(上面 §一 留下的公司态值)。
  202 + expect(heartbeatService.readHeartbeatConfig(addrA)).toMatchObject({ intervalMinutes: 7 });
  203 + });
  204 +
  205 + it('GET /info 报的是实际在跑的那份,不是本体缺省', async () => {
  206 + // 用**裸 agent id**(落 `_global` 公司态)而不是 `cid:role`:/info 的 404 闸门
  207 + // 仍是 `getBodyDir(id)` = 拿角色名当本体 id,对 `cid:role` 会直接 404。
  208 + // 那是 /info 整体寻址的既有缺口(它还要按同一个目录读 identity / agent.md / cron),
  209 + // 不在本次心跳这一条的范围内 —— 记在这里,别当成已经修过。
  210 + const hb = require('../../services/heartbeat-service');
  211 + hb.writeHeartbeatConfig(BODY_ID, { enabled: false, intervalMinutes: 9 });
  212 +
  213 + const res = await fetch(`${base}/api/agent/${BODY_ID}/info`);
  214 + expect(res.status).toBe(200);
  215 + // 修之前读的是本体 config.json → 会报 42(本体缺省),而调度器按 9 在跑。
  216 + // 界面写一个数、系统按另一个数跑,两边都不报错。
  217 + expect((await res.json()).heartbeat).toMatchObject({ intervalMinutes: 9 });
  218 + });
  219 +
  220 + it('非法 intervalMinutes 拦在写盘之前(400,不是 500,也不是写进去再说)', async () => {
  221 + // 这个值直接进 setInterval:0 / 负数 / NaN 会让定时器退化成「每 tick 触发一次」,
  222 + // 而每次 tick 都真起一轮 LLM。
  223 + for (const bad of [0, -5, 'abc']) {
  224 + const res = await fetch(`${base}/api/agent/${addrA}/heartbeat`, {
  225 + method: 'PUT',
  226 + headers: { 'Content-Type': 'application/json' },
  227 + body: JSON.stringify({ enabled: true, intervalMinutes: bad }),
  228 + });
  229 + expect(res.status).toBe(400);
  230 + }
  231 + // 拦下来之后旧值必须还在(不能写一半)
  232 + expect(heartbeatService.readHeartbeatConfig(addrA)).toMatchObject({ intervalMinutes: 7 });
  233 + });
171 234 });
... ...