Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 11 节:定时任务与主动触发 (Chronos)

“从被动响应到主动工作——好的 Agent 不只是等待,它知道什么时候该主动出击。”

本节改动全景

相比第 10 节,本节的改动集中在主动触发层,Plugin/Skill 系统与 Agent 主循环完全不变

改动点第 10 节第 11 节
触发方式只有用户主动发消息+ Cron 定时触发 / 系统事件触发
执行模式单一模式(响应用户)+ CHRONOS MODE(静默自检)
工具集plugin 工具+ notify(异常通知工具)
新增文件src/chronos/engine.tssrc/chronos/eventBus.tsconfig/chronos.jsonscripts/scan-secrets.js
Agent 构造固定 system prompt+ isChronos 标志位,动态切换 CHRONOS MODE

这一节的核心设计思想:把“时间“和“系统事件“也封装成消息发送者——ChronosEngine 以 caller: 'agent' 的身份向 Orchestrator 发送消息,Orchestrator 完全不感知“这是用户触发还是定时器触发“,它只是照常执行任务。区别仅在于 system prompt 里多了 CHRONOS MODE 约束。


整体架构

文档是设计蓝图,实际代码在此基础上有所完善(如 cron 表达式解析、isExecuting 竞态保护等)。读代码时以代码为准,文档描述核心骨架。

启动时:
  ChronosEngine.loadFromConfig('config/chronos.json')
    └── registerJob({id, expression, taskPrompt, enabled})
          └── scheduleCron(expression) → 计算下次触发时间 → setTimeout 链

每次 Cron 触发:
  ChronosEngine._runJob(config)
    ├── guard: isExecuting → skip(防止任务堆积)
    ├── isExecuting = true
    ├── 构造 ACPMessage {isChronos: true, caller: 'agent', sessionId: 'chronos-{id}-{ts}'}
    ├── agent.handle(msg) with CHRONOS MODE system prompt
    └── isExecuting = false(无论成功失败)

事件驱动触发:
  eventBus.emitEvent({type: 'SYSTEM_ALERT', payload: {...}})
    └── ChronosEngine.handleEvent(event)
          └── 同上 _runJob 流程(session ID 前缀为 'event-{type}-{ts}')

CHRONOS MODE(system prompt 追加):
  [CHRONOS MODE] 你在无人值守下自主运行。
  - 一切正常:保持静默,不发送通知
  - 发现异常:立即调用 notify 工具,停止其他操作
  - 硬性限制:最多 ${maxSteps} 次工具调用,超限即停止并汇报

两条触发路径的对比:

用户触发(正常模式)Cron/事件触发(CHRONOS MODE)
触发者人类用户ChronosEngine(定时器/事件)
session IDcli / web-{uuid}chronos-{jobId}-{ts} / event-{type}-{ts}
system prompt标准 Orchestrator 提示+ CHRONOS MODE 追加块
输出目标用户 terminal / 浏览器notify 工具(飞书 / QQ / stdout)
执行策略无特殊限制静默优先,异常才告警

为什么需要主动触发

【传统 Agent:被动响应】

  用户(主动提问)──> Orchestrator ──> Worker(执行)──> 结果返回给用户

  问题:用户不在线 = 什么都不发生

【增强 Agent:主动工作(本节新增)】

  触发源                          执行层                     输出
  ──────                          ──────                     ────
  [定时事件]  ──> Cron 调度 ──┐
                               ├──> ChronosEngine ──> Orchestrator ──> Worker(执行)
  [系统事件]  ──> Event Bus ──┘         │                                    │
  (Webhook/                         anti-deadloop                           │
   监控系统)                         isExecuting 锁                          ▼
                                    maxSteps 上限              notify 工具(异常时)
                                                                   │
                                                         ┌─────────┴──────────┐
                                                         ▼                    ▼
                                                    飞书卡片告警          QQ 私信推送

前 10 节的 xclaw 是纯被动架构——所有事情都等用户开口才开始。对于日常交互这已经足够,但两类场景会让被动架构失效:

场景 A — 时间敏感的例行巡检
  需求:每 15 分钟检查一次服务器内存和磁盘,超阈值立即告警
  被动架构:用户记不住,或者人睡觉了,没人发消息
  → 需要定时器主动触发 Agent 执行检查

场景 B — 外部系统事件响应
  需求:监控系统检测到 CPU 飙升,立刻触发 Agent 分析日志并给出建议
  被动架构:监控系统不会打字,无法"发消息"给 Agent
  → 需要事件总线让外部信号驱动 Agent

对比两种架构的执行时序:

被动架构 — "帮我检查服务器内存"
  凌晨 3:00:内存使用率飙升到 95%
  凌晨 3:00:无人值守,没有用户消息
  早上 9:00:用户上班,看到服务挂了 ← 已经晚了 6 小时

主动架构 — Cron 每 15 分钟检查
  凌晨 3:00:内存使用率飙升到 95%
  凌晨 3:00:Cron 触发 → Agent 检查 → 发现异常 → notify → 飞书告警
  凌晨 3:01:用户收到通知,可以远程处理 ← 1 分钟响应

1. Cron 调度器:ChronosEngine

1.1 轻量级 Cron 解析

node-cron 是功能完善的外部库,但引入它只为了一个调度功能并不合算。xclaw 实现了一个零依赖的 cron 解析器,支持项目所需的核心语法:

// src/chronos/engine.ts

