Commit 6d03688f8d710795b0caced078466180aa308d77
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)
Showing
5 changed files
with
373 additions
and
11 deletions
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, |