diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index 52cac1f..d9b7135 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -31,6 +31,38 @@ const { companyWorkspaceDir } = require('../src/core/prompt-builder'); const systemConfig = require('../config'); const router = express.Router(); +// ── 自唤醒回调:端口取实际监听端口,测试环境整体短路 ──────────────────── +// +// 曾经这六处各自写死 `hostname:'localhost', port: systemConfig.port`(3010)。 +// 单测把路由挂到 `app.listen(0)` 的临时端口上,回调却照样打进**线上 3010**: +// 每跑一次 task-dispatch-store 的汇报用例,就给真 CEO 发一条 chat、起一个常驻 +// claude CLI 子进程、在 system/agents/ceo/_global/ 下留一个 callback-T-* 目录。 +// 症状是静默的(测试全绿),只有进程数和内存在涨 —— 同 docs/done/claude-cli-session-leak.md。 +// 端口从 req.socket 取(收到这条请求的那个监听端口,天然就是"我自己")。 +const isTestEnv = () => !!process.env.VITEST || process.env.NODE_ENV === 'test'; + +function selfChat(req, agentId, message, conversationId, label = 'SelfChat') { + if (isTestEnv()) { + console.log(`[${label}] ⏭️ 测试环境,跳过对 ${agentId} 的自唤醒 (conv: ${conversationId})`); + return; + } + const http = require('http'); + const postData = JSON.stringify({ message, conversationId }); + const chatReq = http.request({ + hostname: req?.socket?.localAddress || '127.0.0.1', + port: req?.socket?.localPort || systemConfig.port, + path: `/api/agent/${agentId}/chat`, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }, + }, (chatRes) => { + chatRes.on('data', () => {}); + chatRes.on('end', () => console.log(`[${label}] ✅ 已唤醒 ${agentId} (conv: ${conversationId})`)); + }); + chatReq.on('error', (err) => console.error(`[${label}] ❌ 回调失败: ${err.message}`)); + chatReq.write(postData); + chatReq.end(); +} + // ── 一个寻址名,三种语义(28 号设计 §4.1)────────────────────────────── // // 改造前这里只有一个 getAgentDir(),返回值同时被当成「CLI 的 cwd」「本体只读根」 @@ -1899,26 +1931,7 @@ router.post('/task/dispatch', async (req, res) => { const taskMessage = `[TASK from ${from}]\n任务编号: ${taskId}\n优先级: ${priority}${goalInfo}\n\n${task}\n\n请立即执行此任务。执行完成后,如果你的 agent.md 规定了后续步骤,请在同一轮对话中继续执行,不要停下来等待上级催促!\n全部完成后使用以下命令汇报:\nnode bin/lemon.js task report --taskId ${taskId} --from ${to} --to ${from} --status done --summary "(你的完成摘要)"`; // 内部触发聊天 (通过 HTTP 自调用) - const http = require('http'); - const postData = JSON.stringify({ message: taskMessage, conversationId: executionConvId }); - const options = { - hostname: 'localhost', port: systemConfig.port, - path: `/api/agent/${to}/chat`, - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }, - }; - const chatReq = http.request(options, (chatRes) => { - let body = ''; - chatRes.on('data', (chunk) => body += chunk); - chatRes.on('end', () => { - console.log(`[TaskDispatch] ✅ ${to} 异步执行已启动 (conv: ${executionConvId})`); - }); - }); - chatReq.on('error', (err) => { - console.error(`[TaskDispatch] ❌ 异步执行失败: ${err.message}`); - }); - chatReq.write(postData); - chatReq.end(); + selfChat(req, to, taskMessage, executionConvId, 'TaskDispatch'); } catch (err) { console.error(`[TaskDispatch] ❌ 异步触发错误: ${err.message}`); } @@ -2077,26 +2090,7 @@ router.post('/task/report', (req, res) => { const statusText = status === 'done' ? '✅ 已完成' : '❌ 失败'; const callbackMessage = `[TASK_CALLBACK]\n任务编号: ${taskId}\n执行者: ${from}\n状态: ${statusText}\n摘要: ${summary || '(无摘要)'}\n产出文件: ${outputs.length > 0 ? outputs.join(', ') : '(无)'}\n${taskMeta ? `原始任务: ${taskMeta.task}` : ''}\n\n**去重提醒**:在 dispatch 任何新任务前,请务必先检查目标 Agent 的 todo.md 和全局任务状态。如果同一目标已有相似任务在进行中,直接跳过即可,绝对禁止重复分发。\n\n请检查上述任务的执行结果,然后根据你的 plan.md 继续执行下一步计划。`; - const http = require('http'); - const postData = JSON.stringify({ message: callbackMessage, conversationId: callbackConvId }); - const options = { - hostname: 'localhost', port: systemConfig.port, - path: `/api/agent/${to}/chat`, - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }, - }; - const chatReq = http.request(options, (chatRes) => { - let body = ''; - chatRes.on('data', (chunk) => body += chunk); - chatRes.on('end', () => { - console.log(`[TaskCallback] ✅ 已回调 ${to}: ${taskId} ${statusText} (conv: ${callbackConvId})`); - }); - }); - chatReq.on('error', (err) => { - console.error(`[TaskCallback] ❌ 回调失败: ${err.message}`); - }); - chatReq.write(postData); - chatReq.end(); + selfChat(req, to, callbackMessage, callbackConvId, 'TaskCallback'); // 更新 task 元数据 if (taskMeta) { @@ -2160,18 +2154,7 @@ router.post('/:agentId/task/transition', (req, res) => { // 通知 Master 审批 setImmediate(() => { const masterMsg = `[⚠️ 事前审批请求]\n你的下属 ${agentId} 已经完成了 P0 级任务 ${taskId} 的规划阶段。\n现在它正准备加载大威力执行权限 (Execute) 落地方案。\n\n请你阅读其产生的 ${artifact || '_plan.md'},并在确认无误后通过指令手动放行它。`; - const http = require('http'); - const postData = JSON.stringify({ message: masterMsg, conversationId: `approval-${taskId}` }); - const options = { - hostname: 'localhost', port: systemConfig.port, - path: `/api/agent/master/chat`, - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }, - }; - const chatReq = http.request(options, () => {}); - chatReq.on('error', () => {}); - chatReq.write(postData); - chatReq.end(); + selfChat(req, 'master', masterMsg, `approval-${taskId}`, 'TaskTransition'); }); // 掐断当前进程使其休眠,等待重新唤醒 @@ -2270,18 +2253,7 @@ ${artifactContent ? `这是上一阶段你或你的前置节点留下的交付ç‰ // 4. 发送唤醒指令让他立刻基于新脑子行动 setImmediate(() => { - const http = require('http'); - const postData = JSON.stringify({ message: "请确认你已收到系统的新阶段挂载指令,并立即执行你当前阶段本分的任务!", conversationId: activeConvId }); - const options = { - hostname: 'localhost', port: systemConfig.port, - path: `/api/agent/${agentId}/chat`, - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }, - }; - const chatReq = http.request(options, () => {}); - chatReq.on('error', () => {}); - chatReq.write(postData); - chatReq.end(); + selfChat(req, agentId, '请确认你已收到系统的新阶段挂载指令,并立即执行你当前阶段本分的任务!', activeConvId, 'TaskTransition'); }); } @@ -2335,26 +2307,7 @@ router.post('/task/reject', (req, res) => { `node bin/lemon.js task report --taskId redo-${runId}-${stepIndex} --from ${agentId} --to ${reviewedBy} --status done --summary "(改进后的完成摘要)"`, ].join('\n'); - const http = require('http'); - const postData = JSON.stringify({ message: rejectMessage, conversationId: `redo-${runId}-step-${stepIndex}` }); - const options = { - hostname: 'localhost', port: systemConfig.port, - path: `/api/agent/${agentId}/chat`, - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }, - }; - const chatReq = http.request(options, (chatRes) => { - let body = ''; - chatRes.on('data', (chunk) => body += chunk); - chatRes.on('end', () => { - console.log(`[TaskReject] 🔄 ${agentId} 重做任务已触发`); - }); - }); - chatReq.on('error', (err) => { - console.error(`[TaskReject] ❌ 重做触发失败: ${err.message}`); - }); - chatReq.write(postData); - chatReq.end(); + selfChat(req, agentId, rejectMessage, `redo-${runId}-step-${stepIndex}`, 'TaskReject'); }); } @@ -2421,20 +2374,7 @@ router.post('/human-request', (req, res) => { `请敦促人类 Master 提供该 Key,或者你评估是否可以使用备用方案。` ].join('\n'); - const http = require('http'); - const postData = JSON.stringify({ message: masterMsg, conversationId: `human-req-${Date.now().toString(36)}` }); - const options = { - hostname: 'localhost', port: systemConfig.port, - path: `/api/agent/master/chat`, - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }, - }; - const chatReq = http.request(options, (chatRes) => { - chatRes.on('data', () => {}); - }); - chatReq.on('error', () => {}); - chatReq.write(postData); - chatReq.end(); + selfChat(req, 'master', masterMsg, `human-req-${Date.now().toString(36)}`, 'HumanRequest'); }); res.json({ success: true, message: `已将请求上报给 Master 并记录至 Human To-Do List` }); diff --git a/server/tests/unit/self-chat-callbacks.test.js b/server/tests/unit/self-chat-callbacks.test.js new file mode 100644 index 0000000..2c38ac9 --- /dev/null +++ b/server/tests/unit/self-chat-callbacks.test.js @@ -0,0 +1,67 @@ +/** + * 自唤醒回调(`selfChat`)的两条不变式。 + * + * 背景:`/task/dispatch`、`/task/report`、`task/transition` 等六处都要「服务端自己给某个 + * agent 发一条 chat」。它们原来各自写死 `hostname:'localhost', port: systemConfig.port` + * (3010)—— 单测把路由挂到 `app.listen(0)` 的临时端口上,回调却照样打进**线上 3010**: + * 每跑一次汇报用例就给真 CEO 发一条 chat、起一个常驻 claude CLI 子进程、留一个 + * `callback-T-*` 会话目录。症状完全静默(测试全绿),只有进程数和内存在涨。 + * + * 抽成 `selfChat` 之后要盯两件事: + * 1. **不许再有第二处自己拼 http.request 打 systemConfig.port** —— 那就是分叉的开始; + * 2. **不许留下 `chatReq.write(postData)` 这类孤儿调用** —— 改造时删了声明却留下调用, + * `node --check` 一样通过(是运行时 ReferenceError 不是语法错),而它在 + * `setImmediate` 里抛就是未捕获异常、直接杀进程。实测这次改造就留下了两处。 + */ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const FILE = path.resolve(__dirname, '../../routes/agent-chat.js'); +const src = fs.readFileSync(FILE, 'utf-8'); +const lines = src.split('\n'); + +describe('自唤醒回调', () => { + it('样本非空 —— 路径写错时不许退化成永远通过', () => { + expect(src.length).toBeGreaterThan(1000); + expect(src).toContain('function selfChat('); + }); + + it('chatReq / postData 只出现在 selfChat 内部,没有孤儿调用', () => { + // selfChat 的函数体范围 + const start = lines.findIndex((l) => l.includes('function selfChat(')); + expect(start).toBeGreaterThan(-1); + let end = start; + for (let i = start + 1; i < lines.length; i++) { + if (lines[i] === '}') { end = i; break; } + } + const offenders = []; + lines.forEach((l, i) => { + if (i >= start && i <= end) return; // 函数体内是正当用法 + if (/\bchatReq\b|\bpostData\b/.test(l)) offenders.push(`${i + 1}: ${l.trim()}`); + }); + expect(offenders).toEqual([]); + }); + + it('没有第二处自己拼 http.request 打 systemConfig.port', () => { + const start = lines.findIndex((l) => l.includes('function selfChat(')); + let end = start; + for (let i = start + 1; i < lines.length; i++) { + if (lines[i] === '}') { end = i; break; } + } + const offenders = []; + lines.forEach((l, i) => { + if (i >= start && i <= end) return; + // 「自己拼端口去打自己」的形状:port: systemConfig.port + // 跳过注释行 —— selfChat 上方那段说明**引用**了这个写法来解释它为什么被换掉, + // 那是文档不是代码。第一版没跳,把注释当成漏改报了红。 + if (/^\s*(\/\/|\*|\/\*)/.test(l)) return; + if (/port:\s*systemConfig\.port/.test(l)) offenders.push(`${i + 1}: ${l.trim()}`); + }); + expect(offenders).toEqual([]); + }); + + it('测试环境必须短路 —— 否则单测会打进线上端口', () => { + expect(src).toMatch(/process\.env\.VITEST|NODE_ENV === 'test'/); + }); +});