// 支持的 cron 表达式语法:
//   *     — 匹配所有值
//   */n   — 每隔 n 个单位触发
//   n     — 精确值匹配
//
// 标准 5 字段格式:分钟 小时 日 月 周
// "*/15 * * * *"  — 每 15 分钟
// "0 1 * * *"     — 每天凌晨 1 点
// "*/1 * * * *"   — 每分钟(调试用)

function matchField(field: string, value: number): boolean {
  if (field === '*') return true;
  if (field.startsWith('*/')) {
    const step = parseInt(field.slice(2), 10);
    return value % step === 0;
  }
  return parseInt(field, 10) === value;
}

function cronMatches(expression: string, date: Date): boolean {
  const [min, hour, dom, month, dow] = expression.split(' ');
  return (
    matchField(min!,   date.getMinutes()) &&
    matchField(hour!,  date.getHours())   &&
    matchField(dom!,   date.getDate())    &&
    matchField(month!, date.getMonth() + 1) &&
    matchField(dow!,   date.getDay())
  );
}

// 计算到下一个匹配分钟的等待时间
function nextTickMs(expression: string): number {
  const now = new Date();
  // 从下一分钟开始搜索(当前分钟内已过)
  const start = new Date(now);
  start.setSeconds(0, 0);
  start.setMinutes(start.getMinutes() + 1);

  for (let i = 0; i < 60 * 24 * 7; i++) {  // 最多搜索一周
    const candidate = new Date(start.getTime() + i * 60_000);
    if (cronMatches(expression, candidate)) {
      return candidate.getTime() - Date.now();
    }
  }
  throw new Error(`no match found for cron expression: ${expression}`);
}

// 递归 setTimeout 实现 cron——每次触发后重新计算下次时间
function scheduleCron(expression: string, fn: () => void): { cancel: () => void } {
  let timer: NodeJS.Timeout | null = null;

  const tick = () => {
    fn();  // 先执行,再安排下次
    const delay = nextTickMs(expression);
    timer = setTimeout(tick, delay);
  };

  const delay = nextTickMs(expression);
  timer = setTimeout(tick, delay);

  return { cancel: () => { if (timer) clearTimeout(timer); } };
}

为什么用递归 setTimeout 而不是 setInterval:cron 表达式的触发间隔是不均匀的(“每天凌晨 1 点“的间隔正好是 24 小时,但下次触发时间要精确到分钟边界)。setInterval 会产生漂移,而递归 setTimeout 每次都重新计算到下一个匹配时刻,精度更高。

1.2 ChronosEngine 核心逻辑

// src/chronos/engine.ts

interface CronJobConfig {
  id: string;
  expression: string;
  taskPrompt: string;
  enabled: boolean;
}

interface JobEntry {
  config: CronJobConfig;
  cancel: (() => void) | null;
  isExecuting: boolean;  // 防止任务堆积的锁
}

export class ChronosEngine {
  private jobs = new Map<string, JobEntry>();
  private providerChain: string[];
  private maxSteps: number;

  constructor(providerChain: string[], maxSteps = 15) {
    this.providerChain = providerChain;
    this.maxSteps      = maxSteps;  // 系统触发任务的步数硬上限
  }

  loadFromConfig(configPath: string): void {
    if (!fs.existsSync(configPath)) {
      log(`[chronos] no config file found at ${configPath}, skipping`);
      return;
    }
    const configs = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as CronJobConfig[];
    for (const config of configs) {
      this.registerJob(config);
    }
  }

  registerJob(config: CronJobConfig): void {
    if (this.jobs.has(config.id)) {
      this.jobs.get(config.id)!.cancel?.();  // 停掉旧的
    }

    const entry: JobEntry = { config, cancel: null, isExecuting: false };
    this.jobs.set(config.id, entry);

    if (!config.enabled) {
      log(`[chronos] job [${config.id}] registered but disabled`);
      return;
    }

    try {
      const { cancel } = scheduleCron(config.expression, () => this._runJob(entry));
      entry.cancel = cancel;
      log(`[chronos] job [${config.id}] scheduled: ${config.expression}`);
    } catch (err: any) {
      log(`[chronos] job [${config.id}] failed to schedule: ${err.message}`);
    }
  }

  // 受锁保护的任务执行 — 上一次未完成则跳过本次
  async _runJob(entry: JobEntry): Promise<void> {
    if (entry.isExecuting) {
      log(`[chronos] job [${entry.config.id}] still running, skipping this tick`);
      return;
    }
    entry.isExecuting = true;
    const jobId = entry.config.id;

    try {
      log(`[chronos] job [${jobId}] triggered`);
      const sessionId = `chronos-${jobId}-${Date.now()}`;
      const msg: ACPMessage = {
        id: crypto.randomUUID(),
        sessionId,
        channel: 'internal',
        content: entry.config.taskPrompt,
        timestamp: Date.now(),
        caller: 'agent',
        isChronos: true,  // 触发 CHRONOS MODE system prompt
      };

      // Chronos Agent 用更低的 maxIterations 上限防止烧费用
      const chronosAgent = new Agent(
        this.providerChain,
        this.maxSteps,
        null,
        null,
        0,
        buildChronosSystemPrompt(this.maxSteps),  // CHRONOS MODE prompt
      );

      const result = await chronosAgent.handle(msg, (token) => {
        process.stdout.write(token);  // 实时输出到 terminal,方便调试
      });
      log(`[chronos] job [${jobId}] completed: ${result.slice(0, 100)}`);
    } catch (err: any) {
      log(`[chronos] job [${jobId}] failed: ${err.message}`);
    } finally {
      entry.isExecuting = false;  // 无论成败都释放锁
    }
  }
  
