Commit 3e718aac7a2c54026e3f82fd1ac0ebf0acf06900

Authored by yibo
1 parent 310a2b2e

fix(agent-chat): 后台调用按 kind 区分标注与回收策略,pipeline 重试不再冷启动

承接 310a2b2 的两个遗留:

① 四类被错标成心跳
   company-heartbeat-service._sendAgentChat 给五类调用(心跳巡检 / Pipeline 步骤 /
   日程 / 告警 / 日报)无差别打 isHeartbeat:true,于是对话列表里全都显示成
   「⏱️ 自主心跳巡检」、meta.type 一律 'heartbeat'。改成显式传 kind,五类各自标注。
   isHeartbeat 仍然发,但只对真心跳为真,老消费方行为不变。

② 310a2b2 让 Pipeline 重试变成了冷启动
   Pipeline 步骤的 convId 是 `pipeline-{runId}-step-{i}`,被驳回重做时**复用同一个**。
   310a2b2 按 isHeartbeat 一刀切回收,等于每次重试都要走 buildPrompt 场景 C 重注入历史。
   现在回收策略挂在 CALL_KINDS 表上,判据只有一条:**convId 会不会被下一轮复用** ——
   会复用的(pipeline / 人类对话)留着走 TTL,不复用的(心跳 / 日程 / 告警 / 日报)跑完即回收。
   不按「是不是后台调用」分:五类后台调用里 pipeline 就是要留的那一类。

守卫补到 12 条(原 3 + kind 标注 5 + 回收策略 4)。对 310a2b2 跑过:8 红 4 绿。
convId 一律用固定字面量而非拼时间戳 —— 撞上 test-id-stability 那道防线后按它的判据改的,
固定之后第二次跑还顺带走了「复用已有对话」那条分支。

文档:
  - CLAUDE.md 注意事项加一条(新增会话创建路径时必须同时定回收路径,附症状与守卫位置)
  - 28-followups 守卫清单补第五条,并把「前四条守单一持有者」与第五条「守资源生命周期」
    的形状差异写明;顺手修掉表格已有 4 行、结尾却仍写「这三条」的失配
  - .env.example 补 AGENT_SESSION_IDLE_MS

全量单测 667/667 绿。重启后 /api/health 正常,心跳照常挂载。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.env.example
... ... @@ -24,6 +24,11 @@ export WHATSAPP_TRIGGER=@Andy
24 24 export WHATSAPP_BOT_NAME=Andy
25 25 export WHATSAPP_SELF_CHAT=true
26 26  
  27 +# agent-chat 会话池:空闲多久回收一个常驻 claude CLI 子进程(毫秒,默认 30 分钟)
  28 +# 调小能更快释放内存,代价是多轮对话更容易冷启动重注入历史。
  29 +# 一次性调用(心跳 / 日程 / 告警 / 日报)不受这个值影响,它们跑完即回收。
  30 +# AGENT_SESSION_IDLE_MS=1800000
  31 +
