Commit c3a5d462e33f7e50f942fdb5981c0b1af158ed87

Authored by yibo
1 parent 8f4c1b63

fix(agent-chat): 自唤醒回调抽成 selfChat,端口取实际监听端口、测试环境短路

主体由另一个会话完成(其会话已关闭,由我接手核对并提交),我补了两处修复
与一条守卫。

## 它修的是什么
六处「服务端自己给某个 agent 发一条 chat」各自写死
`hostname:'localhost', port: systemConfig.port`(3010)。单测把路由挂到
`app.listen(0)` 的临时端口上,回调却照样打进**线上 3010** —— 每跑一次汇报
用例就给真 CEO 发一条 chat、起一个常驻 claude CLI 子进程、留一个
callback-T-* 会话目录。症状完全静默:测试全绿,只有进程数和内存在涨。

(这条是被我在批 5 新加的 task-dispatch-store 用例触发出来的:那之前没有
任何测试会走到汇报回调。)

抽成 selfChat:端口从 req.socket 取(收到这条请求的那个监听端口,天然就是
「我自己」),测试环境整体短路。

## 我接手时发现并修掉的两处
`task/transition` 的两个调用点删掉了 `const chatReq` / `const postData` 的
声明,**却留下了 `chatReq.write(postData); chatReq.end();` 两行**。
`node --check` 照样通过 —— 那是运行时 ReferenceError 不是语法错;而它在
`setImmediate` 里抛就是未捕获异常,**直接杀进程**。

真起服务打了一遍 POST /api/agent/ceo/task/transition:200,服务仍在。
修之前这条路必炸。

## 守卫:server/tests/unit/self-chat-callbacks.test.js
1. chatReq / postData 只许出现在 selfChat 函数体内 —— 「删了声明留下调用」
   这类改造残留,语法检查和单测都抓不到,只有真跑到那条路径才炸;
2. 不许再有第二处自己拼 http.request 打 systemConfig.port(分叉的开始);
3. 测试环境必须短路。

第一版守卫把 selfChat 上方那段**注释**里引用的写法当成漏改报了红 ——
那是文档不是代码,已跳过注释行。

全量:135 文件 / 1272 测试全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server/routes/agent-chat.js
@@ -31,6 +31,38 @@ const { companyWorkspaceDir } = require(&#39;../src/core/prompt-builder&#39;); @@ -31,6 +31,38 @@ const { companyWorkspaceDir } = require(&#39;../src/core/prompt-builder&#39;);
31 const systemConfig = require('../config'); 31 const systemConfig = require('../config');
32 const router = express.Router(); 32 const router = express.Router();
33 33
  34 +// 笏笏 閾ェ蜚、驢貞屓隹シ夂ォッ蜿」蜿門ョ樣刔逶大成遶ッ蜿」梧オ玖ッ慕識蠅紛菴鍋洒霍ッ 笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏
  35 +//
  36 +// 譖セ扈剰ソ吝螟推閾ェ蜀呎ュサ `hostname:'localhost', port: systemConfig.port`3010峨
  37 +// 蜊墓オ区滑霍ッ逕ア謖ょ芦 `app.listen(0)` 逧クエ譌カ遶ッ蜿」荳奇シ悟屓隹唆辣ァ譬キ謇楢ソ**郤ソ荳 3010**
  38 +// 豈剰キ台ク谺。 task-dispatch-store 逧ア冠逕ィ萓具シ悟ーア扈咏悄 CEO 蜿台ク譚。 chat縲∬オキ荳荳ェ蟶ク鬩サ
  39 +// claude CLI 蟄占ソ帷ィ九∝惠 system/agents/ceo/_global/ 荳狗蕗荳荳ェ callback-T-* 逶ョ蠖輔
  40 +// 逞憾譏ッ髱咎サ倡噪域オ玖ッ募扈ソ会シ悟宵譛芽ソ帷ィ区焚蜥悟蟄伜惠豸ィ 窶披 蜷 docs/done/claude-cli-session-leak.md縲
  41 +// 遶ッ蜿」莉 req.socket 蜿厄シ域噺蛻ー霑呎擅隸キ豎ら噪驍」荳ェ逶大成遶ッ蜿」悟、ゥ辟カ蟆ア譏ッ"謌題蟾ア"峨
  42 +const isTestEnv = () => !!process.env.VITEST || process.env.NODE_ENV === 'test';
  43 +
  44 +function selfChat(req, agentId, message, conversationId, label = 'SelfChat') {
  45 + if (isTestEnv()) {
  46 + console.log(`[${label}] 竢ュ 豬玖ッ慕識蠅シ瑚キウ霑ッケ ${agentId} 逧蜚、驢 (conv: ${conversationId})`);
  47 + return;
  48 + }
  49 + const http = require('http');
  50 + const postData = JSON.stringify({ message, conversationId });
  51 + const chatReq = http.request({
  52 + hostname: req?.socket?.localAddress || '127.0.0.1',
  53 + port: req?.socket?.localPort || systemConfig.port,
  54 + path: `/api/agent/${agentId}/chat`,
  55 + method: 'POST',
  56 + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },
  57 + }, (chatRes) => {
  58 + chatRes.on('data', () => {});
  59 + chatRes.on('end', () => console.log(`[${label}] 笨 蟾イ蜚、驢 ${agentId} (conv: ${conversationId})`));
  60 + });
  61 + chatReq.on('error', (err) => console.error(`[${label}] 笶 蝗櫁ー、ア雍・: ${err.message}`));
  62 + chatReq.write(postData);
  63 + chatReq.end();
  64 +}
  65 +