  // 事件驱动触发(同 _runJob,但 session ID 前缀不同)
  async handleEvent(event: SystemEvent): Promise<void> {
    const sessionId = `event-${event.type.toLowerCase()}-${Date.now()}`;
    const prompt = `[系统事件: ${event.type}]
事件详情:
${JSON.stringify(event.payload, null, 2)}

处理步骤(按顺序执行):
1. 首先调用 notify 工具发送告警通知,级别 WARNING,标题"系统事件告警",消息中包含事件类型和详情。
2. 然后分析此事件的可能原因和严重程度。
3. 如果分析结果表明情况严重,再次调用 notify 工具升级为 CRITICAL 级别并附上分析结论。`;

    const msg: ACPMessage = {
      id: crypto.randomUUID(),
      sessionId,
      channel: 'internal',
      content: prompt,
      timestamp: Date.now(),
      caller: 'agent',
      isChronos: true,
    };

    const chronosAgent = new Agent(
      this.providerChain,
      this.maxSteps,
      null, null, 0,
      buildChronosSystemPrompt(this.maxSteps),
    );
    await chronosAgent.handle(msg, (token) => process.stdout.write(token));
  }

  jobIds(): IterableIterator<string> { return this.jobs.keys(); }

  stopAll(): void {
    for (const entry of this.jobs.values()) {
      entry.cancel?.();
    }
    this.jobs.clear();
    log('[chronos] all jobs stopped');
  }
}

为什么每次触发都 new Agent()Agent 类在内存里维护一个 sessions: Map<string, Message[]> 存放对话历史。Chronos 任务每次触发都生成新的 sessionIdchronos-{id}-{ts}),如果复用同一个 Agent 实例,这个 Map 会随着每次触发无限累积旧 session,长期运行即内存泄漏。更根本的是,Chronos 巡检本来就是无状态的——每次检查都从零开始,不需要知道上次跑了什么。新建实例保证每次都是干净的上下文,实例本身只是几个字段加一个空 Map,开销可以忽略。


2. 事件总线:EventBus

事件总线是轻量的 EventEmitter 包装器,为外部系统(Webhook、监控 agent、CI)提供统一的注入入口:

// src/chronos/eventBus.ts
import { EventEmitter } from 'events';

export interface SystemEvent {
  type: 'CODE_COMMIT' | 'SYSTEM_ALERT' | 'SKILL_ERROR' | string;
  payload: Record<string, any>;
}

class AgentEventBus extends EventEmitter {
  emitEvent(event: SystemEvent): void {
    this.emit(event.type, event.payload);
  }
}

export const eventBus = new AgentEventBus();

index.ts 中把事件总线和 ChronosEngine 挂钩:

// index.ts(新增)
import { eventBus } from './chronos/eventBus.ts';

// 示例:系统告警事件 → Chronos 引擎
eventBus.on('SYSTEM_ALERT', (payload) => {
  log(`[event-bus] SYSTEM_ALERT received`);
  chronos.handleEvent({ type: 'SYSTEM_ALERT', payload });
});

外部系统通过独立的 Webhook HTTP 服务器注入事件(详见第 8 节):

curl -X POST http://localhost:3001/webhook/alert \
  -H "Content-Type: application/json" \
  -d '{"service":"database","error":"connection timeout"}'

3. CHRONOS MODE:系统 Prompt 扩展

当任务由 Cron 或事件触发时,Orchestrator 的 system prompt 末尾追加 CHRONOS MODE 约束块。这个约束块解决了两个关键问题:避免无效通知(一切正常时保持静默)和防止失控执行(步数硬上限)。

// src/agent.ts — 新增

export function buildChronosSystemPrompt(maxSteps: number): string {
  const base = buildSystemPrompt();  // 复用标准 Orchestrator prompt

  return base + `

## [CHRONOS MODE — 系统自动触发]

你现在在**无人值守**的环境下运行。没有人在等待你的回复。

### 执行原则
1. **静默优先**:如果检查结果一切正常,什么都不做,直接结束。不要发送通知,不要输出无意义的确认信息。
2. **异常即告警**:一旦发现真正的异常(资源超阈值、安全漏洞、服务故障),立即调用 \`notify\` 工具。告警后无需继续其他操作,直接结束。
3. **步数硬限制**:最多执行 ${maxSteps} 次工具调用。超出限制时,立即停止并输出一行简短说明("已达步数上限,任务终止")。不要循环重试。

### 禁止行为
- 禁止在没有发现异常的情况下调用 \`notify\`
- 禁止进行超出巡检范围的操作(不要修改文件、不要删除数据)
- 禁止向用户询问确认(无人值守,没有人会回答)`;
}

CHRONOS MODE 与普通模式的 system prompt 对比

普通模式 system prompt(buildSystemPrompt):
  - 你是 xclaw,一个 AI Orchestrator
  - 通过 delegate/debate/pipeline 工具协调 Worker
  - 工具列表...
  [无特殊约束,回复内容由任务决定]

CHRONOS MODE system prompt(buildChronosSystemPrompt):
  = 普通 system prompt
  + ## [CHRONOS MODE] 追加块
      - 静默优先:正常 → 什么都不做
      - 异常即告警:用 notify 工具
      - 步数硬上限:超过 maxSteps 强制终止

4. notify 工具:异常通知

