# 定时任务引擎选型与 yzj chatnode 投递(R2.7 调研结论)
> 调研对象(2026-08-16 实读源码):
> [Jesse-njx/dsh-routines](https://github.com/Jesse-njx/dsh-routines)、
> [Jesse-njx/dsh-chatnode-wechat](https://github.com/Jesse-njx/dsh-chatnode-wechat)、
> [zxz9988/dsh-wechat-bridge](https://github.com/zxz9988/dsh-wechat-bridge)。
> 插件生态入口("dsh find"):[dsh-plugin-marketplace](https://github.com/YELEBAI/dsh-plugin-marketplace)
>(装进 DSH 的 UI 市场:设置 → 插件 → 插件市场;Registry 验证、精确 commit 安装)、
> [dsh-market](https://github.com/dsh-market/dsh-market)、
> [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)、
> [HubaKing/dsh-community-plugins](https://github.com/HubaKing/dsh-community-plugins)(市场注册成 skill)。
## 1. 为什么定时任务应该单独成插件
- harness 内置 `@deepseek-ai/dsh-schedule` 只为 **root agent**(config 创建的会话)挂载
schedule 工具;程序化创建的非 root agent 拿不到(`unknown tool`,见
`../pitfalls/pitfall-007`),把 harness 的 schedule 子系统硬挂到机器人会话上是
逆着 harness 设计在走。
- 社区结论一致:**调度是独立关注点**。dsh-routines 把"排期 + 执行 + 投递"从任何
会话里抽出来:routine 是 YAML(可提交可审计),每次到点起**独立 one-shot 会话**
跑(完整会话日志 = 完整审计),digest 投递走抽象通道。dsh-wechat-bridge 则是
**通道内嵌 cron**(`override.json` 的 `jobs[]`,热加载,结果走通道自己的流式链路)。
## 2. 两个参考实现的对比(实读源码结论)
| 维度 | dsh-routines | dsh-wechat-bridge |
|---|---|---|
| 形态 | 独立 bundle:store / scheduler / cli / run 四个插件 | 通道插件内的 cron jobs |
| 排期 | 5 段 cron + `@daily` + `every 4h`;`timezone` 显式 | 5 段 cron;`override.json` 热加载 |
| 执行 | **子进程** `dsh --profile
--patch -- `,approval 强制 `never`,`headless-runner` 行被替换成 run 驱动 | 通道自己的 agent 链路 |
| 审计 | 每次 run 独立会话日志 + `runs/.json` + digest.md | 普通会话流式链路 |
| 投递 | `file`(常开)+ `chatnode`(可选) | 只投到微信联系人 |
| 重叠/超时 | `overlap: skip/queue/cancel-previous`、`timeoutMin` 硬停(jobs.kill) | 未单独设计 |
| CLI | `dsh routines list/run/pause/resume/logs` | `/help /new /stop /status` |
### 2.1 关键契约:`ctx.chatnode`
dsh-routines 的投递契约极简(`src/types.ts`):
```ts
interface ChatnodeService {
send(input: { text: string; title?: string }): Promise
}
// declare module '@deepseek-ai/cordis' { interface Context { chatnode?: ChatnodeService } }
```
调度器 `finalizeRun` 里 `ctx.get('chatnode')`,有则 `send({title, text})`,无则记录
`not-installed`,**投递失败绝不 crash 调度器**。
### 2.2 避坑清单(参考设计里实测/实读得出的)
1. **dsh-chatnode-wechat 没有实现 `ctx.chatnode`**:README 声称 chatnode 投递,但代码
只提供 `ctx.wechat` 网关 + 会话节点,`send` 契约无人实现——**我们做 yzj chatnode
就是生态里第一个真实实现**,必须自己补齐 module augmentation 与失败语义
(投递异常被调度器吞掉并记入 `deliveries`,所以 send 内部要尽量自己兜住错误)。
2. **`chatnode` 同一 profile 只能有一个 provider**:Cordis 同名服务二次注册会 throw;
微信节点与 yzj 节点不能共存于同一 profile(与 wechat "一账号一 poller" 同理)。
3. **dsh-routines 子进程启动在 Windows/tsx 环境的坑**:`realSpawn` 用
`process.execPath --profile …` 直启,不带 `--import tsx/esm`——本仓库
`apps/cli/src/bin.ts` 是 TS 入口,跑 routine 子进程必须配置 `dshBin`(指向可直启
的入口)或 `DSH_BIN`;默认 run profile 是 `headless`,测试前需准备 headless
profile(或每个 routine 显式 `profile:`)。
4. **调度器 tick 依赖 `loader.await()` 后才首跑**(避免与 jobs 控制器竞态)——
测试时别在启动瞬间断言。
5. **dsh-wechat-bridge 的通道内嵌 cron 是另一条可行路**:不需要子进程/独立 profile,
结果直接走通道现有链路(我们的 PushHub 正是这个链路)。若不想引入 dsh-routines,
可仿它做 `robot-yzj` 内嵌 `jobs[]`(cron + 热加载 override.json)。本方案默认选
dsh-routines 路线(审计与投递抽象更完整),内嵌路线作为备选记录。
6. **生态部署形态调研(2026-08-16)**:网上定时类插件分三种——
a) **独立 profile daemon**(dsh-routines 官方 README 推荐:`dsh --profile ops`
daemon 保活 + `headless` 子进程跑 run)——本方案同款,是主流形态;
b) **通道内嵌自研 cron**(dsh-wechat-bridge:`override.json` jobs[] 热加载,
不走 `ctx.jobs`,代价是无取消/超时/管理)——备选记录(§2.2 第 5 条);
c) 纯通道无调度(dsh-chatnode-wechat 源码只有 `setTimeout` 超时)。
dsh-routines README 另写「也可装进 web profile」——该句**依赖 `tool-jobs`
(job controller)存在**;新版 harness 的 web profile 禁了 tool-jobs(模型面
工具移出 web-app 层),`ctx.jobs.start` 判「无 controller 服务该 owner」抛错
(`packages/jobs/jobs-local` `servesOwner`),即本仓库 §5.1 第 1 条实测的崩
溃——官方 README 此句在本环境不成立,部署以 §5.1/§6 为准。
## 3. 选定方案:dsh-routines + yzj chatnode(本仓库实现)
- 定时引擎:安装 dsh-routines(独立 bundle,进 `ops` profile);
- 投递:`robot-yzj` 提供 `ctx.chatnode` 服务——`send({text, title})` →
`yzjRobot.notify(text, chatnodeRobotIndex)`(群机器人推群、个人机器人推 DM),
`title` 作为首行前缀;实现见 `packages/robot-yzj/src/chatnode.ts`;
- 验收口径:routine 到点 → run 完成 → digest 经 chatnode 出现在群里
(`[status] routine\n\ndigest` 形态),调度器 `deliveries` 记录 ok;
- 端到端已验证(§5 实测记录,隔离 DSH_HOME 全链路,in-process chatnode 形态)。
### 3.1 生产形态(决策 2026-08-16 后定稿):跨进程通信桥,ops 不直连机器人
用户要求:ops 调度器**不直连**云之家(不开第二条 WS、不持任何机器人凭据),
一切机器人通信走我们的插件。最终架构是 **HTTP 桥,两端都是 robot-yzj 这一个插件**:
```
ops daemon (base + dsh-routines + robot-yzj client 模式)
│ ctx.chatnode.send({text, title}) ← dsh-routines 调度器投递
▼
ChatnodeBridgeClient POST http://127.0.0.1:3080/yzj/chatnode (Bearer )
▼
web profile (web-app + robot-yzj 机器人模式)
ChatnodeBridge 注册在 webServer 上的 exact 路由 /yzj/chatnode
→ 校验 token → 走自己的通道 notify(text, chatnodeRobotIndex)
→ 群机器人推送
```
- **web 侧**:config 加 `bridgeToken`(可选,缺省不注册路由)→ 在 webServer
(`@deepseek-ai/dsh-host-webserver`,loopback-only 绑定)注册 exact 路由
`POST /yzj/chatnode`;body `{text, title?, robotIndex?}`,缺省通道
`chatnodeRobotIndex`;错误一律 JSON 响应(400/401/405/502),绝不炸进程。
- **ops 侧**:config 加 `bridgeTarget` → 插件进入 **bridge client 模式**:不开
WS、不建会话、不注册机器人工具,只提供 `ctx.chatnode`(HTTP 客户端,15s
超时)。`bridgeTarget` 必配 `bridgeToken`(共享口令)。
- 桥两侧同属 `packages/robot-yzj/src/bridge.ts`;listener 半
`ChatnodeBridge`、client 半 `ChatnodeBridgeClient`,单测见
`tests/bridge.spec.ts`(真实 loopback HTTP 全路径)。
- 生产通道选择:web patch 里 `chatnodeRobotIndex: 1`(第二个 robot = 群机器人)。
- 客户端半不再需要 `yzjBridge` 硬注入——`inject` 从 `['yzjBridge','agents','tools']`
改为 `['agents','tools']`,allowFrom 解析保持 `ctx.get('yzjBridge')` 可选(只有
机器人模式会调用)。
## 4. 决策表
| 决策 | 选择 | 理由 |
|---|---|---|
| 定时引擎 | 外部 dsh-routines(独立插件) | 社区已验证架构;审计/投递抽象完整;避免继续对抗 harness 对非 root agent 的限制 |
| 云之家投递 | 自研 `ctx.chatnode` 实现(本仓库) | 生态无人实现该契约;契约极小;复用 robot-yzj 现成出站 |
| chatnode 归属 | robot-yzj 包内(同一插件行提供) | 通道自包含;无需新增 bundle 行;与机器人生命周期一致 |
| 目标通道 | Config `chatnodeRobotIndex`(默认 0) | 群/个人机器人可配;与 notify 的 robotIndex 语义一致 |
| 与未来微信节点共存 | 文档禁止同 profile 双 chatnode | Cordis 同名服务冲突;与 wechat 单 poller 同理 |
| ops→机器人通信(生产) | **HTTP 桥,两端都是 robot-yzj**(web 侧 webServer 路由 `/yzj/chatnode`,ops 侧 client 模式) | 用户要求 ops 不直连机器人、一切走我们的插件;复用 GUI 已有 3080 端口与 loopback-only 绑定,零新增端口;单插件双模式,无第二 bundle 行 |
| 桥鉴权 | `bridgeToken` 共享口令(Bearer),路由注册 opt-in | loopback 表面 + 口令纵深防御;无 token 即无路由 |
| 桥失败语义 | client 侧 throw(调度器记入 `deliveries` 不 crash);listener 侧 JSON 错误响应 | 与 chatnode 契约失败语义一致;异常请求不能击穿 web 进程 |
| 被否决的备选 | 文件监听投递(watch runs 目录) | 用户判定太 low;HTTP RPC 是标准控制面形态 |
| 被否决的备选 | ops 侧 webhook chatnode 直连机器人 API | 用户要求不直连;改为打我们自己的桥端点 |
## 6. 生产布局(`~/.dsh`,2026-08-16 落地)
> **一键部署**:`node scripts/setup-ops.mjs`(仓库根)自动完成本节的 ops
> profile 创建(`dsh plugin add` dsh-routines、patch 生成、peers link、
> dsh-run.mjs wrapper、routine 模板、桥 token 生成)——路径经参数化
> (`--home`/`--harness`/`--bridge-target`/`--cwd`),可在任意机器跑;
> 唯一的手动步骤是给 web profile 的 robot-yzj 行加
> `bridgeToken`(同一口令)+ `chatnodeRobotIndex`。以下为落地细节。
- **web profile**(`profiles/web/cordis.patch.yml`):robot-yzj 行加
`bridgeToken: <共享口令>` + `chatnodeRobotIndex: 1`(群机器人);
机器人行保持现状(两条 sendMsgUrl)。
- **ops profile**(`profiles/ops/`,专用 base-only daemon):bundles =
`@deepseek-ai/dsh-base` + `@dsh-routines/bundle`;patch 行:
- `routines-store`:`projectDir: C:/Users/rocks`(state 与 project 目录固定,
不随启动 cwd 漂移);
- `routines-scheduler`:`dshBin: C:/Users/rocks/.dsh/dsh-run.mjs`(re-spawn
包装)、`runModule: file:///C:/Users/rocks/.dsh/profiles/ops/node_modules/@dsh-routines/bundle/lib/run.js`、
`tickIntervalMs: 15000`;
- `robot-yzj`:**必须 `- insert:`**(ops 组合没有 @dsh-yzj/bundle 层,裸
`- id:` 会静默丢弃)——`bridgeTarget: http://127.0.0.1:3080/yzj/chatnode` +
`bridgeToken: <同一口令>`(client 模式,无 robots);包本体以 junction 挂在
profile node_modules(`pnpm add link:` 会因往 harness 里装传递依赖被拒
EPERM,见 pitfall);
- `routines-cli` 保留(`dsh --profile ops routines list/run/…` 从这里跑)。
- **peers 必须 link: 到 harness checkout**:profile 模板的 `pnpm-workspace.yaml`
写死 `autoInstallPeers: false`,bundle 的 `@deepseek-ai/*` peer 不会装;手动开
`autoInstallPeers: true` 会从 registry 装当时的 rc.6,与 harness 本体的 rc.5 双份并存
(Service 单例分裂风险;现行对外口径是 `^0.1.0-rc.7`,peers 仍须 `link:` 到兄弟 checkout,不要再装一份 registry)。生产做法:在 profile 里 `pnpm add link:`(cordis/timer/schemastery 在 `vendor/`,agent/session 在
`packages/core/`,jobs/llm 在 `packages/{jobs,llm}/{jobs,llm}`,cmdline 在
`packages/boot/cmdline`)——与 `@dsh-yzj/*` 的 link: 依赖同一模式,解析全部
落到 harness checkout 单份(验证:`createRequire(profileDir).resolve(...)`)。
- **routine 文件**:`C:/Users/rocks/.dsh/routines/*.yaml`(global 目录,store
默认扫描);run 记录落在 `/.dsh/routines/runs/`。
- **dsh-run.mjs**(`C:/Users/rocks/.dsh/`):`realSpawn` 对 `.mjs` 用 node 直启,
包装内再 `node --import tsx/esm apps/cli/src/bin.ts `,cwd 固定 harness。
- **启动:`dsh web` 是唯一入口**——web profile 的 robot-yzj 配
`autoStartOps: true`,启动时经 `~/.dsh/ops-wrapper.mjs`(detached + pid
文件 `ops.pid` 幂等)自动拉起 ops daemon;无任何手动启动脚本。
**为什么 ops 不能和 GUI 同进程**:web profile 禁用 jobs 控制器
(`tool-jobs` 随模型面控件移除),调度器 tick 的 `ctx.jobs.start` 抛
"background jobs unavailable" 且未捕获 → 整个 web 进程退出(§5.1 第 1 条
实测),故调度器必须 base-only 独立进程。
调度器 tick 每 15s;run 子进程用 routine 的 `profile:`(默认 headless)。
- 端到端验证依赖 web profile 重启(web patch 生效才有桥路由);重启后
ops 下一次 tick 即完成 ops→桥→群全链路。
### 6.1 日常运维(操作者速查)
| 场景 | 动作 |
|---|---|
| **启动一切** | **`dsh web`**(robot-yzj 自动拉起 ops daemon;pid 幂等) |
| ops 意外没了(web 未重启) | 重启 web,或手动 `node /ops-wrapper.mjs`(会重新写 pid) |
| 全链路自检 | 群里每 5 分钟收到 `[completed] c11-prod` 巡检 digest;或看 `~/.dsh/routines/runs/` 有新记录 |
| 暂停/查看定时任务 | `dsh --profile ops routines list / pause c11-prod`(ops profile 下) |
## 5. 实测记录(2026-08-16,隔离 DSH_HOME `~/.dsh-test` 全链路)
**验收通过**:routine `c11-yzj`(`every 1m`,prompt=一句话回复)在 ops daemon 里被
调度器 tick 触发 → headless 子进程跑独立会话 → digest 产出 →
`ctx.chatnode.send` → robot 推送到「金蝶最小DSH交流群」。群里实测收到:
```
dsh-routines: c11-yzj
[completed] c11-yzj
定时任务 chatnode 投递测试通过。
```
run 记录(`runs/.json`):`trigger: schedule`、`status: completed`、
`deliveries: [{type: file, ok: true}, {type: chatnode, ok: true}]`;失败轮次如实
记录 `[failed] … (no digest)` 并推送到群(失败语义符合设计)。
### 5.1 实测踩坑(全部已绕过,写在这里避免重踩)
1. **web profile 会因 jobs 控制器缺失崩溃**:web-app 层禁用了 `tool-jobs`
(模型面控件移到 preset 后),routines 调度器的 `ctx.jobs.start` 抛
"background jobs unavailable" → tick 未捕获 → **整个 web 进程退出**。
→ 调度器必须跑在 base-only 的专用 profile(`ops` daemon);web profile 里
三行 routines 全部 `disabled: true`。
2. **routines-cli 与 web 应用抢命令行**:cli 插件注入 `cmdlineArgs` 并自行 parse,
与 web 的 `--port` 冲突(`unknown option '--port'`)→ web profile 禁用
`routines-cli`;CLI 从专用 profile 跑(`dsh --profile ops routines list`)。
3. **Windows + 源码布局的子进程启动**:默认 `dshBin`(`process.argv[1]` 为
`apps/cli/src/bin.ts`)spawn 报 `EFTYPE`;`runModule` 裸 Windows 路径报
`ERR_UNSUPPORTED_ESM_URL_SCHEME`;tsx 解析器对 cwd 敏感(cwd 不在 harness
时 cordis 解析到错误副本)。解法:`dshBin` 指一个 re-spawn 包装
(`node --import `,cwd=harness),`runModule`
配 `file:///…/lib/run.js` 文件 URL。
4. **patch 新增行必须 `- insert:`**:`- id: robot-yzj name: …` 对不存在的行报
`entry not found`(静默丢弃整行,后果是 chatnode 没装上且无报错迹象)。
5. **`every Nm` 首次触发需要播种**:全新 state 下 `next = now + interval > now`
恒跳过,调度永不触发(§5.1 旧注「首跑在启动后约 1 分钟」不成立,见
`../pitfalls/pitfall-009`);先 `dsh routines run ` 播种
`lastRunAt`,且**播种后必须重启 daemon**(state 只读一次进内存,他进程写的
state.json 不热更)。
6. **桥未注册时 2xx 假阳性**:webServer 的 SPA fallback 对未匹配路由回
200+index.html——client 必须校验 body `{ok:true}`,仅查状态码会把「没送达」
记成 ok(见 `../pitfalls/pitfall-009` 第 3 条)。