34 // 笏笏 荳荳ェ蟇サ蝮蜷搾シ御ク臥ァ崎ッュ荵会シ28 蜿キ隶セ隶。 ツァ4.1俄楳笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏 66 // 笏笏 荳荳ェ蟇サ蝮蜷搾シ御ク臥ァ崎ッュ荵会シ28 蜿キ隶セ隶。 ツァ4.1俄楳笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏笏
35 // 67 //
36 // 謾ケ騾蜑崎ソ咎蜿ェ譛我ク荳ェ getAgentDir()瑚ソ泌屓蛟シ蜷梧慮陲ォ蠖捺縲靴LI 逧 cwd縲阪梧悽菴灘宵隸サ譬ケ縲 68 // 謾ケ騾蜑崎ソ咎蜿ェ譛我ク荳ェ getAgentDir()瑚ソ泌屓蛟シ蜷梧慮陲ォ蠖捺縲靴LI 逧 cwd縲阪梧悽菴灘宵隸サ譬ケ縲
@@ -1899,26 +1931,7 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; { @@ -1899,26 +1931,7 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; {
1899 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 "(菴逧ョ梧鞫倩ヲ)"`; 1931 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 "(菴逧ョ梧鞫倩ヲ)"`;
1900 1932
1901 // 蜀Κ隗ヲ蜿題♀螟ゥ (騾夊ソ HTTP 閾ェ隹畑) 1933 // 蜀Κ隗ヲ蜿題♀螟ゥ (騾夊ソ HTTP 閾ェ隹畑)
1902 - const http = require('http');  
1903 - const postData = JSON.stringify({ message: taskMessage, conversationId: executionConvId });  
1904 - const options = {  
1905 - hostname: 'localhost', port: systemConfig.port,  
1906 - path: `/api/agent/${to}/chat`,  
1907 - method: 'POST',  
1908 - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },  
1909 - };  
1910 - const chatReq = http.request(options, (chatRes) => {  
1911 - let body = '';  
1912 - chatRes.on('data', (chunk) => body += chunk);  
1913 - chatRes.on('end', () => {  
1914 - console.log(`[TaskDispatch] 笨 ${to} 蠑よュ・謇ァ陦悟キイ蜷ッ蜉ィ (conv: ${executionConvId})`);  
1915 - });  
1916 - });  
1917 - chatReq.on('error', (err) => {  
1918 - console.error(`[TaskDispatch] 笶 蠑よュ・謇ァ陦悟、ア雍・: ${err.message}`);  
1919 - });  
1920 - chatReq.write(postData);  
1921 - chatReq.end(); 1934 + selfChat(req, to, taskMessage, executionConvId, 'TaskDispatch');
1922 } catch (err) { 1935 } catch (err) {
1923 console.error(`[TaskDispatch] 笶 蠑よュ・隗ヲ蜿鷹漠隸ッ: ${err.message}`); 1936 console.error(`[TaskDispatch] 笶 蠑よュ・隗ヲ蜿鷹漠隸ッ: ${err.message}`);
1924 } 1937 }
@@ -2077,26 +2090,7 @@ router.post(&#39;/task/report&#39;, (req, res) =&gt; { @@ -2077,26 +2090,7 @@ router.post(&#39;/task/report&#39;, (req, res) =&gt; {
2077 const statusText = status === 'done' ? '笨 蟾イ螳梧' : '笶 螟ア雍・'; 2090 const statusText = status === 'done' ? '笨 蟾イ螳梧' : '笶 螟ア雍・';
2078 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 扈ァ扈ュ謇ァ陦御ク倶ク豁・隶。蛻偵A; 2091 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 扈ァ扈ュ謇ァ陦御ク倶ク豁・隶。蛻偵A;
2079 2092
2080 - const http = require('http');  
2081 - const postData = JSON.stringify({ message: callbackMessage, conversationId: callbackConvId });  
2082 - const options = {  
2083 - hostname: 'localhost', port: systemConfig.port,  
2084 - path: `/api/agent/${to}/chat`,  
2085 - method: 'POST',  
2086 - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },  
2087 - };  
2088 - const chatReq = http.request(options, (chatRes) => {  
2089 - let body = '';  
2090 - chatRes.on('data', (chunk) => body += chunk);  
2091 - chatRes.on('end', () => {  
2092 - console.log(`[TaskCallback] 笨 蟾イ蝗櫁ー ${to}: ${taskId} ${statusText} (conv: ${callbackConvId})`);  
2093 - });  
2094 - });  
2095 - chatReq.on('error', (err) => {  
2096 - console.error(`[TaskCallback] 笶 蝗櫁ー、ア雍・: ${err.message}`);  
2097 - });  
2098 - chatReq.write(postData);  
2099 - chatReq.end(); 2093 + selfChat(req, to, callbackMessage, callbackConvId, 'TaskCallback');
2100 2094
2101 // 譖エ譁ー task 蜈焚謐ョ 2095 // 譖エ譁ー task 蜈焚謐ョ
2102 if (taskMeta) { 2096 if (taskMeta) {
@@ -2160,18 +2154,7 @@ router.post(&#39;/:agentId/task/transition&#39;, (req, res) =&gt; { @@ -2160,18 +2154,7 @@ router.post(&#39;/:agentId/task/transition&#39;, (req, res) =&gt; {
2160 // 騾夂衍 Master 螳。謇ケ 2154 // 騾夂衍 Master 螳。謇ケ
2161 setImmediate(() => { 2155 setImmediate(() => {
2162 const masterMsg = `[笞 莠句燕螳。謇ケ隸キ豎\n菴逧ク句ア ${agentId} 蟾イ扈丞ョ梧莠 P0 郤ァ莉サ蜉。 ${taskId} 逧ァ髦カ谿オ縲n邇ー蝨ィ螳ュ」蜃、刈霓ス螟ァ螽∝鴨謇ァ陦梧揀髯 (Execute) 關ス蝨ー譁ケ譯医n\n隸キ菴髦ッサ蜈カ莠ァ逕溽噪 ${artifact || '_plan.md'}悟ケカ蝨ィ遑ョ隶、譌隸ッ蜷朱夊ソ欠莉、謇句勘謾セ陦悟ョA; 2156 const masterMsg = `[笞 莠句燕螳。謇ケ隸キ豎\n菴逧ク句ア ${agentId} 蟾イ扈丞ョ梧莠 P0 郤ァ莉サ蜉。 ${taskId} 逧ァ髦カ谿オ縲n邇ー蝨ィ螳ュ」蜃、刈霓ス螟ァ螽∝鴨謇ァ陦梧揀髯 (Execute) 關ス蝨ー譁ケ譯医n\n隸キ菴髦ッサ蜈カ莠ァ逕溽噪 ${artifact || '_plan.md'}悟ケカ蝨ィ遑ョ隶、譌隸ッ蜷朱夊ソ欠莉、謇句勘謾セ陦悟ョA;
2163 - const http = require('http');  
2164 - const postData = JSON.stringify({ message: masterMsg, conversationId: `approval-${taskId}` });  
2165 - const options = {  
2166 - hostname: 'localhost', port: systemConfig.port,  
2167 - path: `/api/agent/master/chat`,  
2168 - method: 'POST',  
2169 - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },  
2170 - };  
2171 - const chatReq = http.request(options, () => {});  
2172 - chatReq.on('error', () => {});  
2173 - chatReq.write(postData);  
2174 - chatReq.end(); 2157 + selfChat(req, 'master', masterMsg, `approval-${taskId}`, 'TaskTransition');
2175 }); 2158 });
2176 2159
2177 // 謗先妙蠖灘燕霑帷ィ倶スソ蜈カ莨醍悛檎ュ牙セ譁ー蜚、驢 2160 // 謗先妙蠖灘燕霑帷ィ倶スソ蜈カ莨醍悛檎ュ牙セ譁ー蜚、驢
@@ -2270,18 +2253,7 @@ ${artifactContent ? `霑呎弍荳贋ク髦カ谿オ菴謌紋ス逧燕鄂ョ闃らせ逡吩ク狗噪莠、莉倡 @@ -2270,18 +2253,7 @@ ${artifactContent ? `霑呎弍荳贋ク髦カ谿オ菴謌紋ス逧燕鄂ョ闃らせ逡吩ク狗噪莠、莉倡
2270 2253
2271 // 4. 蜿鷹∝筈驢呈欠莉、隶ゥ莉也ォ句綾蝓コ莠取眠閼大ュ占。悟勘 2254 // 4. 蜿鷹∝筈驢呈欠莉、隶ゥ莉也ォ句綾蝓コ莠取眠閼大ュ占。悟勘
2272 setImmediate(() => { 2255 setImmediate(() => {
2273 - const http = require('http');  
2274 - const postData = JSON.stringify({ message: "隸キ遑ョ隶、菴蟾イ謾カ蛻ー邉サ扈溽噪譁ー髦カ谿オ謖りスス謖サ、悟ケカ遶句叉謇ァ陦御ス蠖灘燕髦カ谿オ譛ャ蛻噪莉サ蜉。", conversationId: activeConvId });  
2275 - const options = {  
2276 - hostname: 'localhost', port: systemConfig.port,  
2277 - path: `/api/agent/${agentId}/chat`,  
2278 - method: 'POST',  
2279 - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },  
2280 - };  
2281 - const chatReq = http.request(options, () => {});  
2282 - chatReq.on('error', () => {});  
2283 - chatReq.write(postData);  
2284 - chatReq.end(); 2256 + selfChat(req, agentId, '隸キ遑ョ隶、菴蟾イ謾カ蛻ー邉サ扈溽噪譁ー髦カ谿オ謖りスス謖サ、悟ケカ遶句叉謇ァ陦御ス蠖灘燕髦カ谿オ譛ャ蛻噪莉サ蜉。', activeConvId, 'TaskTransition');
2285 }); 2257 });
2286 } 2258 }
2287 2259
@@ -2335,26 +2307,7 @@ router.post(&#39;/task/reject&#39;, (req, res) =&gt; { @@ -2335,26 +2307,7 @@ router.post(&#39;/task/reject&#39;, (req, res) =&gt; {
2335 `node bin/lemon.js task report --taskId redo-${runId}-${stepIndex} --from ${agentId} --to ${reviewedBy} --status done --summary "(謾ケ霑帛錘逧ョ梧鞫倩ヲ)"`, 2307 `node bin/lemon.js task report --taskId redo-${runId}-${stepIndex} --from ${agentId} --to ${reviewedBy} --status done --summary "(謾ケ霑帛錘逧ョ梧鞫倩ヲ)"`,
2336 ].join('\n'); 2308 ].join('\n');
2337 2309
2338 - const http = require('http');  
2339 - const postData = JSON.stringify({ message: rejectMessage, conversationId: `redo-${runId}-step-${stepIndex}` });  
2340 - const options = {  
2341 - hostname: 'localhost', port: systemConfig.port,  
2342 - path: `/api/agent/${agentId}/chat`,  
2343 - method: 'POST',  
2344 - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },  
2345 - };  
2346 - const chatReq = http.request(options, (chatRes) => {  
2347 - let body = '';  
2348 - chatRes.on('data', (chunk) => body += chunk);  
2349 - chatRes.on('end', () => {  
2350 - console.log(`[TaskReject] 売 ${agentId} 驥榊★莉サ蜉。蟾イ隗ヲ蜿疏);  
2351 - });  
2352 - });  
2353 - chatReq.on('error', (err) => {  
2354 - console.error(`[TaskReject] 笶 驥榊★隗ヲ蜿大、ア雍・: ${err.message}`);  
2355 - });  
2356 - chatReq.write(postData);  
2357 - chatReq.end(); 2310 + selfChat(req, agentId, rejectMessage, `redo-${runId}-step-${stepIndex}`, 'TaskReject');
2358 }); 2311 });
2359 } 2312 }
2360 2313
@@ -2421,20 +2374,7 @@ router.post(&#39;/human-request&#39;, (req, res) =&gt; { @@ -2421,20 +2374,7 @@ router.post(&#39;/human-request&#39;, (req, res) =&gt; {
2421 `隸キ謨ヲ菫ココ邀サ Master 謠蝉セ幄ッ・ Key梧閠ス隸シー譏ッ蜷ヲ蜿ッ莉・菴ソ逕ィ螟畑譁ケ譯医A 2374 `隸キ謨ヲ菫ココ邀サ Master 謠蝉セ幄ッ・ Key梧閠ス隸シー譏ッ蜷ヲ蜿ッ莉・菴ソ逕ィ螟畑譁ケ譯医A
2422 ].join('\n'); 2375 ].join('\n');
2423 2376
2424 - const http = require('http');  
2425 - const postData = JSON.stringify({ message: masterMsg, conversationId: `human-req-${Date.now().toString(36)}` });  
2426 - const options = {  
2427 - hostname: 'localhost', port: systemConfig.port,  
2428 - path: `/api/agent/master/chat`,  
2429 - method: 'POST',  
2430 - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },  
2431 - };  
2432 - const chatReq = http.request(options, (chatRes) => {  
2433 - chatRes.on('data', () => {});  
2434 - });  
2435 - chatReq.on('error', () => {});  
2436 - chatReq.write(postData);  
2437 - chatReq.end(); 2377 + selfChat(req, 'master', masterMsg, `human-req-${Date.now().toString(36)}`, 'HumanRequest');
2438 }); 2378 });
2439 2379
2440 res.json({ success: true, message: `蟾イ蟆ッキ豎ゆク頑冠扈 Master 蟷カ隶ー蠖戊 Human To-Do List` }); 2380 res.json({ success: true, message: `蟾イ蟆ッキ豎ゆク頑冠扈 Master 蟷カ隶ー蠖戊 Human To-Do List` });
server/tests/unit/self-chat-callbacks.test.js 0 → 100644
  1 +/**
  2 + * 自唤醒回调(`selfChat`)的两条不变式。
  3 + *
  4 + * 背景:`/task/dispatch`、`/task/report`、`task/transition` 等六处都要「服务端自己给某个
  5 + * agent 发一条 chat」。它们原来各自写死 `hostname:'localhost', port: systemConfig.port`
  6 + * (3010)—— 单测把路由挂到 `app.listen(0)` 的临时端口上,回调却照样打进**线上 3010**:
  7 + * 每跑一次汇报用例就给真 CEO 发一条 chat、起一个常驻 claude CLI 子进程、留一个
  8 + * `callback-T-*` 会话目录。症状完全静默(测试全绿),只有进程数和内存在涨。
  9 + *
  10 + * 抽成 `selfChat` 之后要盯两件事:
  11 + * 1. **不许再有第二处自己拼 http.request 打 systemConfig.port** —— 那就是分叉的开始;
  12 + * 2. **不许留下 `chatReq.write(postData)` 这类孤儿调用** —— 改造时删了声明却留下调用,
  13 + * `node --check` 一样通过(是运行时 ReferenceError 不是语法错),而它在
  14 + * `setImmediate` 里抛就是未捕获异常、直接杀进程。实测这次改造就留下了两处。
  15 + */
  16 +import { describe, it, expect } from 'vitest';
  17 +import fs from 'fs';
  18 +import path from 'path';
  19 +
  20 +const FILE = path.resolve(__dirname, '../../routes/agent-chat.js');
  21 +const src = fs.readFileSync(FILE, 'utf-8');
  22 +const lines = src.split('\n');
  23 +
  24 +describe('自唤醒回调', () => {
  25 + it('样本非空 —— 路径写错时不许退化成永远通过', () => {
  26 + expect(src.length).toBeGreaterThan(1000);
  27 + expect(src).toContain('function selfChat(');
  28 + });
  29 +
  30 + it('chatReq / postData 只出现在 selfChat 内部,没有孤儿调用', () => {
  31 + // selfChat 的函数体范围
  32 + const start = lines.findIndex((l) => l.includes('function selfChat('));
  33 + expect(start).toBeGreaterThan(-1);
  34 + let end = start;
  35 + for (let i = start + 1; i < lines.length; i++) {
  36 + if (lines[i] === '}') { end = i; break; }
  37 + }
  38 + const offenders = [];
  39 + lines.forEach((l, i) => {
  40 + if (i >= start && i <= end) return; // 函数体内是正当用法
  41 + if (/\bchatReq\b|\bpostData\b/.test(l)) offenders.push(`${i + 1}: ${l.trim()}`);
  42 + });
  43 + expect(offenders).toEqual([]);
  44 + });
  45 +
  46 + it('没有第二处自己拼 http.request 打 systemConfig.port', () => {
  47 + const start = lines.findIndex((l) => l.includes('function selfChat('));
  48 + let end = start;
  49 + for (let i = start + 1; i < lines.length; i++) {
  50 + if (lines[i] === '}') { end = i; break; }
  51 + }
  52 + const offenders = [];
  53 + lines.forEach((l, i) => {
  54 + if (i >= start && i <= end) return;
  55 + // 「自己拼端口去打自己」的形状:port: systemConfig.port
  56 + // 跳过注释行 —— selfChat 上方那段说明**引用**了这个写法来解释它为什么被换掉,
  57 + // 那是文档不是代码。第一版没跳,把注释当成漏改报了红。
  58 + if (/^\s*(\/\/|\*|\/\*)/.test(l)) return;
  59 + if (/port:\s*systemConfig\.port/.test(l)) offenders.push(`${i + 1}: ${l.trim()}`);
  60 + });
  61 + expect(offenders).toEqual([]);
  62 + });
  63 +
  64 + it('测试环境必须短路 —— 否则单测会打进线上端口', () => {
  65 + expect(src).toMatch(/process\.env\.VITEST|NODE_ENV === 'test'/);
  66 + });
  67 +});