Agent 在 CHRONOS MODE 下发现异常时,通过 notify 工具推送告警。notify 支持三种输出模式,按优先级依次检查:

优先级渠道触发条件
1飞书群机器人FEISHU_WEBHOOK_URL 已配置
2QQ 私信或群消息QQ_APP_ID + QQ_CLIENT_SECRET + QQ_NOTIFY_OPENID 均已配置
3stdout 打印以上均未配置(开发模式降级)

飞书 vs QQ 推送的关键区别:飞书使用群机器人 Webhook(无状态 HTTP POST,无需认证),而 QQ 渠道需要先获取 access_token 再调用消息发送 API——这与 channels/qq.ts 里响应用户消息的 token 逻辑完全一致,可以直接复用。

// src/tools.ts — registerHostModeTools() 中新增

// QQ token 缓存(与 channels/qq.ts 独立维护,避免跨模块共享可变状态)
let qqTokenCache: { token: string; expiresAt: number } | null = null;

async function getQQNotifyToken(appId: string, secret: string): Promise<string> {
  if (qqTokenCache && Date.now() < qqTokenCache.expiresAt - 60_000) {
    return qqTokenCache.token;
  }
  const res = await fetch('https://bots.qq.com/app/getAppAccessToken', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ appId, clientSecret: secret }),
  });
  const data = await res.json() as { access_token: string; expires_in: number };
  qqTokenCache = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 };
  return data.access_token;
}

