Commit edeff2ec18c776c6ae4eed06c52f22b2ac8f7cad
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>
Showing
3 changed files
with
115 additions
and
26 deletions
server/routes/agent-chat.js
| ... | ... | @@ -1300,42 +1300,58 @@ router.get('/:agentId/info', (req, res) => { |
| 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('心跳设置按公司隔离(28 遗留项)', () => { |
| 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('心跳设置按公司隔离(28 遗留项)', () => { |
| 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('心跳设置按公司隔离(28 遗留项)', () => { |
| 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('心跳设置按公司隔离(28 遗留项)', () => { |
| 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 | }); | ... | ... |