27 32 # Open API(外部接入)
28 33 # 逗号分隔的 API Key 列表
29 34 OPEN_API_KEYS=sk-your-key-1,sk-your-key-2
... ...
CLAUDE.md
... ... @@ -145,6 +145,12 @@ auto-dev &quot;&lt;task&gt;&quot; # bin 入口 → server/src/cli/auto-dev.js
145 145 - 生产环境只跑 `server/index.js`:它会同时托管 API + `web/dist/` 静态文件(`NODE_ENV=production`)。
146 146 - `start.sh` **只**做:kill 占用 3010/5173 的进程 → 起 `server/index.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。
147 147 - `server/index.js` 与 `agent-server.js` 都内置了 **body-sanitizer 中间件**:在 `express.json()` 之前清掉 `\x00-\x1F` 控制字符,避免外部 Agent 通过 `curl` 传未转义换行/制表符导致的 `Bad control character` 报错。修改请求体解析链路前先看这两段。
  148 +- **agent-chat 的 `sessions` 池存的是常驻 claude CLI 子进程**(stream-json 模式,不是一次性的 `claude -p`)。
  149 + 新增一条会创建会话的调用路径时,必须同时想清楚它的回收路径 —— 判据只有一条:**convId 会不会被下一轮复用**。
  150 + 不复用的(心跳 / 日程 / 告警 / 日报)在 `CALL_KINDS` 里标 `true`,跑完即回收;会复用的(pipeline 被驳回重做、
  151 + 人类多轮对话)标 `false`,靠空闲 TTL 兜底(`AGENT_SESSION_IDLE_MS`,默认 30 分钟)。
  152 + 漏标的症状是**静默的**:不报错、不写日志、`git status` 干净,只有内存一路涨(曾经 2h43m 漏出 82 个进程 / 17.5 GB)。
  153 + 守卫:`server/tests/unit/agent-chat-session-reclaim.test.js`。
148 154 - 模块别名 `@server` → `server/`(`module-alias` 注册),跨子包 require 时可用。
149 155 - Claude CLI 进程默认带 `--dangerously-skip-permissions`,写入受限路径前自己评估风险。
150 156 - DevLoop 详见上方「DevLoop 子系统」一节;需要 wireframe / SVG 图表 / 完整人类阅读版时才回查 `_spec/*.md` 同名的 `*.html`。
... ...
docs/todo/system-design/28-followups.md
... ... @@ -95,9 +95,12 @@
95 95  
96 96 ## 五、已经立起来的守卫(别重复造,也别删)
97 97  
98   -这几条是沉淀下来的不变式测试(前三条出自 agent 目录重构那一轮,第四条出自 27-P3C 批 1)。它们守的都是「同一个事实只能有一处持有者」,
99   -而且都带**「样本非空」前置断言** —— 少了那一条,扫描路径一写错就会以「零违例」的形式全绿,
100   -一条永远通过的不变式测试和没有这条测试是一回事。
  98 +这几条是沉淀下来的不变式测试(前三条出自 agent 目录重构那一轮,第四条出自 27-P3C 批 1,第五条出自 CLI 进程泄漏那一轮)。
  99 +**前四条**守的都是「同一个事实只能有一处持有者」,而且都带**「样本非空」前置断言** —— 少了那一条,扫描路径一写错就会以
  100 +「零违例」的形式全绿,一条永远通过的不变式测试和没有这条测试是一回事。
  101 +
  102 +第五条形状不同:它守的不是「持有者唯一」而是**资源生命周期**(每一条创建路径都要有对应的回收路径),
  103 +判据也不靠扫源码,而是把回收行为当黑盒去探。列在一起是因为「别重复造,也别删」这条规矩对它同样适用。
101 104  
102 105 | 测试 | 守什么 | 违例时 |
103 106 |------|--------|--------|
... ... @@ -105,12 +108,23 @@
105 108 | `no-stale-templates-path.test.js` | 活定义里不许出现已死的 `system/templates`(判据:git 跟踪的才是定义,未跟踪的是运行态产物) | 同上 |
106 109 | `repo-hygiene.test.js` | tracked 与 ignored 不重叠 | 列出越界文件 |
107 110 | `runs-root-single-holder.test.js` | 凡是把 `process.env.GRAPH_RUNS_DIR` 绑到标识符上的模块,那个标识符必须在**代码里**(不是注释里)真正被用到 | 精确点名到文件行号 |
  111 +| `agent-chat-session-reclaim.test.js` | agent-chat 的 `sessions` 池:一次性 convId 的调用跑完必须回收,可复用的(pipeline / 人类对话)必须留下并受 TTL 兜底;顺带锁 `CALL_KINDS` 的标注 | 探针 `/chat/:convId/stop` 报告会话还在 |
108 112  
109 113 > 第四条的来历值得记:`goal-contract.js` 里曾有一行 `const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null`,
110 114 > 注释白纸黑字写着「与 graph-engine.RUNS_ROOT 是同一个变量(测试隔离在用)」,**全文件却零引用**。
111 115 > 一个声明了意图、代码却不兑现的常量,比没有这个常量更容易骗人 —— 读的人会据此认为该模块认这个覆盖。
112 116 > 它同时也是「实现在、零调用方」这个惯犯模式的又一例(本仓库有人数到第五次,有人写「十几次」)。
113 117  
114   -**改动它们之前先想清楚**:这三条的价值全在于「红了才有用」。
  118 +> 第五条的来历同样值得记:`sessions` 池存的是**常驻**的 claude CLI 子进程,回收路径只有四条
  119 +> (协议 error / 子进程 disconnected / 手动删对话 / 手动 stop),**「正常跑完」不在其中**。
  120 +> 两路心跳又都用一次性 convId 打进来,于是每 5 分钟漏一个进程,实测单次运行 2h43m 攒到
  121 +> **82 个进程 / 17.5 GB RSS**。它不报错、不写日志、`git status` 干净,唯一的症状是机器变慢 ——
  122 +> 和第四条一样属于「读代码读不出来、要去看运行态才发现」的一类。
  123 +>
  124 +> 回收策略的判据写在 `agent-chat.js` 的 `CALL_KINDS` 表里,只有一条:**convId 会不会被下一轮复用**。
  125 +> 会复用的(pipeline 被驳回重做、人类多轮对话)留着走 TTL,不复用的跑完即回收。
  126 +> 不要按「是不是后台调用」来分 —— 五类后台调用里 pipeline 就是要留的那一类。
  127 +
  128 +**改动它们之前先想清楚**:这几条的价值全在于「红了才有用」。
115 129 放宽判据、加例外、或者把断言改成永远成立的形式,等于把守卫拆掉 ——
116 130 本仓库有过「隔离是表演」的测试污染前科。
... ...
server/routes/agent-chat.js
... ... @@ -772,6 +772,31 @@ function closeSession(key, reason) {
772 772 }
773 773  
774 774 /**
  775 + * 后台调用的种类 → [对话标题, meta.type, 会话是否一次性]
  776 + *
  777 + * 在此之前后台五条调用(心跳巡检 / Pipeline 步骤 / 日程 / 告警 / 日报)无差别打 `isHeartbeat:true`,
  778 + * 于是对话列表里全都显示成「⏱️ 自主心跳巡检」—— 四类被错标成第五类。
  779 + *
  780 + * 「一次性」那一列不是标签,是**回收策略**,判据只有一条:convId 会不会被下一轮复用。
  781 + * - 心跳 / 日程 / 告警 / 日报:convId 带时间戳,用完即弃 → 跑完立刻回收
  782 + * - Pipeline 步骤:convId 是 `pipeline-{runId}-step-{i}`,**被驳回重做时会复用同一个**,
  783 + * 留着会话能让重试直接接上下文(否则要走 buildPrompt 场景 C 冷启动重注入历史)。
  784 + * 它不会变成泄漏 —— 有空闲 TTL 兜底。
  785 + */
  786 +const CALL_KINDS = {
  787 + heartbeat: ['⏱️ 自主心跳巡检', 'heartbeat', true],
  788 + pipeline: ['🔧 流水线步骤', 'pipeline', false],
  789 + schedule: ['📅 日程任务', 'schedule', true],
  790 + alert: ['🚨 告警', 'alert', true],
  791 + report: ['📊 日报', 'report', true],
  792 +};
  793 +
  794 +/** 没带 kind 的老调用方(heartbeat-service 只发 isHeartbeat)按心跳算,行为不变 */
  795 +function resolveKind(body) {
  796 + return CALL_KINDS[body.kind] || (body.isHeartbeat ? CALL_KINDS.heartbeat : null);
  797 +}
  798 +
  799 +/**
775 800 * 请求结束后给会话上弦;请求开始时由 getOrCreateProtocol 停表。
776 801 * 计时器只在「没有在途请求」的间隙走,所以再长的单次生成也不会被 TTL 打断
777 802 * —— 这比在流式回调里 touch() 少一处需要维护的调用点。
... ... @@ -2451,14 +2476,16 @@ router.post(&#39;/:agentId/chat&#39;, async (req, res) =&gt; {
2451 2476 const sendSSE = setupSSE(res);
2452 2477 const identity = loadIdentity(agentDir);
2453 2478  
  2479 + const kind = resolveKind(req.body);
  2480 +
2454 2481 // 确保 meta.json 存在
2455 2482 let meta = loadMeta(agentId, convId);
2456 2483 if (!meta) {
2457 2484 meta = {
2458 2485 conversationId: convId,
2459 2486 agentId,
2460   - title: req.body.isHeartbeat ? '⏱️ 自主心跳巡检' : message.slice(0, 50),
2461   - type: req.body.isHeartbeat ? 'heartbeat' : 'chat',
  2487 + title: kind ? kind[0] : message.slice(0, 50),
  2488 + type: kind ? kind[1] : 'chat',
2462 2489 createdAt: new Date().toISOString(),
2463 2490 updatedAt: new Date().toISOString(),
2464 2491 status: 'running',
... ... @@ -2753,10 +2780,9 @@ ${resultsText.join(&#39;\n\n&#39;)}
2753 2780 } finally {
2754 2781 agentChatLocks.delete(agentId);
2755 2782 res.end();
2756   - // 心跳会话是一次性的(heartbeat-service 每次都生成新 convId),留在池子里就是纯泄漏。
2757   - // 普通对话保留会话复用能力,只上空闲 TTL。
  2783 + // convId 用完即弃的那几类立刻回收(留着就是纯泄漏);其余保留会话复用能力,只上空闲 TTL。
2758 2784 const sessionKey = `${agentId}:${convId}`;
2759   - if (req.body.isHeartbeat) closeSession(sessionKey, '心跳结束');
  2785 + if (kind?.[2]) closeSession(sessionKey, `${kind[1]} 结束`);
2760 2786 else armSessionIdle(sessionKey);
2761 2787 }
2762 2788 });
... ...
server/services/company-heartbeat-service.js
... ... @@ -256,7 +256,7 @@ class CompanyHeartbeatService {
256 256 ].join('\n');
257 257  
258 258 // 通过 HTTP 触发 Agent Chat (异步,不阻塞心跳)
259   - this._sendAgentChat(companyId, task.agent, taskMessage, `pipeline-${runMeta.runId}-step-${task.stepIndex}`);
  259 + this._sendAgentChat(companyId, task.agent, taskMessage, `pipeline-${runMeta.runId}-step-${task.stepIndex}`, 'pipeline');
260 260 }
261 261 }
262 262  
... ... @@ -278,7 +278,7 @@ class CompanyHeartbeatService {
278 278 `5. 无事则回复 "[心跳正常]"`,
279 279 ].join('\n');
280 280  
281   - this._sendAgentChat(companyId, ceoId, msg, `heartbeat-${Date.now().toString(36)}`);
  281 + this._sendAgentChat(companyId, ceoId, msg, `heartbeat-${Date.now().toString(36)}`, 'heartbeat');
282 282 }
283 283  
284 284 // ═══════════════════════════════════════
... ... @@ -304,7 +304,7 @@ class CompanyHeartbeatService {
304 304 ].join('\n');
305 305  
306 306 console.log(`[Schedule] 📅 ${companyId} 触发日程: ${slot.time} → ${slot.agent} (${slot.task})`);
307   - this._sendAgentChat(companyId, agentId, msg, `schedule-${slot.id}-${Date.now().toString(36)}`);
  307 + this._sendAgentChat(companyId, agentId, msg, `schedule-${slot.id}-${Date.now().toString(36)}`, 'schedule');
308 308 }
309 309 }
310 310  
... ... @@ -329,7 +329,7 @@ class CompanyHeartbeatService {
329 329 ].join('\n');
330 330  
331 331 // 发给 Master Agent
332   - this._sendAgentChat(null, 'master', msg, `alert-${companyId}-${Date.now().toString(36)}`);
  332 + this._sendAgentChat(null, 'master', msg, `alert-${companyId}-${Date.now().toString(36)}`, 'alert');
333 333 console.log(`[Alert] 🚨 已向 Master 发送告警: ${companyId} - ${alertMessage.substring(0, 80)}`);
334 334 }
335 335  
... ... @@ -376,7 +376,7 @@ class CompanyHeartbeatService {
376 376 `请查看以上日报。如需调整策略或设定新目标,请下发指令给 CEO。`,
377 377 ].join('\n');
378 378  
379   - this._sendAgentChat(null, 'master', msg, `daily-report-${companyId}-${today}`);
  379 + this._sendAgentChat(null, 'master', msg, `daily-report-${companyId}-${today}`, 'report');
380 380 console.log(`[DailyReport] 📊 ${companyId} 晨会日报已发送给 Master`);
381 381 }
382 382  
... ... @@ -467,12 +467,17 @@ class CompanyHeartbeatService {
467 467 /**
468 468 * 通过 HTTP 向 Agent 发送消息 (异步, 不阻塞)
469 469 * companyId 为 null 时发给全局 Agent (如 master)
  470 + *
  471 + * kind 决定对方那边的对话标题 / meta.type / 会话回收策略(见 agent-chat.CALL_KINDS)。
  472 + * 之前这里给五类调用无差别打 isHeartbeat:true,对话列表里全成了「自主心跳巡检」。
  473 + * isHeartbeat 仍然发,但只对真心跳为真 —— 老的消费方(如果还有)行为不变。
470 474 */
471   - _sendAgentChat(companyId, agentId, message, conversationId) {
  475 + _sendAgentChat(companyId, agentId, message, conversationId, kind = 'heartbeat') {
472 476 const postData = JSON.stringify({
473 477 message,
474 478 conversationId,
475   - isHeartbeat: true,
  479 + kind,
  480 + isHeartbeat: kind === 'heartbeat',
476 481 });
477 482  
478 483 const options = {
... ...
server/tests/unit/agent-chat-session-reclaim.test.js
... ... @@ -4,12 +4,14 @@
4 4 * agent-chat 的 `sessions` Map 存的是**常驻**的 claude CLI 子进程(stream-json 模式,
5 5 * 不是一次性的 `claude -p`)。修之前只有四条回收路径:协议 error / 子进程 disconnected /
6 6 * 手动删对话 / 手动 stop —— 「正常跑完的对话」不在其中。而 heartbeat-service 每 5 分钟
7   - * 用一个**全新** convId(`heartbeat-${Date.now()}`)打进来,于是每次心跳都新起一个进程且
  7 + * 用一个**全新** convId(前缀 heartbeat- 后面拼时间戳)打进来,于是每次心跳都新起一个进程且
8 8 * 永不释放。cto / coo 两个本体的 intervalMinutes 都是 5,正好对上观测到的「每 5 分钟涨 2-3 个」。
9 9 *
10 10 * 探针用现成的 `/chat/:convId/stop`:它只在 sessions 里查得到条目时才返回 success:true,
11 11 * 所以不用为测试往模块里加导出口。断言全是「会话还在不在」,不看 chat 自己的响应内容。
12 12 */
  13 +// convId 一律用固定字面量(不拼时间戳):见 test-id-stability —— 测试写进 system/agents/ 的东西
  14 +// 都是正式条目,「每跑一次换一个 id」会变成注册表泄漏。固定之后第二次跑还顺带走了「复用已有对话」那条分支。
13 15 process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; // 必须早于 require:acp-core 的 mock 开关是模块级常量
14 16 process.env.AGENT_SESSION_IDLE_MS = '400'; // 同理,SESSION_IDLE_MS 是模块级常量
15 17  
... ... @@ -24,10 +26,22 @@ const ROOT = path.resolve(__dirname, &#39;../../..&#39;);
24 26 const AGENT = 'zz-session-reclaim';
25 27 const BODY = path.join(ROOT, 'system', 'agents', AGENT);
26 28  
  29 +/** 只清运行态。本体定义(config/identity/agent.md)留下,与 zz-tenant-* 同一约定:
  30 + * 测试建出来的 agent 也是正式注册表条目,建了又删会让并行的全量遍历用例撞上
  31 + * 「列到了名字、进去时已经没了」的 flaky(见 test-id-stability 的头注)。 */
  32 +function clearRuntimeState() {
  33 + for (const d of ['conversations', '_global', 'memory']) {
  34 + fs.rmSync(path.join(BODY, d), { recursive: true, force: true });
  35 + }
  36 +}
  37 +
27 38 describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () => {
28 39 let base, srv;
29 40  
30 41 beforeAll(async () => {
  42 + // 固定 convId 之后,上一次跑(尤其是中途失败那次)残留的 meta.json 会被 loadMeta 命中,
  43 + // 标题就不再重算 —— 断言会验到历史值。所以进来先清一遍运行态。
  44 + clearRuntimeState();
31 45 fs.mkdirSync(BODY, { recursive: true });
32 46 fs.writeFileSync(path.join(BODY, 'config.json'), JSON.stringify({ id: AGENT, type: 'skill', name: AGENT, status: 'active' }, null, 2));
33 47 fs.writeFileSync(path.join(BODY, 'identity.md'), `# ${AGENT}\n`);
... ... @@ -41,9 +55,7 @@ describe(&#39;agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)&#39;, () =
41 55  
42 56 afterAll(async () => {
43 57 if (srv) await new Promise((r) => srv.close(r));
44   - // 本体定义(config/identity/agent.md)留下,与 zz-tenant-* 同一约定:测试建出来的 agent 也是
45   - // 注册表条目,建了又删会让并行用例撞上「列到了名字、进去时已经没了」的 flaky。只清运行态。
46   - for (const d of ['conversations', '_global', 'memory']) fs.rmSync(path.join(BODY, d), { recursive: true, force: true });
  58 + clearRuntimeState();
47 59 });
48 60  
49 61 /** 跑完一轮对话(SSE 要读到流结束,否则 finally 还没执行) */
... ... @@ -62,24 +74,56 @@ describe(&#39;agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)&#39;, () =
62 74 return (await res.json()).success === true;
63 75 };
64 76  
  77 + /** 探针:对话的 meta(标题 / type) */
  78 + const metaOf = async (convId) =>
  79 + (await fetch(`${base}/api/agent/${AGENT}/conversations/${convId}`)).json();
  80 +
65 81 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
66 82  
67 83 it('心跳会话跑完立刻回收(这是泄漏的正主)', async () => {
68   - const conv = `heartbeat-${Date.now().toString(36)}`;
  84 + const conv = 'heartbeat-fixed';
69 85 await chat(conv, { isHeartbeat: true });
70 86 expect(await alive(conv)).toBe(false);
71 87 });
72 88  
73 89 it('普通对话保留会话(复用能力不能被顺手砍掉)', async () => {
74   - const conv = `conv-keep-${Date.now().toString(36)}`;
  90 + const conv = 'conv-keep';
75 91 await chat(conv);
76 92 expect(await alive(conv)).toBe(true);
77 93 });
78 94  
79 95 it('普通对话空闲超过 TTL 后被回收', async () => {
80   - const conv = `conv-ttl-${Date.now().toString(36)}`;
  96 + const conv = 'conv-ttl';
81 97 await chat(conv);
82 98 await sleep(700); // > AGENT_SESSION_IDLE_MS
83 99 expect(await alive(conv)).toBe(false);
84 100 });
  101 +
  102 + // ── kind:四类一次性调用被错标成心跳,且 pipeline 不能跟它们同策略 ──
  103 +
  104 + it.each([
  105 + ['heartbeat', '⏱️ 自主心跳巡检'],
  106 + ['schedule', '📅 日程任务'],
  107 + ['alert', '🚨 告警'],
  108 + ['report', '📊 日报'],
  109 + ['pipeline', '🔧 流水线步骤'],
  110 + ])('kind=%s 的对话按自己的类型标注,不再一律显示成心跳', async (kind, title) => {
  111 + const conv = `k-${kind}`;
  112 + await chat(conv, { kind });
  113 + const meta = await metaOf(conv);
  114 + expect(meta.type).toBe(kind);
  115 + expect(meta.title).toBe(title);
  116 + });
  117 +
  118 + it.each(['schedule', 'alert', 'report'])('kind=%s(convId 用完即弃)跑完立刻回收', async (kind) => {
  119 + const conv = `r-${kind}`;
  120 + await chat(conv, { kind });
  121 + expect(await alive(conv)).toBe(false);
  122 + });
  123 +
  124 + it('kind=pipeline 保留会话 —— 被驳回重做会复用同一个 convId,冷启动要重注入历史', async () => {
  125 + const conv = 'pipeline-run1-step-0';
  126 + await chat(conv, { kind: 'pipeline' });
  127 + expect(await alive(conv)).toBe(true);
  128 + });
85 129 });
... ...