registerTool(
  {
    name: 'notify',
    description: '发送告警通知。仅在主动巡检发现真实异常时调用。正常情况下禁止调用。',
    parameters: {
      type: 'object',
      properties: {
        title:   { type: 'string', description: '告警标题,简洁描述问题' },
        message: { type: 'string', description: '详细说明:异常数据、影响范围、建议操作' },
        level:   { type: 'string', description: 'INFO | WARNING | CRITICAL' },
      },
      required: ['title', 'message', 'level'],
    },
  },
  async (_sessionId, params) => {
    const { title, message, level } = params as { title: string; message: string; level: string };
    const prefix = level === 'CRITICAL' ? '🚨' : level === 'WARNING' ? '⚠️' : 'ℹ️';
    const text = `${prefix} [xclaw 巡检] [${level}] ${title}\n${message}\n时间:${new Date().toLocaleString()}`;

    // ── 优先级 1:飞书 Webhook ────────────────────────────────────────────────
    const feishuUrl = process.env['FEISHU_WEBHOOK_URL'];
    if (feishuUrl) {
      const colorMap: Record<string, string> = { INFO: 'blue', WARNING: 'orange', CRITICAL: 'red' };
      const payload = {
        msg_type: 'interactive',
        card: {
          header: {
            title:    { tag: 'plain_text', content: `[xclaw 巡检] ${title}` },
            template: colorMap[level] ?? 'blue',
          },
          elements: [
            { tag: 'markdown', content: `**级别:** ${level} **时间:** ${new Date().toLocaleString()}` },
            { tag: 'hr' },
            { tag: 'markdown', content: message },
          ],
        },
      };
      const resp = await fetch(feishuUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      if (!resp.ok) return `error: feishu webhook returned ${resp.status}`;
      return `notified via feishu: [${level}] ${title}`;
    }

    // ── 优先级 2:QQ 主动推送 ─────────────────────────────────────────────────
    // 与 channels/qq.ts 的"被动回复"不同:这里是无触发消息的主动推送(proactive),
    // 目标 openid 和消息类型通过环境变量配置,不依赖 replyCtx。
    const qqAppId  = process.env['QQ_APP_ID'];
    const qqSecret = process.env['QQ_CLIENT_SECRET'];
    const qqOpenid = process.env['QQ_NOTIFY_OPENID'];       // 推送目标(用户或群的 openid)
    const qqType   = process.env['QQ_NOTIFY_TYPE'] ?? 'c2c'; // 'c2c'(私信)或 'group'(群消息)

    if (qqAppId && qqSecret && qqOpenid) {
      try {
        const token = await getQQNotifyToken(qqAppId, qqSecret);
        const API   = 'https://api.sgroup.qq.com';
        const url   = qqType === 'group'
          ? `${API}/v2/groups/${qqOpenid}/messages`
          : `${API}/v2/users/${qqOpenid}/messages`;

        // QQ 主动消息(非回复):msg_id 留空,msg_seq 用时间戳保证唯一性
        const resp = await fetch(url, {
          method: 'POST',
          headers: { Authorization: `QQBot ${token}`, 'Content-Type': 'application/json' },
          body: JSON.stringify({ content: text, msg_type: 0, msg_seq: Date.now() % 65536 }),
        });
        if (!resp.ok) return `error: QQ notify returned ${resp.status}: ${await resp.text()}`;
        return `notified via qq (${qqType}): [${level}] ${title}`;
      } catch (err: any) {
        return `error: QQ notify failed: ${err.message}`;
      }
    }

    // ── 优先级 3:stdout 降级(开发模式)────────────────────────────────────────
    console.log(`\n${text}\n`);
    return `notified via stdout: [${level}] ${title}`;
  },
);

notify 工具加入 ORCHESTRATOR_TOOLS 白名单

// src/tools.ts
const ORCHESTRATOR_TOOLS = new Set([
  'delegate', 'debate', 'pipeline',
  'view_file', 'list_dir',
  'memory_save', 'memory_search', 'kb_search',
  'shell',
  'notify',  // ← 新增:Orchestrator 在 CHRONOS MODE 下直接调用
]);

5. 任务配置文件

任务列表通过 JSON 配置文件管理,无需修改代码即可增删任务:

// config/chronos.json
[
  {
    "id": "server-health-monitor",
    "expression": "*/15 * * * *",
    "taskPrompt": "检查当前主机状态。使用 shell 工具执行:\n1. 内存检查:node skills/sysinfo/scripts/sysinfo.js memory\n2. 磁盘检查:node skills/sysinfo/scripts/sysinfo.js disk\n\n判断标准:\n- 内存使用率 > 85%:WARNING\n- 磁盘剩余 < 10%:WARNING\n- 两者同时超标:CRITICAL\n\n正常则保持静默。超标则调用 notify 工具。",
    "enabled": false
  },
  {
    "id": "codebase-security-audit",
    "expression": "0 2 * * *",
    "taskPrompt": "使用 shell 工具执行:node scripts/scan-secrets.js\n\n判断规则:\n- 输出第一行是 CLEAN:静默结束,不做任何操作。\n- 输出第一行是 FOUND:立即调用 notify 工具,级别 CRITICAL,消息中列出所有发现的文件和行号(从输出的后续行获取)。",
    "enabled": false
  }
]

配置字段说明:

字段类型说明
idstring任务唯一标识,用于日志和 sessionId 生成
expressionstring标准 5 字段 cron 表达式
taskPromptstring直接传给 Orchestrator 的任务描述,应包含判断标准和行动指令
enabledbooleanfalse 时任务被注册但不启动,重启后生效

5.1 跨平台安全扫描脚本

codebase-security-audit 任务使用 node scripts/scan-secrets.js 代替 shell 的 grep 命令。原因:Windows 的 cmd /c 环境没有 grep,直接在 taskPrompt 里写 grep 会导致 Agent 反复重试并耗尽 15 步预算,始终无法触发 notify。

scripts/scan-secrets.js 用 Node.js fs 模块实现跨平台目录扫描,无任何外部依赖:

// scripts/scan-secrets.js
// Usage: node scripts/scan-secrets.js [rootDir]
// Output: 第一行 CLEAN 或 FOUND,后续行为 file:line  [pattern-name]

const PATTERNS = [
  { name: 'OpenAI key',        re: /sk-[A-Za-z0-9]{20,}/g },
  { name: 'Anthropic key',     re: /sk-ant-[A-Za-z0-9\-_]{20,}/g },
  { name: 'Private key block', re: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g },
  { name: 'AWS key',           re: /AKIA[0-9A-Z]{16}/g },
  { name: 'Generic secret',    re: /(?:secret|password|passwd|pwd)\s*[:=]\s*["'][^"'\s]{8,}["']/gi },
  { name: 'API key assign',    re: /(?:api[_-]?key|apikey)\s*[:=]\s*["'][^"'\s]{8,}["']/gi },
  { name: 'Bearer token',      re: /Bearer\s+[A-Za-z0-9\-_]{20,}/g },
];

taskPrompt 只需一条指令,Agent 读到输出第一行就能决策,不再需要多步 grep 重试:

node scripts/scan-secrets.js
↓
CLEAN          → 静默结束
FOUND          → 调用 notify,把后续行列表附上
src/config.ts:12  [OpenAI key]

6. 防死循环设计

主动触发场景下,两类死循环风险必须在工程层面拦截:

6.1 任务堆积(时间死循环)

风险场景:
  cron 每 1 分钟触发一次
  但 Agent 执行需要 3 分钟
  → 1 分钟时触发任务 A(开始)
  → 2 分钟时触发任务 B(任务 A 还没结束)
  → 3 分钟时触发任务 C(任务 A、B 都没结束)
  → 多个 Agent 实例并发运行,LLM 并发调用暴增

防护机制(isExecuting 锁):
  → 1 分钟:任务 A 开始,isExecuting = true
  → 2 分钟:检测到 isExecuting,跳过,打印 "still running, skipping"
  → 3 分钟:同上,跳过
  → 4 分钟:任务 A 完成,isExecuting = false
  → 4 分钟 cron 触发:isExecuting = false,任务 B 正常开始
// engine.ts — _runJob 中的锁保护
async _runJob(entry: JobEntry): Promise<void> {
  if (entry.isExecuting) {
    log(`[chronos] job [${entry.config.id}] still running, skipping this tick`);
    return;  // 直接跳过,不等待
  }
  entry.isExecuting = true;
  try {
    // ... 执行任务
  } finally {
    entry.isExecuting = false;  // finally 确保即使抛错也释放锁
  }
}

6.2 工具调用死循环(费用死循环)

风险场景:
  Agent 在检查磁盘时调用 shell 工具
  shell 返回错误信息
  Agent 尝试"修复",再次调用 shell
  shell 还是报错
  循环继续,每次循环消耗 ~2000 tokens
  1 小时内:约 60 次循环 × 2000 tokens = 120k tokens

防护机制(maxSteps 硬上限):
  ChronosEngine 为每个 chronos 任务创建专用 Agent,maxIterations 设为 maxSteps(默认 15)
  标准 Orchestrator maxIterations 是 50
  → chronos 任务最多 15 次工具调用,之后强制停止
// engine.ts — 创建 Chronos 专用 Agent
const chronosAgent = new Agent(
  this.providerChain,
  this.maxSteps,  // ← 比主 Agent 更严格的上限(默认 15 vs 50)
  null, null, 0,
  buildChronosSystemPrompt(this.maxSteps),
);

两层防护的配合

外层防护(isExecuting):防止任务在时间维度上堆积
内层防护(maxSteps):防止单次任务在工具调用维度上失控

7. ACPMessage 协议扩展

为支持 Chronos 模式的路由判断,ACPMessage 新增 isChronos 标志位:

// gateway/types.ts
export interface ACPMessage {
  id: string;
  sessionId: string;
  channel: string;
  content: string;
  timestamp: number;
  type?: string;
  caller?: 'user' | 'agent';
  parentSessionId?: string;
  isChronos?: boolean;  // ← 新增:标记系统自动触发任务
}

虽然 ChronosEngine 目前通过创建独立的 Agent 实例并传入 systemPromptOverride 来注入 CHRONOS MODE prompt,isChronos 字段保留在协议层是为了未来可以在 Gateway 层统一处理(例如统计系统触发的任务比例、对 chronos session 做特殊的 DB 标记等)。


8. 启动配置

// index.ts — 在现有启动逻辑末尾新增

import * as http from 'http';
import { ChronosEngine } from './chronos/engine.ts';
import { eventBus }      from './chronos/eventBus.ts';

// ── Plugins / Skills 初始化(已有,略)────────────────────────────────────────

// ── Chronos 定时任务引擎 ──────────────────────────────────────────────────────
const providerChainForChronos = buildProviderChain();
const chronos = new ChronosEngine(providerChainForChronos, 15);

chronos.loadFromConfig(path.resolve('config/chronos.json'));
log(`[chronos] active jobs: ${[...chronos.jobIds()].filter(Boolean).join(', ') || 'none'}`);

// ── 事件总线 ──────────────────────────────────────────────────────────────────
eventBus.on('SYSTEM_ALERT', (payload) => {
  log(`[event-bus] SYSTEM_ALERT received`);
  chronos.handleEvent({ type: 'SYSTEM_ALERT', payload });
});

// ── Webhook 服务器(独立端口,在所有监听器注册完毕后再 listen)──────────────────
// 重要:必须在 eventBus.on() 之后才调用 listen()。
// 原因:listen() 之后 Node.js 开始接受连接;如果期间有 await(如 loadPluginsDir),
// 事件循环会处理进来的请求,此时 eventBus 监听器若未注册,emitEvent 发出的事件直接丢失。
const WEBHOOK_PORT = parseInt(process.env['WEBHOOK_PORT'] ?? '3001', 10);
const webhookServer = http.createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/webhook/alert') {
    const secret = process.env['WEBHOOK_SECRET'];
    if (secret && req.headers['authorization'] !== `Bearer ${secret}`) {
      res.writeHead(401).end('unauthorized');
      return;
    }
    let body = '';
    req.on('data', (chunk) => { body += chunk; });
    req.on('end', () => {
      try {
        const payload = JSON.parse(body);
        eventBus.emitEvent({ type: 'SYSTEM_ALERT', payload });
        res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
      } catch {
        res.writeHead(400).end('bad json');
      }
    });
  } else {
    res.writeHead(404).end();
  }
});
await new Promise<void>((resolve) => webhookServer.listen(WEBHOOK_PORT, resolve));
log(`[webhook]  http://localhost:${WEBHOOK_PORT}/webhook/alert`);

