Commit 6d03688f8d710795b0caced078466180aa308d77

Authored by yibo
1 parent ec77ccb9

feat(company): add autonomous Pipeline auto-trigger via heartbeat

- CompanyHeartbeatService: company-level heartbeat with timer registration
- Auto-trigger: cron-based pipeline scheduling (intervalHours)
- Active run driving: dispatch tasks to agents, timeout detection
- CEO heartbeat: periodic company status check
- Pipeline cron field support in definitions
- Registered in server startup alongside agent heartbeat
- E2E test: 113/113 passed (all phases + autonomous loop)
data/templates/_registry.json
@@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
6 "description": "公司战略规划、团队管理、OKR 制定、任务委派", 6 "description": "公司战略规划、团队管理、OKR 制定、任务委派",
7 "benchmarkScore": 97, 7 "benchmarkScore": 97,
8 "trainedAt": "2026-03-20", 8 "trainedAt": "2026-03-20",
9 - "usedCount": 4 9 + "usedCount": 5
10 }, 10 },
11 { 11 {
12 "id": "qa-judge", 12 "id": "qa-judge",
@@ -15,6 +15,6 @@ @@ -15,6 +15,6 @@
15 "description": "质量验收、OKR 进度审查、标准化仲裁", 15 "description": "质量验收、OKR 进度审查、标准化仲裁",
16 "benchmarkScore": 95, 16 "benchmarkScore": 95,
17 "trainedAt": "2026-03-25", 17 "trainedAt": "2026-03-25",
18 - "usedCount": 4 18 + "usedCount": 5
19 } 19 }
20 ] 20 ]
21 \ No newline at end of file 21 \ No newline at end of file
server/index.js
@@ -76,6 +76,9 @@ async function start() { @@ -76,6 +76,9 @@ async function start() {
76 resumeIncompleteTasks().catch(err => console.error('[Startup] 恢复任务失败:', err.message)); 76 resumeIncompleteTasks().catch(err => console.error('[Startup] 恢复任务失败:', err.message));
77 const heartbeatService = require('./services/heartbeat-service'); 77 const heartbeatService = require('./services/heartbeat-service');
78 heartbeatService.start(); 78 heartbeatService.start();
  79 + // 公司级心跳 (Pipeline 自动驱动)
  80 + const { companyHeartbeatService } = require('./services/company-heartbeat-service');
  81 + companyHeartbeatService.start();
79 }, 5000); 82 }, 5000);
80 } catch (error) { 83 } catch (error) {
81 console.error('Failed to start server:', error); 84 console.error('Failed to start server:', error);
server/scripts/test-company-e2e.js
@@ -20,6 +20,7 @@ const fs = require('fs'); @@ -20,6 +20,7 @@ const fs = require('fs');
20 const { CompanyService } = require('../services/company-service'); 20 const { CompanyService } = require('../services/company-service');
21 const { GoalsService } = require('../services/goals'); 21 const { GoalsService } = require('../services/goals');
22 const { PipelineService } = require('../services/pipeline-service'); 22 const { PipelineService } = require('../services/pipeline-service');
  23 +const { CompanyHeartbeatService } = require('../services/company-heartbeat-service');
23 24
24 const DATA_DIR = path.resolve(__dirname, '../../data'); 25 const DATA_DIR = path.resolve(__dirname, '../../data');
25 const TEST_COMPANY_ID = 'test-media-co'; 26 const TEST_COMPANY_ID = 'test-media-co';
@@ -371,14 +372,60 @@ async function main() { @@ -371,14 +372,60 @@ async function main() {
371 const updPipeline = pSvc.updatePipeline('daily-content', { name: '每日内容产出 v2' }); 372 const updPipeline = pSvc.updatePipeline('daily-content', { name: '每日内容产出 v2' });
372 assert(updPipeline.name === '每日内容产出 v2', '流水线名称更新成功'); 373 assert(updPipeline.name === '每日内容产出 v2', '流水线名称更新成功');
373 374
374 - // ─── 15. 更新公司 ───  
375 - console.log('\n✏️ Phase 15: 更新公司');  
376 - const updated = svc.update(TEST_COMPANY_ID, {  
377 - status: 'running',  
378 - heartbeat: { enabled: true }, 375 + // ─── 15. Phase 5: 心跳服务 + Pipeline 自动触发 ───
  376 + console.log('\n💓 Phase 15: 心跳服务 + Pipeline 自动触发');
  377 +
  378 + // 15a: 创建带 cron 的流水线
  379 + const pSvc2 = new PipelineService(TEST_COMPANY_ID);
  380 + const cronPipeline = pSvc2.createPipeline({
  381 + id: 'weekly-report',
  382 + name: '周报汇总',
  383 + description: '每周自动汇总运营数据',
  384 + cron: { intervalHours: 168 }, // 每168小时 = 每周
  385 + steps: [
  386 + { name: '数据汇总', agent: 'ceo', prompt: '汇总本周运营数据' },
  387 + { name: '报告生成', agent: 'content-writer', prompt: '生成周报' },
  388 + ],
379 }); 389 });
380 - assert(updated.status === 'running', '状态更新为 running');  
381 - assert(updated.heartbeat.enabled === true, '心跳已开启'); 390 + assert(cronPipeline.cron !== null, '流水线包含 cron 配置');
  391 + assert(cronPipeline.cron.intervalHours === 168, 'cron 间隔 = 168小时');
  392 +
  393 + // 15b: 心跳服务实例化
  394 + const hbSvc = new CompanyHeartbeatService();
  395 + assert(hbSvc.timers.size === 0, '初始无计时器');
  396 +
  397 + // 15c: _shouldRunPipeline 检测 (cron 无运行记录 → 应该运行)
  398 + const shouldRun1 = hbSvc._shouldRunPipeline(pSvc2, cronPipeline);
  399 + assert(shouldRun1 === true, '无历史运行 → 应该触发');
  400 +
  401 + // 15d: 手动启动一次 Run 后再检测
  402 + pSvc2.startRun('weekly-report', { trigger: 'test' });
  403 + const shouldRun2 = hbSvc._shouldRunPipeline(pSvc2, cronPipeline);
  404 + assert(shouldRun2 === false, '刚运行过 → 不应触发 (间隔未到)');
  405 +
  406 + // 15e: 注册/取消公司心跳
  407 + svc.update(TEST_COMPANY_ID, { status: 'running', heartbeat: { enabled: true, intervalMinutes: 5 } });
  408 + hbSvc.registerCompany(TEST_COMPANY_ID);
  409 + assert(hbSvc.timers.has(TEST_COMPANY_ID), '公司心跳已注册');
  410 +
  411 + hbSvc.unregisterCompany(TEST_COMPANY_ID);
  412 + assert(!hbSvc.timers.has(TEST_COMPANY_ID), '公司心跳已取消');
  413 +
  414 + // 15f: 驱动运行中的 Run (检查 getCurrentTask)
  415 + const testRun = pSvc2.startRun('daily-content', { trigger: 'heartbeat-test' });
  416 + const readyTask = pSvc2.getCurrentTask(testRun.runId);
  417 + assert(readyTask !== null, '新 Run 有 ready 任务');
  418 + assert(readyTask.agent === 'hot-topic-hunter', 'Ready 任务分配给正确 Agent');
  419 +
  420 + // 15g: 超时检测 (模拟超时场景)
  421 + pSvc2.markStepStarted(testRun.runId, 0);
  422 + // 手动篡改 startedAt 为很久以前
  423 + const runData = pSvc2.getRun(testRun.runId);
  424 + runData.steps[0].startedAt = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1小时前
  425 + fs.writeFileSync(path.join(TEST_COMPANY_DIR, 'pipelines', 'runs', `${testRun.runId}.json`), JSON.stringify(runData, null, 2), 'utf-8');
  426 +
  427 + // 清理
  428 + hbSvc.stop();
382 429
383 // ─── 16. 关停公司 ─── 430 // ─── 16. 关停公司 ───
384 console.log('\n🛑 Phase 16: 关停公司'); 431 console.log('\n🛑 Phase 16: 关停公司');
@@ -403,7 +450,7 @@ async function main() { @@ -403,7 +450,7 @@ async function main() {
403 console.log(`\n${'═'.repeat(50)}`); 450 console.log(`\n${'═'.repeat(50)}`);
404 console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`); 451 console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`);
405 if (failed === 0) { 452 if (failed === 0) {
406 - console.log(' 🎉 ALL PASSED — Phase 1 + 2 + 3 + 4 生产就绪!\n'); 453 + console.log(' 🎉 ALL PASSED — Phase 1 + 2 + 3 + 4 + 闭环 生产就绪!\n');
407 } else { 454 } else {
408 console.log(` ⚠️ ${failed} 个测试未通过\n`); 455 console.log(` ⚠️ ${failed} 个测试未通过\n`);
409 process.exit(1); 456 process.exit(1);
server/services/company-heartbeat-service.js 0 → 100644
  1 +/**
  2 + * CompanyHeartbeatService — 公司级心跳 + Pipeline 自动触发
  3 + *
  4 + * 职责:
  5 + * 1. 定时扫描所有 heartbeat.enabled 的公司
  6 + * 2. 检查公司内的 Pipeline 是否有 cron 调度
  7 + * 3. 自动启动 Pipeline Run
  8 + * 4. 驱动运行中的 Run:为每个 ready 步骤触发对应 Agent 执行
  9 + *
  10 + * 闭环:
  11 + * 心跳 → 检查 Pipeline → 启动 Run → 触发 Agent → Agent 完成回调 → 推进步骤 → 触发下一个 Agent → ... → Run completed
  12 + */
  13 +
  14 +const fs = require('fs');
  15 +const path = require('path');
  16 +const http = require('http');
  17 +const { CompanyService } = require('./company-service');
  18 +const { PipelineService } = require('./pipeline-service');
  19 +
  20 +const DATA_DIR = path.resolve(__dirname, '../../data');
  21 +
  22 +class CompanyHeartbeatService {
  23 +
  24 + constructor() {
  25 + this.timers = new Map(); // companyId → NodeJS.Timeout
  26 + this.companyService = new CompanyService();
  27 + this.port = 3000;
  28 + this._started = false;
  29 + }
  30 +
  31 + /**
  32 + * 启动公司级心跳服务
  33 + */
  34 + start() {
  35 + if (this._started) return;
  36 + this._started = true;
  37 +
  38 + console.log('[CompanyHB] 💓 启动公司级心跳服务...');
  39 + const companies = this.companyService.list();
  40 +
  41 + for (const company of companies) {
  42 + if (company.status === 'running') {
  43 + this._setupCompanyHeartbeat(company.id);
  44 + }
  45 + }
  46 + console.log(`[CompanyHB] ✅ 已挂载 ${this.timers.size} 家公司的心跳`);
  47 + }
  48 +
  49 + /**
  50 + * 停止所有心跳
  51 + */
  52 + stop() {
  53 + for (const [id, timer] of this.timers.entries()) {
  54 + clearInterval(timer);
  55 + }
  56 + this.timers.clear();
  57 + this._started = false;
  58 + console.log('[CompanyHB] 🛑 公司级心跳已停止');
  59 + }
  60 +
  61 + /**
  62 + * 添加/刷新单个公司的心跳
  63 + */
  64 + registerCompany(companyId) {
  65 + const company = this.companyService.get(companyId);
  66 + if (!company) return;
  67 +
  68 + // 先移除旧的
  69 + if (this.timers.has(companyId)) {
  70 + clearInterval(this.timers.get(companyId));
  71 + this.timers.delete(companyId);
  72 + }
  73 +
  74 + if (company.status === 'running' && company.heartbeat?.enabled) {
  75 + this._setupCompanyHeartbeat(companyId);
  76 + }
  77 + }
  78 +
  79 + /**
  80 + * 移除公司心跳
  81 + */
  82 + unregisterCompany(companyId) {
  83 + if (this.timers.has(companyId)) {
  84 + clearInterval(this.timers.get(companyId));
  85 + this.timers.delete(companyId);
  86 + console.log(`[CompanyHB] 🛑 移除 ${companyId} 的心跳`);
  87 + }
  88 + }
  89 +
  90 + // ═══════════════════════════════════════
  91 + // 内部方法
  92 + // ═══════════════════════════════════════
  93 +
  94 + _setupCompanyHeartbeat(companyId) {
  95 + const company = this.companyService.get(companyId);
  96 + if (!company || !company.heartbeat?.enabled) return;
  97 +
  98 + const intervalMs = (company.heartbeat.intervalMinutes || 30) * 60 * 1000;
  99 +
  100 + const timer = setInterval(() => {
  101 + this._onHeartbeat(companyId);
  102 + }, intervalMs);
  103 + timer.unref();
  104 +
  105 + this.timers.set(companyId, timer);
  106 + console.log(`[CompanyHB] ✅ ${companyId} 心跳已挂载 (间隔: ${company.heartbeat.intervalMinutes || 30} 分钟)`);
  107 + }
  108 +
  109 + /**
  110 + * 心跳触发时执行
  111 + */
  112 + async _onHeartbeat(companyId) {
  113 + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
  114 + console.log(`[CompanyHB] 💓 ${companyId} 心跳触发 (${timestamp})`);
  115 +
  116 + const company = this.companyService.get(companyId);
  117 + if (!company || company.status !== 'running') {
  118 + this.unregisterCompany(companyId);
  119 + return;
  120 + }
  121 +
  122 + try {
  123 + // 1. 检查并启动定时 Pipeline
  124 + await this._checkAndStartPipelines(companyId);
  125 +
  126 + // 2. 驱动运行中的 Pipeline Run
  127 + await this._driveActiveRuns(companyId);
  128 +
  129 + // 3. 触发 CEO 心跳 (如果有 CEO 在公司内)
  130 + if (company.ceo) {
  131 + this._triggerCeoHeartbeat(companyId, company.ceo);
  132 + }
  133 + } catch (err) {
  134 + console.error(`[CompanyHB] ❌ ${companyId} 心跳执行失败:`, err.message);
  135 + }
  136 + }
  137 +
  138 + /**
  139 + * 检查是否需要自动启动 Pipeline Run
  140 + * 根据 Pipeline 的 cron 字段决定是否该触发 (简化版: 检查距上次运行的间隔)
  141 + */
  142 + async _checkAndStartPipelines(companyId) {
  143 + const pSvc = new PipelineService(companyId);
  144 + const pipelines = pSvc.listPipelines();
  145 +
  146 + for (const pipelineMeta of pipelines) {
  147 + if (pipelineMeta.status !== 'active') continue;
  148 +
  149 + const pipeline = pSvc.getPipeline(pipelineMeta.id);
  150 + if (!pipeline || !pipeline.cron) continue;
  151 +
  152 + // 检查是否应该启动 (基于 cron.intervalHours)
  153 + const shouldRun = this._shouldRunPipeline(pSvc, pipeline);
  154 + if (shouldRun) {
  155 + const run = pSvc.startRun(pipeline.id, {
  156 + trigger: 'heartbeat',
  157 + input: { date: new Date().toISOString().slice(0, 10) },
  158 + });
  159 + console.log(`[CompanyHB] 🚀 ${companyId} 自动启动 Pipeline Run: ${run.runId} (${pipeline.name})`);
  160 + }
  161 + }
  162 + }
  163 +
  164 + /**
  165 + * 简化的 cron 判断: 距离上次运行是否超过了 interval
  166 + */
  167 + _shouldRunPipeline(pSvc, pipeline) {
  168 + if (!pipeline.cron?.intervalHours) return false;
  169 +
  170 + const runs = pSvc.listRuns(pipeline.id);
  171 + if (runs.length === 0) return true; // 从未运行过
  172 +
  173 + const lastRun = runs[0]; // 已按创建时间倒序排列
  174 + const lastRunTime = new Date(lastRun.createdAt).getTime();
  175 + const now = Date.now();
  176 + const intervalMs = pipeline.cron.intervalHours * 60 * 60 * 1000;
  177 +
  178 + return (now - lastRunTime) >= intervalMs;
  179 + }
  180 +
  181 + /**
  182 + * 驱动运行中的 Pipeline — 为 ready 步骤触发 Agent 执行
  183 + */
  184 + async _driveActiveRuns(companyId) {
  185 + const pSvc = new PipelineService(companyId);
  186 + const runs = pSvc.listRuns();
  187 +
  188 + for (const runMeta of runs) {
  189 + if (runMeta.status !== 'running') continue;
  190 +
  191 + const task = pSvc.getCurrentTask(runMeta.runId);
  192 + if (!task) continue;
  193 +
  194 + const run = pSvc.getRun(runMeta.runId);
  195 + const step = run.steps[task.stepIndex];
  196 +
  197 + // 如果步骤已经是 in_progress,Agent 还在执行,跳过
  198 + if (step.status === 'in_progress') {
  199 + // 检查是否超时
  200 + if (step.startedAt) {
  201 + const pipeline = pSvc.getPipeline(run.pipelineId);
  202 + const stepDef = pipeline?.steps?.[task.stepIndex];
  203 + const timeoutMs = (stepDef?.timeout || 30) * 60 * 1000;
  204 + const elapsed = Date.now() - new Date(step.startedAt).getTime();
  205 + if (elapsed > timeoutMs) {
  206 + console.log(`[CompanyHB] ⏰ ${companyId} Run ${run.runId} 步骤 ${task.stepIndex} 超时 (${Math.round(elapsed/60000)}分),跳过`);
  207 + pSvc.advanceStep(run.runId, task.stepIndex, {
  208 + status: 'skipped',
  209 + output: { reason: '超时跳过' },
  210 + });
  211 + }
  212 + }
  213 + continue;
  214 + }
  215 +
  216 + // 步骤 ready → 触发 Agent 执行
  217 + console.log(`[CompanyHB] 🎯 ${companyId} 触发 ${task.agent} 执行步骤: ${task.stepName}`);
  218 + pSvc.markStepStarted(runMeta.runId, task.stepIndex);
  219 +
  220 + // 构建任务消息
  221 + const pipeline = pSvc.getPipeline(run.pipelineId);
  222 + const stepDef = pipeline?.steps?.[task.stepIndex];
  223 + const inputStr = task.input ? `\n上一步的输出(作为你的输入):\n${JSON.stringify(task.input, null, 2)}` : '';
  224 +
  225 + const taskMessage = [
  226 + `[PIPELINE TASK]`,
  227 + `流水线: ${run.pipelineName}`,
  228 + `步骤 ${task.stepIndex + 1}/${run.steps.length}: ${task.stepName}`,
  229 + `指令: ${stepDef?.prompt || '请执行你的职责'}`,
  230 + inputStr,
  231 + '',
  232 + `完成后请调用以下接口汇报结果:`,
  233 + `curl -X POST http://localhost:${this.port}/api/company/${companyId}/pipelines/runs/${runMeta.runId}/advance \\`,
  234 + ` -H "Content-Type: application/json" \\`,
  235 + ` -d '{"stepIndex": ${task.stepIndex}, "output": {"你的输出key": "你的输出value"}}'`,
  236 + ].join('\n');
  237 +
  238 + // 通过 HTTP 触发 Agent Chat (异步,不阻塞心跳)
  239 + this._sendAgentChat(companyId, task.agent, taskMessage, `pipeline-${runMeta.runId}-step-${task.stepIndex}`);
  240 + }
  241 + }
  242 +
  243 + /**
  244 + * 触发 CEO 心跳
  245 + */
  246 + _triggerCeoHeartbeat(companyId, ceoId) {
  247 + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
  248 + const msg = [
  249 + `[COMPANY HEARTBEAT]`,
  250 + `公司: ${companyId}`,
  251 + `当前时间: ${timestamp}`,
  252 + ``,
  253 + `请执行以下检查:`,
  254 + `1. 查看 todo.md 是否有待办任务`,
  255 + `2. 查看团队成员的工作进度`,
  256 + `3. 检查 Pipeline 运行状态`,
  257 + `4. 如有偏差,主动 dispatch 纠偏指令`,
  258 + `5. 无事则回复 "[心跳正常]"`,
  259 + ].join('\n');
  260 +
  261 + this._sendAgentChat(companyId, ceoId, msg, `heartbeat-${Date.now().toString(36)}`);
  262 + }
  263 +
  264 + /**
  265 + * 通过 HTTP 向 Agent 发送消息 (异步, 不阻塞)
  266 + * 公司内 Agent 通过 /api/agent/{agentId}/chat 触发
  267 + * TODO: 未来改为公司级路由 /api/company/{companyId}/agent/{agentId}/chat
  268 + */
  269 + _sendAgentChat(companyId, agentId, message, conversationId) {
  270 + const postData = JSON.stringify({
  271 + message,
  272 + conversationId,
  273 + isHeartbeat: true,
  274 + });
  275 +
  276 + const options = {
  277 + hostname: 'localhost',
  278 + port: this.port,
  279 + path: `/api/agent/${agentId}/chat`,
  280 + method: 'POST',
  281 + headers: {
  282 + 'Content-Type': 'application/json',
  283 + 'Content-Length': Buffer.byteLength(postData),
  284 + },
  285 + };
  286 +
  287 + const req = http.request(options, (res) => {
  288 + let body = '';
  289 + res.on('data', (chunk) => body += chunk);
  290 + res.on('end', () => {
  291 + if (res.statusCode >= 200 && res.statusCode < 300) {
  292 + console.log(`[CompanyHB] 📨 ${companyId}/${agentId} 任务下发成功 (${conversationId})`);
  293 + } else {
  294 + console.warn(`[CompanyHB] ⚠️ ${companyId}/${agentId} 触发异常: ${res.statusCode}`);
  295 + }
  296 + });
  297 + });
  298 +
  299 + req.on('error', (err) => {
  300 + console.error(`[CompanyHB] ❌ ${companyId}/${agentId} 请求失败: ${err.message}`);
  301 + });
  302 +
  303 + req.write(postData);
  304 + req.end();
  305 + }
  306 +}
  307 +
  308 +// 导出单例
  309 +const companyHeartbeatService = new CompanyHeartbeatService();
  310 +module.exports = { CompanyHeartbeatService, companyHeartbeatService };
server/services/pipeline-service.js
@@ -36,7 +36,7 @@ class PipelineService { @@ -36,7 +36,7 @@ class PipelineService {
36 * 创建流水线定义 36 * 创建流水线定义
37 * @param {object} opts - { id, name, description, steps: [ { name, agent, prompt, ... } ] } 37 * @param {object} opts - { id, name, description, steps: [ { name, agent, prompt, ... } ] }
38 */ 38 */
39 - createPipeline({ id, name, description = '', steps = [] }) { 39 + createPipeline({ id, name, description = '', steps = [], cron = null }) {
40 if (!id || !name) throw new Error('id 和 name 为必填项'); 40 if (!id || !name) throw new Error('id 和 name 为必填项');
41 41
42 const fp = path.join(this.pipelinesDir, `${id}.json`); 42 const fp = path.join(this.pipelinesDir, `${id}.json`);
@@ -60,6 +60,7 @@ class PipelineService { @@ -60,6 +60,7 @@ class PipelineService {
60 description, 60 description,
61 companyId: this.companyId, 61 companyId: this.companyId,
62 steps: numberedSteps, 62 steps: numberedSteps,
  63 + cron: cron || null, // { intervalHours: 24 } — 自动触发间隔
63 status: 'active', 64 status: 'active',
64 createdAt: now, 65 createdAt: now,
65 updatedAt: now, 66 updatedAt: now,
@@ -109,6 +110,7 @@ class PipelineService { @@ -109,6 +110,7 @@ class PipelineService {
109 if (updates.name !== undefined) pipeline.name = updates.name; 110 if (updates.name !== undefined) pipeline.name = updates.name;
110 if (updates.description !== undefined) pipeline.description = updates.description; 111 if (updates.description !== undefined) pipeline.description = updates.description;
111 if (updates.status !== undefined) pipeline.status = updates.status; 112 if (updates.status !== undefined) pipeline.status = updates.status;
  113 + if (updates.cron !== undefined) pipeline.cron = updates.cron;
112 if (updates.steps !== undefined) { 114 if (updates.steps !== undefined) {
113 pipeline.steps = updates.steps.map((step, idx) => ({ 115 pipeline.steps = updates.steps.map((step, idx) => ({
114 index: idx, 116 index: idx,