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,42 +1300,58 @@ router.get(&#39;/:agentId/info&#39;, (req, res) =&gt; {
1300 ...identity, 1300 ...identity,
1301 type: config?.type, 1301 type: config?.type,
1302 status: config?.status, 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 cron: cron, 1307 cron: cron,
1305 bootstrapFiles: ['agent.md','soul.md','identity.md','user.md','tools.md','heartbeat.md', 'cron.json'].filter(f => loadFile(agentDir, f).trim().length > 0), 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 router.put('/:agentId/heartbeat', (req, res) => { 1325 router.put('/:agentId/heartbeat', (req, res) => {
  1326 + const agentId = req.params.agentId;
1310 try { 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 const heartbeatService = require('../services/heartbeat-service'); 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 heartbeatService._setupAgentHeartbeat(agentId); 1348 heartbeatService._setupAgentHeartbeat(agentId);
1333 } else if (heartbeatService.timers.has(agentId)) { 1349 } else if (heartbeatService.timers.has(agentId)) {
1334 clearInterval(heartbeatService.timers.get(agentId)); 1350 clearInterval(heartbeatService.timers.get(agentId));
1335 heartbeatService.timers.delete(agentId); 1351 heartbeatService.timers.delete(agentId);
1336 } 1352 }
1337 -  
1338 - res.json({ success: true, heartbeat: config.heartbeat }); 1353 +
  1354 + res.json({ success: true, heartbeat });
1339 } catch (err) { 1355 } catch (err) {
1340 res.status(500).json({ error: err.message }); 1356 res.status(500).json({ error: err.message });
1341 } 1357 }
server/services/heartbeat-service.js
@@ -109,6 +109,16 @@ class HeartbeatService { @@ -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 * 这个 `(agent, 公司)` 的有效心跳配置:公司态优先,缺字段回落本体缺省。 122 * 这个 `(agent, 公司)` 的有效心跳配置:公司态优先,缺字段回落本体缺省。
113 * **读的一侧只有这一份实现** —— 端点和心跳挂载读的必须是同一个答案, 123 * **读的一侧只有这一份实现** —— 端点和心跳挂载读的必须是同一个答案,
114 * 否则会出现「控制台显示 30 分钟、实际按本体的 15 分钟在跑」这种不报错的错位。 124 * 否则会出现「控制台显示 30 分钟、实际按本体的 15 分钟在跑」这种不报错的错位。
server/tests/unit/heartbeat-company-scoped.test.js
@@ -8,6 +8,11 @@ @@ -8,6 +8,11 @@
8 * `system/agents/orggraph-planning/config.json` 照样多出一行 `"intervalMinutes": 15` 8 * `system/agents/orggraph-planning/config.json` 照样多出一行 `"intervalMinutes": 15`
9 * (这条 diff 就是本测试的出处)。同类事故第一轮已经修过一次:公司对话内容写进共享 SKILL.md。 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 * ① 写 co-A 之后**本体 config.json 的 mtime 与内容一个字节没变**(危害全在磁盘上,只看响应码看不出来); 17 * ① 写 co-A 之后**本体 config.json 的 mtime 与内容一个字节没变**(危害全在磁盘上,只看响应码看不出来);
13 * ② co-B 读到的是它自己的值,不是 co-A 的(跨公司串味); 18 * ② co-B 读到的是它自己的值,不是 co-A 的(跨公司串味);
@@ -65,14 +70,14 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; { @@ -65,14 +70,14 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
65 fs.writeFileSync(path.join(BODY_DIR, 'soul.md'), '# 人格\n\n沉默寡言,只负责被测试。\n'); 70 fs.writeFileSync(path.join(BODY_DIR, 'soul.md'), '# 人格\n\n沉默寡言,只负责被测试。\n');
66 fs.writeFileSync(BODY_CONFIG, BODY_CONFIG_TEXT); 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 // 2. 一次性 graph.sqlite + 两家公司的雇佣关系 75 // 2. 一次性 graph.sqlite + 两家公司的雇佣关系
71 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); 76 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
72 process.env.GRAPH_DB_PATH = TEST_DB_PATH; 77 process.env.GRAPH_DB_PATH = TEST_DB_PATH;
73 for (const m of ['../../services/graph-db', '../../services/graph-service', 78 for (const m of ['../../services/graph-db', '../../services/graph-service',
74 '../../services/org-graph-service', '../../services/company-service', 79 '../../services/org-graph-service', '../../services/company-service',
75 - '../../services/heartbeat-service', '../../routes/company']) { 80 + '../../services/heartbeat-service', '../../routes/company', '../../routes/agent-chat']) {
76 delete require_.cache[require_.resolve(m)]; 81 delete require_.cache[require_.resolve(m)];
77 } 82 }
78 const graphDb = require_('../../services/graph-db'); 83 const graphDb = require_('../../services/graph-db');
@@ -92,6 +97,7 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; { @@ -92,6 +97,7 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
92 const app = express(); 97 const app = express();
93 app.use(express.json()); 98 app.use(express.json());
94 app.use('/api/company', require_('../../routes/company')); 99 app.use('/api/company', require_('../../routes/company'));
  100 + app.use('/api/agent', require_('../../routes/agent-chat')); // §二 那组用的第二个入口
95 await new Promise((resolve) => { 101 await new Promise((resolve) => {
96 srv = app.listen(0, '127.0.0.1', () => { 102 srv = app.listen(0, '127.0.0.1', () => {
97 base = `http://127.0.0.1:${srv.address().port}`; 103 base = `http://127.0.0.1:${srv.address().port}`;
@@ -104,7 +110,8 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; { @@ -104,7 +110,8 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
104 if (srv) await new Promise((r) => srv.close(r)); 110 if (srv) await new Promise((r) => srv.close(r));
105 heartbeatService?.stop(); // 端点挂上的 setInterval 会吊住 vitest 进程 111 heartbeatService?.stop(); // 端点挂上的 setInterval 会吊住 vitest 进程
106 // 只清公司态(运行态、gitignore);本体留在 system/agents/ 里当正式注册表条目 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 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); 115 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
109 delete process.env.GRAPH_DB_PATH; 116 delete process.env.GRAPH_DB_PATH;
110 }); 117 });
@@ -168,4 +175,60 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; { @@ -168,4 +175,60 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
168 expect(res400.status).toBe(400); 175 expect(res400.status).toBe(400);
169 expect(heartbeatService.readHeartbeatConfig(`${CO_A}:${ROLE}`).intervalMinutes).toBe(30); 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 });