// ── Cleanup ───────────────────────────────────────────────────────────────────
process.on('SIGINT', async () => {
  chronos.stopAll();           // ← 清理 cron timer
  webhookServer.close();       // ← 关闭 webhook 服务器
  await stopPluginServices();
  // ...
});

为什么 Webhook 用独立端口,而不是挂在 Web 适配器(端口 3000)上:Web 适配器使用 ws 包的 WebSocketServer,它会向 http.Server 注入一个 request 事件监听器,对所有非 GET 请求返回 405 Method Not Allowed——这个拦截发生在我们自己的 createServer 回调之前,无法通过修改回调绕过。使用独立端口(默认 3001,通过 WEBHOOK_PORT 配置)的独立 http.createServer 完全绕开了这个问题。

WEBHOOK_SECRET 认证:如果设置了 WEBHOOK_SECRET 环境变量,Webhook 接口要求请求头携带 Authorization: Bearer <secret>;未设置则无认证(开发模式)。


9. 改动全景

第 10 节                              第 11 节

gateway/types.ts                      gateway/types.ts
  ACPMessage                 →         ACPMessage
  caller?: 'user' | 'agent'             + isChronos?: boolean

tools.ts                              tools.ts
  ORCHESTRATOR_TOOLS         →         ORCHESTRATOR_TOOLS
  (无 notify)                           + 'notify'
                                       registerHostModeTools()
                                         + notify 工具(飞书 Webhook / QQ 主动推送 / stdout 三级降级)

agent.ts                              agent.ts
  buildSystemPrompt()        →         buildSystemPrompt()(不变)
                                       + buildChronosSystemPrompt(maxSteps)
                                           = buildSystemPrompt() + CHRONOS MODE 追加块

src/chronos/(新建)                   2 个文件
                                       engine.ts
                                         CronJobConfig 接口
                                         JobEntry 接口(含 isExecuting 锁)
                                         ChronosEngine 类
                                           loadFromConfig(path)
                                           registerJob(config)
                                           _runJob(entry)     ← isExecuting 防堆积
                                           handleEvent(event) ← 事件驱动触发
                                           stopAll()
                                         scheduleCron(expression, fn)  ← 零依赖 cron
                                         nextTickMs(expression)
                                         cronMatches(expression, date)
                                         matchField(field, value)
                                       eventBus.ts
                                         SystemEvent 接口
                                         AgentEventBus(EventEmitter 包装)
                                         eventBus(单例导出)

