Commit 3e718aac7a2c54026e3f82fd1ac0ebf0acf06900
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>
Showing
6 changed files
with
123 additions
and
23 deletions
.env.example
| @@ -24,6 +24,11 @@ export WHATSAPP_TRIGGER=@Andy | @@ -24,6 +24,11 @@ export WHATSAPP_TRIGGER=@Andy | ||
| 24 | export WHATSAPP_BOT_NAME=Andy | 24 | export WHATSAPP_BOT_NAME=Andy |
| 25 | export WHATSAPP_SELF_CHAT=true | 25 | export WHATSAPP_SELF_CHAT=true |
| 26 | 26 | ||
| 27 | +# agent-chat 会话池:空闲多久回收一个常驻 claude CLI 子进程(毫秒,默认 30 分钟) | ||
| 28 | +# 调小能更快释放内存,代价是多轮对话更容易冷启动重注入历史。 | ||
| 29 | +# 一次性调用(心跳 / 日程 / 告警 / 日报)不受这个值影响,它们跑完即回收。 | ||
| 30 | +# AGENT_SESSION_IDLE_MS=1800000 | ||
| 31 | + | ||
| 27 | # Open API(外部接入) | 32 | # Open API(外部接入) |
| 28 | # 逗号分隔的 API Key 列表 | 33 | # 逗号分隔的 API Key 列表 |
| 29 | OPEN_API_KEYS=sk-your-key-1,sk-your-key-2 | 34 | OPEN_API_KEYS=sk-your-key-1,sk-your-key-2 |
CLAUDE.md
| @@ -145,6 +145,12 @@ auto-dev "<task>" # bin 入口 → server/src/cli/auto-dev.js | @@ -145,6 +145,12 @@ auto-dev "<task>" # bin 入口 → server/src/cli/auto-dev.js | ||
| 145 | - 生产环境只跑 `server/index.js`:它会同时托管 API + `web/dist/` 静态文件(`NODE_ENV=production`)。 | 145 | - 生产环境只跑 `server/index.js`:它会同时托管 API + `web/dist/` 静态文件(`NODE_ENV=production`)。 |
| 146 | - `start.sh` **只**做:kill 占用 3010/5173 的进程 → 起 `server/index.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。 | 146 | - `start.sh` **只**做:kill 占用 3010/5173 的进程 → 起 `server/index.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。 |
| 147 | - `server/index.js` 与 `agent-server.js` 都内置了 **body-sanitizer 中间件**:在 `express.json()` 之前清掉 `\x00-\x1F` 控制字符,避免外部 Agent 通过 `curl` 传未转义换行/制表符导致的 `Bad control character` 报错。修改请求体解析链路前先看这两段。 | 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 | - 模块别名 `@server` → `server/`(`module-alias` 注册),跨子包 require 时可用。 | 154 | - 模块别名 `@server` → `server/`(`module-alias` 注册),跨子包 require 时可用。 |
| 149 | - Claude CLI 进程默认带 `--dangerously-skip-permissions`,写入受限路径前自己评估风险。 | 155 | - Claude CLI 进程默认带 `--dangerously-skip-permissions`,写入受限路径前自己评估风险。 |
| 150 | - DevLoop 详见上方「DevLoop 子系统」一节;需要 wireframe / SVG 图表 / 完整人类阅读版时才回查 `_spec/*.md` 同名的 `*.html`。 | 156 | - DevLoop 详见上方「DevLoop 子系统」一节;需要 wireframe / SVG 图表 / 完整人类阅读版时才回查 `_spec/*.md` 同名的 `*.html`。 |
docs/todo/system-design/28-followups.md
| @@ -95,9 +95,12 @@ | @@ -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,12 +108,23 @@ | ||
| 105 | | `no-stale-templates-path.test.js` | 活定义里不许出现已死的 `system/templates`(判据:git 跟踪的才是定义,未跟踪的是运行态产物) | 同上 | | 108 | | `no-stale-templates-path.test.js` | 活定义里不许出现已死的 `system/templates`(判据:git 跟踪的才是定义,未跟踪的是运行态产物) | 同上 | |
| 106 | | `repo-hygiene.test.js` | tracked 与 ignored 不重叠 | 列出越界文件 | | 109 | | `repo-hygiene.test.js` | tracked 与 ignored 不重叠 | 列出越界文件 | |
| 107 | | `runs-root-single-holder.test.js` | 凡是把 `process.env.GRAPH_RUNS_DIR` 绑到标识符上的模块,那个标识符必须在**代码里**(不是注释里)真正被用到 | 精确点名到文件行号 | | 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 | > 第四条的来历值得记:`goal-contract.js` 里曾有一行 `const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null`, | 113 | > 第四条的来历值得记:`goal-contract.js` 里曾有一行 `const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null`, |
| 110 | > 注释白纸黑字写着「与 graph-engine.RUNS_ROOT 是同一个变量(测试隔离在用)」,**全文件却零引用**。 | 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,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 | * 请求结束后给会话上弦;请求开始时由 getOrCreateProtocol 停表。 | 800 | * 请求结束后给会话上弦;请求开始时由 getOrCreateProtocol 停表。 |
| 776 | * 计时器只在「没有在途请求」的间隙走,所以再长的单次生成也不会被 TTL 打断 | 801 | * 计时器只在「没有在途请求」的间隙走,所以再长的单次生成也不会被 TTL 打断 |
| 777 | * —— 这比在流式回调里 touch() 少一处需要维护的调用点。 | 802 | * —— 这比在流式回调里 touch() 少一处需要维护的调用点。 |
| @@ -2451,14 +2476,16 @@ router.post('/:agentId/chat', async (req, res) => { | @@ -2451,14 +2476,16 @@ router.post('/:agentId/chat', async (req, res) => { | ||
| 2451 | const sendSSE = setupSSE(res); | 2476 | const sendSSE = setupSSE(res); |
| 2452 | const identity = loadIdentity(agentDir); | 2477 | const identity = loadIdentity(agentDir); |
| 2453 | 2478 | ||
| 2479 | + const kind = resolveKind(req.body); | ||
| 2480 | + | ||
| 2454 | // 确保 meta.json 存在 | 2481 | // 确保 meta.json 存在 |
| 2455 | let meta = loadMeta(agentId, convId); | 2482 | let meta = loadMeta(agentId, convId); |
| 2456 | if (!meta) { | 2483 | if (!meta) { |
| 2457 | meta = { | 2484 | meta = { |
| 2458 | conversationId: convId, | 2485 | conversationId: convId, |
| 2459 | agentId, | 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 | createdAt: new Date().toISOString(), | 2489 | createdAt: new Date().toISOString(), |
| 2463 | updatedAt: new Date().toISOString(), | 2490 | updatedAt: new Date().toISOString(), |
| 2464 | status: 'running', | 2491 | status: 'running', |
| @@ -2753,10 +2780,9 @@ ${resultsText.join('\n\n')} | @@ -2753,10 +2780,9 @@ ${resultsText.join('\n\n')} | ||
| 2753 | } finally { | 2780 | } finally { |
| 2754 | agentChatLocks.delete(agentId); | 2781 | agentChatLocks.delete(agentId); |
| 2755 | res.end(); | 2782 | res.end(); |
| 2756 | - // 心跳会话是一次性的(heartbeat-service 每次都生成新 convId),留在池子里就是纯泄漏。 | ||
| 2757 | - // 普通对话保留会话复用能力,只上空闲 TTL。 | 2783 | + // convId 用完即弃的那几类立刻回收(留着就是纯泄漏);其余保留会话复用能力,只上空闲 TTL。 |
| 2758 | const sessionKey = `${agentId}:${convId}`; | 2784 | const sessionKey = `${agentId}:${convId}`; |
| 2759 | - if (req.body.isHeartbeat) closeSession(sessionKey, '心跳结束'); | 2785 | + if (kind?.[2]) closeSession(sessionKey, `${kind[1]} 结束`); |
| 2760 | else armSessionIdle(sessionKey); | 2786 | else armSessionIdle(sessionKey); |
| 2761 | } | 2787 | } |
| 2762 | }); | 2788 | }); |
server/services/company-heartbeat-service.js
| @@ -256,7 +256,7 @@ class CompanyHeartbeatService { | @@ -256,7 +256,7 @@ class CompanyHeartbeatService { | ||
| 256 | ].join('\n'); | 256 | ].join('\n'); |
| 257 | 257 | ||
| 258 | // 通过 HTTP 触发 Agent Chat (异步,不阻塞心跳) | 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,7 +278,7 @@ class CompanyHeartbeatService { | ||
| 278 | `5. 无事则回复 "[心跳正常]"`, | 278 | `5. 无事则回复 "[心跳正常]"`, |
| 279 | ].join('\n'); | 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,7 +304,7 @@ class CompanyHeartbeatService { | ||
| 304 | ].join('\n'); | 304 | ].join('\n'); |
| 305 | 305 | ||
| 306 | console.log(`[Schedule] 📅 ${companyId} 触发日程: ${slot.time} → ${slot.agent} (${slot.task})`); | 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,7 +329,7 @@ class CompanyHeartbeatService { | ||
| 329 | ].join('\n'); | 329 | ].join('\n'); |
| 330 | 330 | ||
| 331 | // 发给 Master Agent | 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 | console.log(`[Alert] 🚨 已向 Master 发送告警: ${companyId} - ${alertMessage.substring(0, 80)}`); | 333 | console.log(`[Alert] 🚨 已向 Master 发送告警: ${companyId} - ${alertMessage.substring(0, 80)}`); |
| 334 | } | 334 | } |
| 335 | 335 | ||
| @@ -376,7 +376,7 @@ class CompanyHeartbeatService { | @@ -376,7 +376,7 @@ class CompanyHeartbeatService { | ||
| 376 | `请查看以上日报。如需调整策略或设定新目标,请下发指令给 CEO。`, | 376 | `请查看以上日报。如需调整策略或设定新目标,请下发指令给 CEO。`, |
| 377 | ].join('\n'); | 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 | console.log(`[DailyReport] 📊 ${companyId} 晨会日报已发送给 Master`); | 380 | console.log(`[DailyReport] 📊 ${companyId} 晨会日报已发送给 Master`); |
| 381 | } | 381 | } |
| 382 | 382 | ||
| @@ -467,12 +467,17 @@ class CompanyHeartbeatService { | @@ -467,12 +467,17 @@ class CompanyHeartbeatService { | ||
| 467 | /** | 467 | /** |
| 468 | * 通过 HTTP 向 Agent 发送消息 (异步, 不阻塞) | 468 | * 通过 HTTP 向 Agent 发送消息 (异步, 不阻塞) |
| 469 | * companyId 为 null 时发给全局 Agent (如 master) | 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 | const postData = JSON.stringify({ | 476 | const postData = JSON.stringify({ |
| 473 | message, | 477 | message, |
| 474 | conversationId, | 478 | conversationId, |
| 475 | - isHeartbeat: true, | 479 | + kind, |
| 480 | + isHeartbeat: kind === 'heartbeat', | ||
| 476 | }); | 481 | }); |
| 477 | 482 | ||
| 478 | const options = { | 483 | const options = { |
server/tests/unit/agent-chat-session-reclaim.test.js
| @@ -4,12 +4,14 @@ | @@ -4,12 +4,14 @@ | ||
| 4 | * agent-chat 的 `sessions` Map 存的是**常驻**的 claude CLI 子进程(stream-json 模式, | 4 | * agent-chat 的 `sessions` Map 存的是**常驻**的 claude CLI 子进程(stream-json 模式, |
| 5 | * 不是一次性的 `claude -p`)。修之前只有四条回收路径:协议 error / 子进程 disconnected / | 5 | * 不是一次性的 `claude -p`)。修之前只有四条回收路径:协议 error / 子进程 disconnected / |
| 6 | * 手动删对话 / 手动 stop —— 「正常跑完的对话」不在其中。而 heartbeat-service 每 5 分钟 | 6 | * 手动删对话 / 手动 stop —— 「正常跑完的对话」不在其中。而 heartbeat-service 每 5 分钟 |
| 7 | - * 用一个**全新** convId(`heartbeat-${Date.now()}`)打进来,于是每次心跳都新起一个进程且 | 7 | + * 用一个**全新** convId(前缀 heartbeat- 后面拼时间戳)打进来,于是每次心跳都新起一个进程且 |
| 8 | * 永不释放。cto / coo 两个本体的 intervalMinutes 都是 5,正好对上观测到的「每 5 分钟涨 2-3 个」。 | 8 | * 永不释放。cto / coo 两个本体的 intervalMinutes 都是 5,正好对上观测到的「每 5 分钟涨 2-3 个」。 |
| 9 | * | 9 | * |
| 10 | * 探针用现成的 `/chat/:convId/stop`:它只在 sessions 里查得到条目时才返回 success:true, | 10 | * 探针用现成的 `/chat/:convId/stop`:它只在 sessions 里查得到条目时才返回 success:true, |
| 11 | * 所以不用为测试往模块里加导出口。断言全是「会话还在不在」,不看 chat 自己的响应内容。 | 11 | * 所以不用为测试往模块里加导出口。断言全是「会话还在不在」,不看 chat 自己的响应内容。 |
| 12 | */ | 12 | */ |
| 13 | +// convId 一律用固定字面量(不拼时间戳):见 test-id-stability —— 测试写进 system/agents/ 的东西 | ||
| 14 | +// 都是正式条目,「每跑一次换一个 id」会变成注册表泄漏。固定之后第二次跑还顺带走了「复用已有对话」那条分支。 | ||
| 13 | process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; // 必须早于 require:acp-core 的 mock 开关是模块级常量 | 15 | process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; // 必须早于 require:acp-core 的 mock 开关是模块级常量 |
| 14 | process.env.AGENT_SESSION_IDLE_MS = '400'; // 同理,SESSION_IDLE_MS 是模块级常量 | 16 | process.env.AGENT_SESSION_IDLE_MS = '400'; // 同理,SESSION_IDLE_MS 是模块级常量 |
| 15 | 17 | ||
| @@ -24,10 +26,22 @@ const ROOT = path.resolve(__dirname, '../../..'); | @@ -24,10 +26,22 @@ const ROOT = path.resolve(__dirname, '../../..'); | ||
| 24 | const AGENT = 'zz-session-reclaim'; | 26 | const AGENT = 'zz-session-reclaim'; |
| 25 | const BODY = path.join(ROOT, 'system', 'agents', AGENT); | 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 | describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () => { | 38 | describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () => { |
| 28 | let base, srv; | 39 | let base, srv; |
| 29 | 40 | ||
| 30 | beforeAll(async () => { | 41 | beforeAll(async () => { |
| 42 | + // 固定 convId 之后,上一次跑(尤其是中途失败那次)残留的 meta.json 会被 loadMeta 命中, | ||
| 43 | + // 标题就不再重算 —— 断言会验到历史值。所以进来先清一遍运行态。 | ||
| 44 | + clearRuntimeState(); | ||
| 31 | fs.mkdirSync(BODY, { recursive: true }); | 45 | fs.mkdirSync(BODY, { recursive: true }); |
| 32 | fs.writeFileSync(path.join(BODY, 'config.json'), JSON.stringify({ id: AGENT, type: 'skill', name: AGENT, status: 'active' }, null, 2)); | 46 | fs.writeFileSync(path.join(BODY, 'config.json'), JSON.stringify({ id: AGENT, type: 'skill', name: AGENT, status: 'active' }, null, 2)); |
| 33 | fs.writeFileSync(path.join(BODY, 'identity.md'), `# ${AGENT}\n`); | 47 | fs.writeFileSync(path.join(BODY, 'identity.md'), `# ${AGENT}\n`); |
| @@ -41,9 +55,7 @@ describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () = | @@ -41,9 +55,7 @@ describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () = | ||
| 41 | 55 | ||
| 42 | afterAll(async () => { | 56 | afterAll(async () => { |
| 43 | if (srv) await new Promise((r) => srv.close(r)); | 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 | /** 跑完一轮对话(SSE 要读到流结束,否则 finally 还没执行) */ | 61 | /** 跑完一轮对话(SSE 要读到流结束,否则 finally 还没执行) */ |
| @@ -62,24 +74,56 @@ describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () = | @@ -62,24 +74,56 @@ describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () = | ||
| 62 | return (await res.json()).success === true; | 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 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | 81 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 66 | 82 | ||
| 67 | it('心跳会话跑完立刻回收(这是泄漏的正主)', async () => { | 83 | it('心跳会话跑完立刻回收(这是泄漏的正主)', async () => { |
| 68 | - const conv = `heartbeat-${Date.now().toString(36)}`; | 84 | + const conv = 'heartbeat-fixed'; |
| 69 | await chat(conv, { isHeartbeat: true }); | 85 | await chat(conv, { isHeartbeat: true }); |
| 70 | expect(await alive(conv)).toBe(false); | 86 | expect(await alive(conv)).toBe(false); |
| 71 | }); | 87 | }); |
| 72 | 88 | ||
| 73 | it('普通对话保留会话(复用能力不能被顺手砍掉)', async () => { | 89 | it('普通对话保留会话(复用能力不能被顺手砍掉)', async () => { |
| 74 | - const conv = `conv-keep-${Date.now().toString(36)}`; | 90 | + const conv = 'conv-keep'; |
| 75 | await chat(conv); | 91 | await chat(conv); |
| 76 | expect(await alive(conv)).toBe(true); | 92 | expect(await alive(conv)).toBe(true); |
| 77 | }); | 93 | }); |
| 78 | 94 | ||
| 79 | it('普通对话空闲超过 TTL 后被回收', async () => { | 95 | it('普通对话空闲超过 TTL 后被回收', async () => { |
| 80 | - const conv = `conv-ttl-${Date.now().toString(36)}`; | 96 | + const conv = 'conv-ttl'; |
| 81 | await chat(conv); | 97 | await chat(conv); |
| 82 | await sleep(700); // > AGENT_SESSION_IDLE_MS | 98 | await sleep(700); // > AGENT_SESSION_IDLE_MS |
| 83 | expect(await alive(conv)).toBe(false); | 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 | }); |
-
mentioned in commit fb25578fd4883008c886372737b308608f8e1a3a
-
mentioned in commit 2092a2e213bdb68b04648fecb33b417049a226a2
-
mentioned in commit d8b4346d1b9a693b6325855e89eeb3aa871157cd