diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index d9b7135..742df3e 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -1300,42 +1300,58 @@ router.get('/:agentId/info', (req, res) => { ...identity, type: config?.type, status: config?.status, - heartbeat: config?.heartbeat || { enabled: false, intervalMinutes: 15 }, + // 心跳读**公司态优先**(28-followups §3.3)—— 与 _setupAgentHeartbeat 读的是同一个函数。 + // 直接读本体 config.json 的话,(agent, 公司) 一旦设过公司态,这里显示的就不是实际在跑的值: + // 界面写「30 分钟」而调度器按本体缺省的 15 分钟在跑,且两边都不报错。 + heartbeat: require('../services/heartbeat-service').readHeartbeatConfig(req.params.agentId), cron: cron, bootstrapFiles: ['agent.md','soul.md','identity.md','user.md','tools.md','heartbeat.md', 'cron.json'].filter(f => loadFile(agentDir, f).trim().length > 0), }); }); +/** + * 心跳配置写入 —— **落公司态,不回写本体**(28-followups §3.3)。 + * + * 原来这里是 `getBodyDir(id)` → 整份重写本体 `config.json`,公司段在寻址第一步就丢了。 + * 两个后果,都不报错: + * ① 跨租户串写:`co-a:ceo` 改心跳,`co-b:ceo` 跟着变(同一份本体); + * ② 一旦该 (agent, 公司) 有了公司态文件,读侧优先取公司态 → **这个端点的写入静默无效**, + * 界面照样收到 `{success:true}`,而实际在跑的还是旧值。 + * + * 现在读写都走 heartbeat-service 那**一对**函数,与心跳挂载读的是同一个答案。 + * 入参校验也归它(intervalMinutes 直接进 setInterval,NaN / 0 / 负数会让定时器退化成 + * 每 tick 触发一次)—— 非法值在写盘前抛,这里翻成 400。 + */ router.put('/:agentId/heartbeat', (req, res) => { + const agentId = req.params.agentId; try { - const agentDir = getBodyDir(req.params.agentId); - if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); - - const configPath = path.join(agentDir, 'config.json'); - let config = {}; - if (fs.existsSync(configPath)) { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - } - - config.heartbeat = { - enabled: req.body.enabled || false, - intervalMinutes: req.body.intervalMinutes || 15 - }; - - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); - - // Update heartbeat service if running const heartbeatService = require('../services/heartbeat-service'); - // Using namespacedId logic from UI (`system:master` is skipped, only `company:role` or pure system agent like `master`) - const agentId = req.params.agentId; - if (config.heartbeat.enabled) { + // 存在性检查走服务自己的寻址:`cid:role` 里冒号后面是**角色名**,未必等于本体 id。 + // 用 getBodyDir(id)(= 拿角色名当本体 id)判的话,凡是角色名 ≠ 本体 id 的都会被 + // 判成「agent 不存在」,而写侧其实找得到 —— 判 404 和写入必须问同一个问题。 + if (!fs.existsSync(heartbeatService.bodyDirOf(agentId))) { + return res.status(404).json({ error: 'Agent not found' }); + } + + let heartbeat; + try { + heartbeat = heartbeatService.writeHeartbeatConfig(agentId, { + enabled: req.body.enabled || false, + intervalMinutes: req.body.intervalMinutes, + }); + } catch (err) { + return res.status(400).json({ error: err.message }); // 入参非法,不是服务器故障 + } + + // 定时器跟着改:开着的重挂(_setupAgentHeartbeat 内部会先清旧的),关掉的摘掉 + if (heartbeat.enabled) { heartbeatService._setupAgentHeartbeat(agentId); } else if (heartbeatService.timers.has(agentId)) { clearInterval(heartbeatService.timers.get(agentId)); heartbeatService.timers.delete(agentId); } - - res.json({ success: true, heartbeat: config.heartbeat }); + + res.json({ success: true, heartbeat }); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/server/services/heartbeat-service.js b/server/services/heartbeat-service.js index 69d6e32..88a1ec4 100644 --- a/server/services/heartbeat-service.js +++ b/server/services/heartbeat-service.js @@ -109,6 +109,16 @@ class HeartbeatService { } /** + * 这个地址对应的本体目录。**存在性检查必须用它**,不能自己再拼一次 —— + * `cid:role` 里冒号后面是角色名,未必等于本体 id(company_agents 的映射)。 + * 端点用 `bodyDir(parseAddress(id).agentId)` 判 404 的话,凡是角色名 ≠ 本体 id 的 + * 都会被判成「agent 不存在」,而写侧其实找得到。 + */ + bodyDirOf(addr) { + return getAgentDir(addr); + } + + /** * 这个 `(agent, 公司)` 的有效心跳配置:公司态优先,缺字段回落本体缺省。 * **读的一侧只有这一份实现** —— 端点和心跳挂载读的必须是同一个答案, * 否则会出现「控制台显示 30 分钟、实际按本体的 15 分钟在跑」这种不报错的错位。 diff --git a/server/tests/unit/heartbeat-company-scoped.test.js b/server/tests/unit/heartbeat-company-scoped.test.js index ba97bc8..d259ec2 100644 --- a/server/tests/unit/heartbeat-company-scoped.test.js +++ b/server/tests/unit/heartbeat-company-scoped.test.js @@ -8,6 +8,11 @@ * `system/agents/orggraph-planning/config.json` 照样多出一行 `"intervalMinutes": 15` * (这条 diff 就是本测试的出处)。同类事故第一轮已经修过一次:公司对话内容写进共享 SKILL.md。 * + * **端点有两个入口**(28-followups §3.3):除了 `/api/company/...` 这三个, + * 还有前端 Agent 详情页走的 `PUT /api/agent/:agentId/heartbeat`。后者当时没改, + * 症状比串写更隐蔽:公司态文件一旦存在,读侧优先取公司态 → **这个端点的写入静默无效**, + * 界面收到 `{success:true}`、显示的却不是实际在跑的值。下面 §二 那组专门盯它。 + * * 三条断言各锁一个方向: * ① 写 co-A 之后**本体 config.json 的 mtime 与内容一个字节没变**(危害全在磁盘上,只看响应码看不出来); * ② co-B 读到的是它自己的值,不是 co-A 的(跨公司串味); @@ -65,14 +70,14 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { fs.writeFileSync(path.join(BODY_DIR, 'soul.md'), '# 人格\n\n沉默寡言,只负责被测试。\n'); fs.writeFileSync(BODY_CONFIG, BODY_CONFIG_TEXT); // 上一轮跑剩的公司态不能留,否则③的回落断言会读到旧值而空过 - for (const cid of [CO_A, CO_B]) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); + for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); // 2. 一次性 graph.sqlite + 两家公司的雇佣关系 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); process.env.GRAPH_DB_PATH = TEST_DB_PATH; for (const m of ['../../services/graph-db', '../../services/graph-service', '../../services/org-graph-service', '../../services/company-service', - '../../services/heartbeat-service', '../../routes/company']) { + '../../services/heartbeat-service', '../../routes/company', '../../routes/agent-chat']) { delete require_.cache[require_.resolve(m)]; } const graphDb = require_('../../services/graph-db'); @@ -92,6 +97,7 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { const app = express(); app.use(express.json()); app.use('/api/company', require_('../../routes/company')); + app.use('/api/agent', require_('../../routes/agent-chat')); // §二 那组用的第二个入口 await new Promise((resolve) => { srv = app.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${srv.address().port}`; @@ -104,7 +110,8 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { if (srv) await new Promise((r) => srv.close(r)); heartbeatService?.stop(); // 端点挂上的 setInterval 会吊住 vitest 进程 // 只清公司态(运行态、gitignore);本体留在 system/agents/ 里当正式注册表条目 - for (const cid of [CO_A, CO_B]) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); + // `_global` 是裸 agent id 的公司态落点(GET /info 那条用它),同样要清 + for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); delete process.env.GRAPH_DB_PATH; }); @@ -168,4 +175,60 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { expect(res400.status).toBe(400); expect(heartbeatService.readHeartbeatConfig(`${CO_A}:${ROLE}`).intervalMinutes).toBe(30); }); + + // ── 第二个入口:PUT / GET /api/agent/:agentId/heartbeat(28-followups §3.3)── + // 放在同一个 describe 里而不是另起一个:夹具(express 实例、本体、公司态)是共用的, + // 另起 describe 的话上面那个 afterAll 会先把 server 关掉 —— 实测 ECONNREFUSED。 + const addrA = `${CO_A}:${ROLE}`; + + it('写公司态、不碰本体,且写完立刻能从读侧读回来(原来是静默无效)', async () => { + const before = fs.readFileSync(BODY_CONFIG, 'utf-8'); + const beforeMtime = fs.statSync(BODY_CONFIG).mtimeMs; + + const res = await fetch(`${base}/api/agent/${addrA}/heartbeat`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: false, intervalMinutes: 7 }), + }); + expect(res.status).toBe(200); + expect((await res.json()).heartbeat).toMatchObject({ intervalMinutes: 7 }); + + // 本体一个字节没动(修之前这里整份被重写,公司段在寻址第一步就丢了) + expect(fs.readFileSync(BODY_CONFIG, 'utf-8')).toBe(before); + expect(fs.statSync(BODY_CONFIG).mtimeMs).toBe(beforeMtime); + + // **这条才是「静默无效」的精确形态**:写完之后,心跳挂载读到的必须是新值。 + // 修之前写的是本体、读的是公司态,这里会读回 30(上面 §一 留下的公司态值)。 + expect(heartbeatService.readHeartbeatConfig(addrA)).toMatchObject({ intervalMinutes: 7 }); + }); + + it('GET /info 报的是实际在跑的那份,不是本体缺省', async () => { + // 用**裸 agent id**(落 `_global` 公司态)而不是 `cid:role`:/info 的 404 闸门 + // 仍是 `getBodyDir(id)` = 拿角色名当本体 id,对 `cid:role` 会直接 404。 + // 那是 /info 整体寻址的既有缺口(它还要按同一个目录读 identity / agent.md / cron), + // 不在本次心跳这一条的范围内 —— 记在这里,别当成已经修过。 + const hb = require('../../services/heartbeat-service'); + hb.writeHeartbeatConfig(BODY_ID, { enabled: false, intervalMinutes: 9 }); + + const res = await fetch(`${base}/api/agent/${BODY_ID}/info`); + expect(res.status).toBe(200); + // 修之前读的是本体 config.json → 会报 42(本体缺省),而调度器按 9 在跑。 + // 界面写一个数、系统按另一个数跑,两边都不报错。 + expect((await res.json()).heartbeat).toMatchObject({ intervalMinutes: 9 }); + }); + + it('非法 intervalMinutes 拦在写盘之前(400,不是 500,也不是写进去再说)', async () => { + // 这个值直接进 setInterval:0 / 负数 / NaN 会让定时器退化成「每 tick 触发一次」, + // 而每次 tick 都真起一轮 LLM。 + for (const bad of [0, -5, 'abc']) { + const res = await fetch(`${base}/api/agent/${addrA}/heartbeat`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, intervalMinutes: bad }), + }); + expect(res.status).toBe(400); + } + // 拦下来之后旧值必须还在(不能写一半) + expect(heartbeatService.readHeartbeatConfig(addrA)).toMatchObject({ intervalMinutes: 7 }); + }); });