config/chronos.json(新建)            任务配置文件(enabled: false 为安全默认值)
                                       server-health-monitor  ← */15 * * * *
                                       codebase-security-audit ← 0 2 * * *(使用 scan-secrets.js)

scripts/scan-secrets.js(新建)        跨平台 Node.js 密钥扫描脚本
                                       替代 grep(Windows cmd 无此命令)
                                       输出:CLEAN 或 FOUND + file:line 列表

index.ts                              index.ts
  process.on('SIGINT')       →         + chronos.stopAll()
                                       + ChronosEngine 初始化
                                       + eventBus 事件监听

增加能力:
  定时触发   → scheduleCron 解析 cron 表达式,递归 setTimeout 精确调度
  事件触发   → eventBus 发布订阅,外部系统通过 emitEvent() 注入
  CHRONOS MODE → buildChronosSystemPrompt 追加静默优先约束
  notify 工具 → 飞书 Webhook 卡片 / QQ 主动推送 / stdout 三级降级,供 CHRONOS MODE 下异常告警
  防堆积锁   → isExecuting per-job 锁,跳过上次未完成的 cron tick
  步数上限   → ChronosEngine 创建 maxSteps=15 的专用 Agent,防费用爆炸

知识点总结

知识点说明
被动 vs 主动架构被动:等用户消息;主动:时间/事件主动驱动 Agent,适合无人值守的例行检查和突发响应
零依赖 croncronMatches 逐字段匹配,nextTickMs 搜索下一个触发时刻,递归 setTimeout 替代 setInterval——精确且无漂移
递归 setTimeout vs setIntervalsetInterval 有累积漂移;递归 setTimeout 每次重新计算下次触发时刻,适合需要对齐到分钟边界的 cron 场景
isExecuting 锁每个 job 持有一个布尔锁;cron tick 触发时先检查锁,上次未完成则跳过——防止慢任务堆积为并发 LLM 调用
maxSteps 上限Chronos 专用 Agent 使用更低的 maxIterations(默认 15 vs 主 Agent 50);超限强制终止,防止工具调用死循环烧费用
CHRONOS MODEbuildChronosSystemPrompt 在标准 prompt 末尾追加约束块:静默优先 + 异常即告警 + 步数硬上限
静默优先原则无异常时不发送通知——避免通知疲劳,让真正的告警有信号价值
notify 工具三级降级:飞书 Webhook 卡片(优先)→ QQ 主动推送(QQ_NOTIFY_OPENID 配置时)→ stdout 打印(开发模式兜底)
QQ proactive vs reactiveQQ 频道回复消息依赖 replyCtx(有入站 msg_id);CHRONOS MODE 的主动推送无触发消息,直接用 QQ_NOTIFY_OPENID 指定目标,msg_id 留空——两种路径独立,互不干扰
QQ token 复用notify 工具内维护独立的 qqTokenCache,与 channels/qq.tstokenCache 隔离,避免跨模块共享可变状态
事件总线EventEmitter 包装为 AgentEventBus,外部系统通过 emitEvent() 注入事件;与 ChronosEngine 松耦合
isChronos 字段ACPMessage 上的标志位,标记系统触发来源;未来可用于 Gateway 层统计、DB 标记或差异化限流
独立 Agent 实例ChronosEngine 为每次触发 new Agent(),而非复用主 Orchestrator 实例。原因双重:①复用实例会导致 sessions Map 无限累积旧 sessionId,长期运行内存泄漏;②巡检任务本身无状态,每次都应从干净上下文出发。Agent 实例极轻(几个字段 + 空 Map),new 的开销可以忽略
finally 释放锁try { ... } finally { entry.isExecuting = false } 确保任务失败时锁也被释放,避免任务永久卡死
配置驱动config/chronos.json 管理任务列表;enabled: false 默认禁用,修改配置后重启生效,无需改代码
两层防护外层(isExecuting)防时间维度堆积;内层(maxSteps)防工具调用维度失控——两者互补,覆盖不同失控路径
跨平台脚本优于 shell 命令taskPrompt 里直接写 grep 在 Windows cmd 环境下不存在,Agent 会反复重试耗尽步数预算;用 node scripts/scan-secrets.js 把平台差异封装进脚本,Agent 只需读第一行输出即可决策

试一试

cd sections/11-chronos/nodejs
cp .env.example .env
npm install
npm start

Terminal 2(CLI 客户端)

node --env-file=.env src/cli.ts

环境变量说明

.env 中与本节相关的配置项:

# ── 通知渠道(三选一,按优先级依次检查)────────────────────────────────────────

# 优先级 1:飞书群机器人 Webhook(推荐,开箱即用)
# 飞书管理后台 → 群机器人 → 添加机器人 → 复制 Webhook 地址
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx

# 优先级 2:QQ 主动推送(需要已有 QQ Bot 凭证)
# QQ_APP_ID / QQ_CLIENT_SECRET 与 QQ 频道接入共用同一套凭证(见第 04 节)
# QQ_NOTIFY_OPENID:推送目标的 openid(用户私信)或 group_openid(群消息)
# QQ_NOTIFY_TYPE:'c2c'(私信,默认)或 'group'(群消息)
QQ_APP_ID=your_qq_app_id
QQ_CLIENT_SECRET=your_qq_client_secret
QQ_NOTIFY_OPENID=xxxxxxxxxxxxxxxxxxxxxx
QQ_NOTIFY_TYPE=c2c

# 优先级 3:stdout 打印(无需任何配置,开发调试默认降级)

如何获取 QQ_NOTIFY_OPENID:让目标用户先给 Bot 发一条私信(或在群里 @ Bot),xclaw 收到消息时日志里会打印 [qq] c2c from <openid>[qq] group <group_openid>,把对应值填入环境变量即可。


验证 notify 工具(stdout 降级模式)

不配置任何通知渠道,直接测试基础功能:

You: 调用 notify 工具,标题"测试告警",消息"这是一条测试通知",级别 WARNING

xclaw uses [notify]: {"title":"测试告警","message":"这是一条测试通知","level":"WARNING"}
→ notified via stdout: [WARNING] 测试告警

⚠️ [xclaw 巡检] [WARNING] 测试告警
这是一条测试通知
时间:2026/5/22 14:30:00

xclaw: 已发送 WARNING 级别通知(stdout 模式)。

验证飞书通知(可选)

.env 中配置 FEISHU_WEBHOOK_URL 后重启,触发 notify:

xclaw uses [notify]: {"title":"磁盘空间告警","message":"/ 磁盘使用率 92%,剩余 8GB","level":"WARNING"}
→ notified via feishu: [WARNING] 磁盘空间告警

飞书群收到橙色卡片消息,标题 [xclaw 巡检] 磁盘空间告警,正文包含级别和时间。

三种级别对应的卡片颜色:

  • INFO → 蓝色
  • WARNING → 橙色
  • CRITICAL → 红色

验证 QQ 通知(可选)

前置步骤:先让目标用户给 Bot 发一条私信,从日志获取 openid:

[qq] c2c from o1ab2c3d4e5f6g7h8i9j0k  ← 复制这个值

.env 中配置后重启:

QQ_NOTIFY_OPENID=o1ab2c3d4e5f6g7h8i9j0k
QQ_NOTIFY_TYPE=c2c

触发 notify:

xclaw uses [notify]: {"title":"安全扫描告警","message":"发现硬编码 API Key:workspace/config.ts 第 12 行","level":"CRITICAL"}
→ notified via qq (c2c): [CRITICAL] 安全扫描告警

目标用户的 QQ 收到私信:

🚨 [xclaw 巡检] [CRITICAL] 安全扫描告警
发现硬编码 API Key:workspace/config.ts 第 12 行
时间:2026/5/22 14:30:00

QQ 主动消息限制:QQ 平台对 Bot 主动发消息有频率和权限限制(每日配额)。频繁巡检场景建议用飞书 Webhook,QQ 仅用于高优先级的 CRITICAL 告警。

验证 Cron 触发(每分钟模式)

修改 config/chronos.json,临时把 server-health-monitor 改为每分钟触发并启用:

{
  "id": "server-health-monitor",
  "expression": "*/1 * * * *",
  "taskPrompt": "说一句话:'巡检完成,一切正常',然后静默结束。",
  "enabled": true
}

重启服务,等待约 1 分钟:

[chronos] job [server-health-monitor] scheduled: */1 * * * *
[chronos] job [server-health-monitor] triggered
巡检完成,一切正常
[chronos] job [server-health-monitor] completed: 巡检完成,一切正常

下一分钟再次自动触发,无需任何用户操作。

验证防堆积锁

taskPrompt 改为需要多步骤的任务(让 Agent 执行超过 1 分钟),用 */1 频率观察锁行为:

[chronos] job [server-health-monitor] triggered        ← 第 1 分钟,开始执行
[chronos] job [server-health-monitor] still running, skipping this tick  ← 第 2 分钟,跳过
[chronos] job [server-health-monitor] still running, skipping this tick  ← 第 3 分钟,跳过
[chronos] job [server-health-monitor] completed: ...   ← 执行完毕,锁释放
[chronos] job [server-health-monitor] triggered        ← 第 4 分钟,正常开始

验证事件驱动触发(Webhook)

服务启动后,用 curl 向 Webhook 接口发送告警事件:

curl -X POST http://localhost:3001/webhook/alert \
  -H "Content-Type: application/json" \
  -d '{"service":"database","error":"connection timeout"}'

如果配置了 WEBHOOK_SECRET,加上认证头:

curl -X POST http://localhost:3001/webhook/alert \
  -H "Authorization: Bearer your-secret" \
  -H "Content-Type: application/json" \
  -d '{"service":"database","error":"connection timeout"}'

立刻观察日志(Webhook 返回 {"ok":true} 后即开始执行):

{"ok":true}

[event-bus] SYSTEM_ALERT received
[chronos] event-driven task triggered for SYSTEM_ALERT

xclaw uses [notify]: {"title":"系统事件告警","message":"收到 SYSTEM_ALERT 事件:service=database, error=connection timeout","level":"WARNING"}
→ notified via qq (c2c): [WARNING] 系统事件告警

xclaw uses [shell]: ...(分析阶段)

xclaw uses [notify]: {"title":"数据库连接超时确认","message":"...分析结论...","level":"CRITICAL"}
→ notified via qq (c2c): [CRITICAL] 数据库连接超时确认

Agent 先发第一条 WARNING 通知(立即),再分析,分析后视严重程度发第二条 CRITICAL 升级通知。