按 cron 或间隔自动执行任务并留存运行记录
29.3/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/19 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
相关生态插件(非 dsh 原生,按其对应运行时安装)
git clone https://github.com/LosEcher/dsh-scheduler.git状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:待复验
是什么
生态应用
桌面端 / Web 外壳,不以 dsh plugin add 安装
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 22 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件发起外部网络请求
查看扫描证据(共 50 处,展示前 33 条)
- 读取本地文件index.mjs:41
return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version ?? null - 读取本地文件index.mjs:410
try { names = readdirSync(stateDir) } catch { return [] } - 读取本地文件index.mjs:415
const st = statSync(full) - 读取本地文件index.mjs:417
const rec = JSON.parse(readFileSync(full, 'utf8')) - 读取本地文件index.mjs:462
const raw = readFileSync(join(stateDir, `${deliveryKeyHash(key)}.sent`), 'utf8') - 读取本地文件index.mjs:487
const raw = readFileSync(join(stateDir, `${deliveryKeyHash(key)}.skipped`), 'utf8') - 写入本地文件index.mjs:1078
mkdirSync(tmpDir, { recursive: true }) - 写入本地文件index.mjs:1079
writeFileSync(patchPath, text, { mode: 0o600 }) - 删除本地文件index.mjs:1081
try { unlinkSync(patchPath) } catch { } - 删除本地文件index.mjs:1090
try { unlinkSync(ov.patchPath) } catch { } - 删除本地文件index.mjs:1099
try { unlinkSync(join(tmpDir, f)) } catch { } - 写入本地文件index.mjs:1207
try { mkdirSync(workspace, { recursive: true }) } catch { } - 执行外部命令来自配置/变量index.mjs:1284
const child = spawn(process.execPath, [ - 执行外部命令固定命令index.mjs:1355
child = spawn('/bin/bash', ['-lc', cmd], { - 执行外部命令固定命令index.mjs:1803
const child = spawn('bash', [script, '--key', alertKey, '--max-chars', '500', text], { - 发起网络请求lib/client.js:223
const res = await fetch(path, { signal }); - 发起网络请求lib/client.js:229
const res = await fetch(path, { - 发起网络请求lib/client.js:676
void fetch(STATUS_API).then((res) => res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))).then((data) => { - 写入本地文件lib/store.mjs:43
mkdirSync(dataDir, { recursive: true }) - 读取本地文件lib/store.mjs:50
const raw = readFileSync(this.jobsPath, 'utf8') - 写入本地文件lib/store.mjs:60
writeFileSync(tmp, JSON.stringify(jobs, null, 2) + '\n', 'utf8') - 写入本地文件lib/store.mjs:61
renameSync(tmp, this.jobsPath) - 写入本地文件lib/store.mjs:102
appendFileSync(this.runsPath, line, 'utf8') - 写入本地文件lib/store.mjs:104
mkdirSync(this.dir, { recursive: true }) - 写入本地文件lib/store.mjs:105
appendFileSync(this.runsPath, line, 'utf8') - 读取本地文件lib/store.mjs:112
try { size = statSync(this.runsPath).size } catch { return } - 读取本地文件lib/store.mjs:120
lines = readFileSync(this.runsPath, 'utf8').split('\n').filter(Boolean) - 读取本地文件lib/store.mjs:209
for (const name of readdirSync(this.archiveDir)) { - 读取本地文件lib/store.mjs:216
try { text = readFileSync(path, 'utf8') } catch { continue } - 读取本地文件lib/store.mjs:245
const text = readFileSync(this.runsPath, 'utf8') - 删除本地文件递归lib/store.mjs:314
rmSync(this.lockPath, { recursive: true, force: true }) - 删除本地文件递归lib/store.mjs:323
try { rmSync(this.lockPath, { recursive: true, force: true }) } catch { } - 读取本地文件lib/progress-hash.mjs:135
stat = statSync(file)
扫描于 2026/11/3(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/schemastery给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
DSH 调度插件:支持 cron、间隔、单次任务,无头执行,运行记录,deliverTo,熔断器,会话管理界面
README
由 DeepSeek 最新模型翻译生成dsh-scheduler — DSH 定时任务插件
DSH 的定时任务(cron / interval / once)插件:宿主半包负责持久化任务定义、调度、执行与运行台账,客户端半包在会话内提供「定时任务」管理页(列表 / 新建 / 触发 / 暂停 / 运行历史)。
〇、0.4.0 变更要点(2026-09-12,commit cc42607)
本节补写于 2026-09-22(此前 README 只写到 0.3.0,版本表与实际 0.4.0 实装不一致)。
0.4.1 机械验收断言 job.assertCmd
- 语义:run 干净退出后再跑一次该任务的验收命令(/bin/bash -lc <assertCmd>,cwd = 任务的 workspace,env 继承宿主),失败即把本次 run 记为 failed。
- 目的:补上「exit 0 ≠ 任务达成」这一漏洞(2026-09-12 审计实证:feed 空转 15 次仍全绿)。
- 约束:assertCmd 最长 4000 字符;assertTimeoutMs 默认 60s(min 1s / max 10min);断言失败视为不可重试(确定性条件,重试会重复副作用,例如已推送的通知),但仍计入失败连击。
展开完整文档(README 剩余 1 节)
dsh-scheduler — DSH 定时任务插件
DSH 的定时任务(cron / interval / once)插件:宿主半包负责持久化任务定义、调度、执行与运行台账,客户端半包在会话内提供「定时任务」管理页(列表 / 新建 / 触发 / 暂停 / 运行历史)。
〇、0.4.0 变更要点(2026-09-12,commit cc42607)
本节补写于 2026-09-22(此前 README 只写到 0.3.0,版本表与实际 0.4.0 实装不一致)。
0.4.1 机械验收断言 job.assertCmd
- 语义:run 干净退出后再跑一次该任务的验收命令(/bin/bash -lc <assertCmd>,cwd = 任务的 workspace,env 继承宿主),失败即把本次 run 记为 failed。
- 目的:补上「exit 0 ≠ 任务达成」这一漏洞(2026-09-12 审计实证:feed 空转 15 次仍全绿)。
- 约束:assertCmd 最长 4000 字符;assertTimeoutMs 默认 60s(min 1s / max 10min);断言失败视为不可重试(确定性条件,重试会重复副作用,例如已推送的通知),但仍计入失败连击。
- 运维现状(2026-09-22 复核本机 9 个 job):7 个已声明 assertCmd,2 个未声明(hermes 日报归档 job-9e2e7232-885、磁盘清理 job-3bff9079b397)——这两个仍只有「exit 0」语义,属于已知缺口。
0.4.2 台账归档轮转(取代破坏性裁剪)
- runs.jsonl 超过 rotateBytes(默认 2 MiB)时触发轮转:保留最新 maxRuns 行(默认 5000),其余追加到 archive/runs-<YYYY-MM>.jsonl。
- 关键:nothing is dropped —— 此前实现是按行裁剪丢弃旧台账(2026-09-12 审计估算裁后只剩 ~41 天),现在旧段可审计/可回填,archive/ 即为分段来源。
- 本机现状:runs.jsonl 4999 行 / 1.90 MB,正好在阈值边缘(下一次运行触发首轮轮转)。
0.4.3 派发延迟可见性
- attempt 1 的 run 事件记录 dispatchLatencyMs = 实际派发时刻 − nextRunAt 计划时刻;> 5min 记一条 warn 日志。
- job.lastDispatchLatencyMs 持久化,便于区分「任务没跑」与「跑了但被睡眠/backoff 顺延」——此前只能靠 pmset dark wake 时序反推。
0.4.4 尚未包含(避免误读)
- 纯脚本任务仍走 headless agent(调度器只有 agent 一条执行路径),没有 shell 直跑类型。
- 定时任务失败没有推送告警 → 已由 pushFailureAlert(0.3.x,alertOnConsecutiveFailures 默认 2、按失败链水位去重)落地;0.5.0 起该告警也带幂等键。
0.5.0 交付幂等与交付台账(2026-10-03,重复推送复盘)
背景:30 天飞书历史里 88 条定时任务消息命中 7 起重复推送(一次触发推 2–4 条)。
根因不是"任务跑了两次",而是"推送这件事没有幂等语义":
- 6/7 起是同一次 run 内模型自我纠正重发(prompt 写"摘要 >500 字截断到 500 以内",模型先发长版、发现超限后再发一版合规的);
- 1/7 起是超时重试整段重跑(attempt2 于 07:16:34 推送成功后 4s 被 SIGKILL,当日去重标记没落地,attempt3 于 07:18:02 又推一条);
- 而 assertCmd 只验「报告文件存在/非空/新鲜」,完全不验「发了几条」, runs.jsonl 也不记交付 ⇒ 重复全在检查链路的盲区里,持续了一个月。
A. runKey 注入(幂等键的正确粒度)
- 每次触发计算 runKey = <jobId>|<scheduledFor>,attempt 不进 key。
- 传递通道 = prompt 占位符替换(不是环境变量):job.prompt 里的
{{DSH_SCHED_RUN_KEY}} 在 spawn 前被替换成本次触发的 runKey。子进程 env 仍照常
注入 DSH_SCHED_RUN_KEY 等 5 个变量,但不作为依赖——原因见下。
为什么不能用 env(2026-10-03 端到端探针实测):DSH 的 bash 工具不继承 process.env——其 spawn 的 env 是 ENV_OVERRIDES + spec.env + dshEnv,而 dshEnv 来自 ShellEnvRegistry.collect(),契约写明「每次模型 shell 调用都重建,ambient DSH_ 一律被 executor 丢弃」(packages/shell/shell-env/src/index.ts:81-85)。注入子进程 env 后 agent 的 bash 里读不到该变量 —— 探针因此红了第一次上线。prompt 替换不依赖任何 shell 环境,是让幂等键到达推送点的唯一可靠通道。
- 于是 agent 侧 feishu-push.sh --key "{{DSH_SCHED_RUN_KEY}}" 让同一次触发的所有 attempt 共用一个幂等键,脚本层(claim→send→commit)据此拒发第二条。
- renderPrompt() 对不含占位符的 prompt 原样返回(既有 job 零影响);runKey 为空时也原样返回 —— 绝不把占位符留成字面量,否则所有 job 会共用同一个假 key 互相 dedup。
- runKey 同时写进 start/finish 台账行,交付事实可按 key 反查。
B. 交付台账(从 push 脚本的 .sent 派生,scheduler 不自己发消息)
- job 声明 notify: { channel: 'feishu', require?: bool } 即开启跟踪(缺省关闭,不打扰不推送的 job)。scheduler 按 sha256(runKey) 前 16 位读
<deliveryStateDir>/<hash>.{sent,skipped}(默认 ~/.dsh/storages/feishu-push,与 push 脚本同源,hash 算法已由门禁锁为跨语言契约)。
- finish 行新增 push 字段,三态:
| 标记 | status | 含义 |
| <hash>.sent | delivered | 真的发了(含 messageId/sentAt) |
| <hash>.skipped | skipped | 有意不推送(合法)——由 feishu-push.sh --skip 写;例:feed 当日故障已通知过就静默,避免同日重复打扰 |
| 都没有 | missing | 该发而没发 —— 唯一该判失败的那种 |
为什么必须三态(2026-10-03 实测):只认 .sent 时,21:35 那次 run 正确静默却被记成 missing,notify.require 因此永远开不起来(每次走静默分支都假红)。
- 与 delivery(投递到目标会话)是两个字段:两条链路的失败模式不同,不共用一个名字。
- notify.require=true 时只有 missing 判本次失败,且允许重试(标记仍缺失 ⇒ 补发不会被脚本当成重复;反之已送达则 .sent 在,重试推不出第二条)。默认 false:先观测遵守率,避免「机制本身变成重复推送源」。
C. 失败告警带 key:<jobId>|<scheduledFor>|alert|<consecutiveFailures> —— 同一次触发只告警一次,失败链变长则换 key 继续升级,不会被误吞。
D. 观测面:/plugins/dsh-scheduler/status 新增 counts.notifyJobs / counts.pushMissing / detail.deliveryStateDir / detail.lastPush(内存投影,读台账会让秒级探测变重)。
E. 配套门禁(在 dsfolder,非本仓): scripts/feishu-delivery-audit.mjs 从飞书侧按「任务类别 + 20 分钟窗口」聚类,一次触发 1 条即 exit 1;scripts/feishu-push-selftest.sh 锁住 push 脚本的幂等与截断语义(9 离线 + 1 live)。
生效前提:host 插件无 HMR,本改动需重载 dsh web 才生效;prompt 侧已用
${DSH_SCHED_RUN_KEY:-<旧 key>} 做过过渡(重载前回落旧行为,重载后自动升级为 runKey)。
0.5.3 交付键模板:把幂等键从「槽位域」抬到「事件域」(2026-10-07)
背景(用 .sent 台账定位到根因,不是猜):runKey = jobId|scheduledFor 是槽位域键。
同一次真实事件被额外触发时 scheduledFor 会变 ⇒ 键也变 ⇒ 两条都推得出去。实测三起重复全是这个机制:
| 观测 | 台账里的两条 key |
| feed 摘要 10-06 相隔 105s | job-63261034-cd7\|2026-10-06T05:35:00Z 与 …\|2026-10-06T05:37:00Z(cron 槽 + 额外触发) |
| 周更新 10-06 09:09–09:14 连推 3 条 | job-ce5b5c94-2c0\|09:09:18.661Z / …09:10:41.132Z / …09:14:34.462Z(三次 ad-hoc) |
结论:台账侧「每键恰好一次」 是绿的,飞书侧聚类是红的 —— 「每键一次」≠「每事件一次」。
新增可选字段 deliveryKey(模板):
- id: my-digest-job
deliveryKey: 'feed-digest-{{jobId}}-{{date}}-{{hour}}' # 事件域键
notify: { channel: feishu, require: true }
- 占位符:{{jobId}} {{runKey}} {{scheduledFor}} {{date}}(本地 YYYYMMDD) {{hour}}(本地 HH) {{slot}}(本地 HHMM)
- prompt 里用 {{DSH_SCHED_DELIVERY_KEY}} 作 feishu-push.sh --key({{DSH_SCHED_RUN_KEY}} 仍然可用,语义不变)
- assertCmd 里可用 $DSH_SCHED_DELIVERY_KEY(env 注入,与 $DSH_SCHED_RUN_KEY 并列)
- 未配 deliveryKey ⇒ 交付键 = runKey,行为与改动前逐位一致(旧 job 零影响)
- 交付派生(.sent/.skipped)、notify.require 门禁、「恰好一次」判据全部改用交付键 ⇒ 三者自洽
判据也随之精确化(否则会出现假阳性):交付键改成事件域后,同一次真实事件的多次触发都会看到同一个 .sent 并各写一条 delivered 事件 ⇒ 若仍按「事件条数」判重复就会误判。现在按 messageId 去重判: .sent 的 messageId 变了才是真发了两条(脚本 dedup 时不重写 .sent;只有绕过 key / --force 才变)。
delivered 事件因此带上 messageId;旧台账(无该字段)不计入。
实测(2026-10-07,临时作业 + --skip 保证不发真消息):两次触发 ⇒ 两个不同 runKey、同一个交付键、两条 finish 都 succeeded、无 missing/duplicate、该键只有 1 个记账文件。
坑(写作业时注意):临时用 workspace=/tmp 会让 headless 的 workspace-write 沙箱拒绝执行 工作区外的 ~/.dsh/scripts/feishu-push.sh(表现为 ledger_write_failed ⇒ deliveryMissing 假红)。
推送型作业的 workspace 用 ~/.dsh/scheduler-reports(与既有 7 个作业一致)。
一、调研结论:为什么需要这个插件
1.1 DSH 现状(2026-08-15 核查)
DSH 内置 @deepseek-ai/dsh-schedule(packages/schedule/schedule),它是会话级 reminder:
- 事件溯源:schedule/change 事件写进会话日志,ScheduleRuntime 折叠事件 + setTimeout 派生唤醒;
- 三种触发器:after_seconds / at / every_seconds(固定间隔,非 cron 表达式);
- 到期行为:agent.followup(createUserMessage(...)) —— 只向当前活着的根 agent 会话注入一条提醒消息;
- 工具面:schedule_create / schedule_list / schedule_delete;
- 无跨会话执行、无 cron 表达式、无任务定义持久化(定义随会话日志)、无运行台账、无管理页面。
结论:DSH 有「定时提醒」,没有「定时任务」——后者需要:跨会话/独立执行面、cron 语法、可管理的任务定义存储、运行记录与重试、管理 UI。这就是 dsh-scheduler 的定位。
1.2 参照实现对比
| 维度 | los scheduled-work | cantool device-jobs | hermes cron | codex(Automations / codex-cron-skill) | dsh-scheduler(本插件) |
| 触发 | cron / interval / once | daily 本地时间 / weekly / interval | 相对延时 / interval / cron / ISO | cron / interval / once(--at) | cron / interval / once |
| 存储 | 服务端数据库 | 文件 store(definitions/grants/runs/locks/logs,原子写) | ~/.hermes/cron/jobs.json 原子写 | .codex/ 状态目录 + runs jsonl | ~/.dsh/storages/dsh-scheduler/ 文件 store,原子写 |
| 调度循环 | 服务端调度器 | launchd 平台调度(reconcile 漂移修复) | 60s tick + 文件锁 | daemon + 单 runner 锁(mkdir CAS) | 60s tick + 精确唤醒 timer + mkdir 锁 |
| 执行模型 | 独立 task run(会话事件、run contract、heartbeat、去重键) | launchd 拉起 runner 子进程 | 每次运行开全新 agent 会话(自包含 prompt) | codex exec 全新运行 / tmux 注入 | dsh --profile headless 全新进程,cwd=任务工作区 |
| 治理 | 审批策略 / 并发策略 / 追赶策略 / 熔断 / 尝试次数 | Cleanup 类 handler 需审批+隔离区 | repeat 次数 / pause-resume / [SILENT] 静默标记 | 默认拒绝沙箱降级 allowlist | 并发上限 / 运行超时 / 失败连续计数 / pause-resume / trigger-now |
| 管理 UI | schedules-page(预设表单 + 触发预览 + 台账) | CanTool 前端 device-jobs 面板 | CLI hermes cron + 聊天内 cronjob 工具 | CLI / 官方 App | 会话内「定时任务」tab(conversation.view slot) |
设计要点借鉴:
- hermes:任务定义 JSON 原子持久化、每次运行开全新会话且 prompt 必须自包含、运行台账 + last_status、run(手动触发)动作、[SILENT] 静默约定;
- los:trigger 预览(下次触发时间)、运行状态机(queued/running/succeeded/failed/skipped)、trigger-now、失败连续计数、时间戳台账;
- cantool:文件 store 分目录(definitions/runs/locks)、原子写、任务校验、手动触发;
- codex:执行安全默认——定时任务跑在 headless 独立进程,天然不继承交互会话的权限上下文;runs 逐条落盘不截断。
1.3 DSH 插件技术底座(复用已验证机制)
- 宿主半包:cordis 函数插件,inject: ['webServer'],ctx.webServer.register({ kind: 'prefix', path: '/scheduler', handler }) 挂同源 API(同 dsh-health-panel 已验证模式);
- 客户端半包:dsh.client.inject: ['@deepseek-ai/dsh-client-runtime'],ctx.slots.inject('conversation.view', ...) 注册会话 tab(同 dsh-health-panel);
- 执行面:dsh --profile headless "<prompt>"(node --import tsx/esm apps/cli/src/bin.ts)——独立进程、无端口、免疫 web 重启、会话落盘共享 ~/.dsh/sessions(按 cwd 隔离工作区)。每次运行 = hermes 的「全新会话」模式。
二、架构
┌─ web 宿主进程(dsh web)───────────────────────────────┐ │ dsh-scheduler (host 半包, index.mjs) │ │ ├─ store: ~/.dsh/storages/dsh-scheduler/ │ │ │ jobs.json (任务定义,原子写 tmp+rename) │ │ │ runs.jsonl (运行台账,append,上限裁剪) │ │ │ .tick.lock (mkdir 单实例锁,防重复触发) │ │ ├─ ticker:60s 周期 + 最近 nextRunAt 精确唤醒 │ │ ├─ executor:spawn headless 子进程(cwd=任务工作区) │ │ │ stdout/stderr 尾部捕获 → runs.jsonl │ │ └─ REST API:GET/POST/PATCH/DELETE /scheduler/... │ └──────────────────────────────────────────────────────────┘ │ spawn(每次运行一个独立进程) ▼ ┌─ dsh --profile headless(cwd = 任务 workspace)──────────┐ │ 全新 agent 会话:执行任务 prompt,结果打印 stdout 后退出 │ │ 会话落盘 ~/.dsh/sessions/<workspace>/ │ └──────────────────────────────────────────────────────────┘ ▲ ┌─ 浏览器(管理页,client 半包)───────────────────────────┐ │ 会话「定时任务」tab → fetch /scheduler/ │ │ 列表 / 新建(触发预览)/ 触发 / 暂停 / 历史 │ └──────────────────────────────────────────────────────────┘
2.1 任务模型(jobs.json)
{
"id": "job-3f9a...", // 稳定 id "name": "每日 CI 巡检", "prompt": "检查 CI 状态并汇总", // 自包含 prompt(headless 全新会话,无上下文)
"trigger": { "kind": "cron", "expression": "0 9 * * 1-5", "timezone": "Asia/Shanghai" },
// kind: cron | interval | once
// cron: 5 段 cron(支持 /n、a-b、a,b、月份/星期名) // interval: expression = "30m" / "2h" / "90s"(最小 60s) // once: expression = ISO 8601 时间戳 "workspace": "/path/to/job/workspace", // headless 运行 cwd "enabled": true, "state": "scheduled", // scheduled | paused | completed(once 已触发)
"deliverTo": { "sessionId": "session-..." }, // 可选:运行结果 followup 进该会话(须在线)
"catchUpPolicy": "run_once", // 可选:'run_once'(错过补跑) | 'skip'(超时差跳过);缺省跟随全局 "pausedReason": null, // 熔断自动暂停时 = 'max_consecutive_failures' "nextRunAt": "2026-08-16T01:00:00.000Z", "lastRunAt": null, "lastStatus": null, // succeeded | failed | skipped "runCount": 0, "consecutiveFailures": 0, "createdAt": "...", "updatedAt": "..." }
2.2 运行台账(runs.jsonl,每行一个 Run)
{ "id": "run-...", "jobId": "job-...", "triggerKind": "scheduled" | "manual",
"scheduledFor": "...", "status": "queued"|"running"|"succeeded"|"failed"|"skipped"|"cancelled",
"exitCode": 0, "durationMs": 1234, "outputHead": "...(尾部 8KB)", "dispatchLatencyMs": 41234, "assertExitCode": 0,
"delivery": { "status": "delivered"|"skipped"|"error", "sessionId": "...", "reason": "..." },
"sessionDir": "...", "startedAt": "...", "completedAt": "..." }
- dispatchLatencyMs(2026-09-12 审计新增):实际派发时刻 − 计划时刻。宿主睡眠或阻塞会让 cron 任务迟到几十分钟(macOS Clamshell Sleep 把 Node 定时器顺延到下一个 dark wake,实测 08:30 的任务有 9 次落在 08:48–09:00)。迟到 >5min 会打 [dsh-scheduler] ... dispatched Nmin late 警告日志;/scheduler/status 暴露每任务 lastDispatchLatencyMs。
- assertExitCode / assertFailed(2026-09-12 审计新增):见 §2.4 的机械验收断言。
- 台账轮转:maxRuns(默认 5000)行以内原地保留;超过 rotateBytes(2MB)时把最旧的若干行归档到 archive/runs-<YYYY-MM>.jsonl(按月分片,按行内 startedAt/scheduledFor 归月),不再直接丢弃。
旧版硬编码 maxRuns=500 会在轮转时丢掉约 90% 历史,使审计无法回溯。
2.3 调度循环
- tick()(60s 周期,每次任务变更后立即重算):对每个 enabled && nextRunAt <= now 的任务:
- 先过追赶策略(decideCatchUp,纯函数):run_once(默认)→ 无论迟到多久都补跑一次;skip → 迟到超过 maxLatenessMs(默认 15min,0=不限)就跳过本次并重算 nextRunAt,台账记 skipped;
- 并发已达上限 → 记一条 skipped 台账,nextRunAt 顺延到下一次(不追赶);
- 否则 spawn 执行,nextRunAt 重算(cron → 下一次匹配;interval → now + every;once → null + state=completed);
- 精确唤醒:arm setTimeout 到最早 nextRunAt(max 2^31-1 ms 分段),唤醒后重新 tick;
- .tick.lock:mkdir 原子获取,web 重启交叠期间防双触发;异常退出遗留锁由 mtime 超时回收。
2.4 执行(executor)
spawn(process.execPath,
['--import', <harness>/node_modules/tsx/esm, <harness>/apps/cli/src/bin.ts,
'--profile', 'headless', prompt],
{ cwd: job.workspace, env: process.env })
- 优先用 apps/cli/lib/bin.js(若已构建,免 tsx),否则走 tsx 源码入口;
- 超时升级:timeoutMs(默认 30min)到点发 SIGTERM,再等 killGraceMs(默认 10s)仍不退则 SIGKILL;台账记 failed + error: timeout after Ns;
- 输出捕获:stdout/stderr 合并尾部 8KB 入台账 outputHead;
- 退出码 0 → succeeded,非 0 → failed;
- 机械验收断言(job.assertCmd,2026-09-12 审计新增):exitCode=0 只说明 headless 进程干净退出, 不等于任务达成(实证:feed 任务连续 15 次「成功」运行产出为零)。可选字段 assertCmd(shell 片段)在进程干净退出后由 /bin/bash -lc 在任务 workspace 内执行(超时 assertTimeoutMs,默认 60s):非零 ⇒ 该次 run 记 failed + assertFailed: true + assertExitCode,error 前缀 assert failed (exit N):。
断言失败是确定性失败,不重试(重试只会重复副作用,例如已发出的通知),但计入连续失败计数并触发告警/熔断。
null 清除该字段(清除必须先于 {...existing} 展开,否则会被复活)。示例(要求当日产物存在、非空、新鲜):
"assertCmd": "f=\"$HOME/.dsh/scheduler-reports/los-governance-daily-$(date +%Y%m%d).md\"\n[ -s \"$f\" ] || { echo \"missing: $f\"; exit 1; }\n[ -n \"$(find \"$f\" -mmin -90)\" ] || { echo \"stale: $f\"; exit 1; }\necho ok: $f"
- 熔断:连续失败达到 maxConsecutiveFailures(默认 5)→ 自动暂停(enabled=false, state=paused, pausedReason=max_consecutive_failures),UI 显示「已熔断暂停」,手动恢复后重新计数;
- 自动重试(带上限):maxAttempts(默认 3,job 可覆盖 1-10)= 单次逻辑触发的总尝试次数,retryDelayMs(默认 60s,job 可覆盖 5s-30min)= 重试间隔。瞬时失败(网络 TRANSPORT/5xx/超时/任意非零退出)自动重试;持续性失败不重试(isRetryableFailure 识别 402/insufficient balance/quota/credit/401/403/billing/account suspended 等额度与认证类错误,重试只会浪费配额)。重试状态持久化在 job 的 retryState(由 tick 驱动,web 重启不丢、等待期不会重复触发);每次尝试记一条 run(带 attempt 序号),重试中失败不递增熔断计数、不投递,最终结果才走 applyRunResult 与投递;耗尽上限仍失败则 run 标注 gave up;
- 调度抖动(dispatch jitter,0.3.0):全局 dispatchJitterMaxMs(默认 180s,0 禁用)让每个定时任务在 cron 时刻后随机延迟 0-max 毫秒再执行——任务不再精确整点触发、彼此错开、避免与外部整点调度(如采集后端的 :00/:30 定时 run)撞车;单任务可用 trigger.jitterMaxMs 覆盖(0 即该任务不抖动)。手动触发与失败重试不走抖动(保持响应性)。
- 每任务模型链(job.model,0.3.0):model: {provider, model, reasoningEffort?, fallback?: [{provider, model, ...}]} 让任务用指定模型执行(headless 经临时 settings 副本 + settings-file patch 注入,run 结束即清理)。fallback 为有序模型链:attempt 1 用主模型,后续 attempt 用 fallback 最后一项(切过去不再回来)——免费渠道(如 opencode-zen/hy3-free)做主渠道、付费渠道(deepseek-official)做兜底,免费渠道瞬态失败自动落到付费重试。未声明 model 的任务沿用 headless 默认模型。
- 结果投递:任务配置了 deliverTo.sessionId 时,运行结束后把结果摘要(renderDelivery)以 createUserMessage + agent.followup 注入该会话(须是本 web 实例的 live root agent);目标不在线则台账记 delivery.skipped;
- harnessDir 配置:可选;缺省自动发现 dsh 命令行(env DSH_HARNESS_DIR → ~/.dsh/source/current → profile 安装 ~/.dsh/profiles/node_modules/@deepseek-ai/dsh → 报错提示);defaultWorkspace 兜底为 homedir()(env DSH_SCHEDULER_WORKSPACE 优先)。
2.5 REST API(同源 /scheduler)
| 方法 | 路径 | 说明 |
| GET | /scheduler/status | 运行时状态(tick、锁、在飞任务、熔断/追赶配置) |
| GET | /scheduler/jobs | 任务列表 |
| POST | /scheduler/jobs | 创建任务(校验 + 重算 nextRunAt) |
| GET | /scheduler/jobs/:id | 单个任务 |
| PATCH | /scheduler/jobs/:id | 更新(name/prompt/trigger/workspace/enabled/deliverTo/catchUpPolicy/maxAttempts/retryDelayMs/model) |
| DELETE | /scheduler/jobs/:id | 删除(含台账) |
| POST | /scheduler/jobs/:id/trigger | 手动触发一次(triggerKind=manual) |
| POST | /scheduler/jobs/:id/pause / /resume | 暂停 / 恢复 |
| GET | /scheduler/jobs/:id/runs?limit= | 运行台账 |
| GET | /scheduler/preview?kind=&expression=&timezone=&n= | 触发预览(未来 n 次) |
2.6 管理页面(client 半包)
- 注册 conversation.view slot(id=scheduler,label=定时任务,order 96);
- 功能:运行时状态条(tick/在飞/任务数/熔断阈值/追赶策略)/ 任务列表(下次触发、上次状态、投递目标、运行次数、熔断暂停标记、操作)/ 新建+编辑表单(触发预设:cron / 间隔 / 一次性 + 时区 + 工作区 + deliverTo 会话 + 追赶策略 + 实时「未来 5 次触发」预览)/ 手动触发 / 暂停恢复 / 删除 / 运行台账(状态、耗时、退出码、投递状态徽标、输出尾部可展开)。
2.7 运行日志
宿主 ctx.logger 打点(web 日志 ~/.dsh/logs/dsh-web.log,前缀 [dsh-scheduler]):启动配置摘要(info)/ 每次 fire(info,含 run id)/ 每次 finish(info,状态+耗时+投递结果)/ 超时与 SIGKILL 升级(warn)/ 熔断自动暂停(warn)/ 投递跳过或失败(warn)。
三、安装
1. 注册 bundle
dsh plugin --profile web add github:LosEcher/dsh-scheduler#main
2. 重启 dsh web(宿主插件代码 HMR 不覆盖,必须重启) ~/.dsh/scripts/dsh-web-restart.sh
3. 验证
curl -s http://127.0.0.1:3080/scheduler/status
本地开发(改源码时):
dsh plugin --profile web add link:/path/to/dsh-scheduler
客户端半包改动(src/client/)需要 node scripts/build.mjs 重建 lib/client.js 后刷新页面;宿主改动必须重启 dsh web。
三·五、配置与常见坑(2026-08-15 事故复盘)
事故:v0.1 的 Config 把 env 派生默认值写成了
schemastery .default(() => ...) 工厂函数。schemastery 的 .default(value) 原样存储参数
(只吃字面量,不调用函数),bundle patch 里 config: {} 一走默认值,Cordis 按 string 校验直接抛:
failed to apply loader entry dsh-scheduler:
$.dataDir expected string but got () => ${process.env.DSH_HOME ?? ...}
配置树组合失败 → 整棵 web 插件树起不来(热重载直接命中;daemon 重启有 --dump-config preflight 兜底)。
规避规则(DSH 插件开发通用):
1. .default() 只放字面量(数字如 Schema.number().default(60_000) 是安全的,见 harness retry-policy.ts);env/运行时派生值一律不在 Schema 里默认。
2. 采用 harness 第一方约定(settings-file:"defaulting happens here, never inline"): Schema 字符串字段留空(可选),在 apply(ctx, config) 里用 resolveConfig(config) 做 config.x ?? process.env.Y ?? 字面量 的运行时解析(本插件 index.mjs 的 resolveConfig() 即此模式)。
3. 改插件后先跑 daemon 同款 preflight:pnpm dsh --profile web --dump-config,任何 schema 错误在这里就会暴露,而不是等热重载把整棵树拉起来。
4. 新增 bundle 行的 config: {} 意味着所有字段默认值都会被实际使用——默认值必须是合法字面量。
插件内 import @deepseek-ai/ 的解析坑:宿主 loader 以 profile 目录为解析基座,profile node_modules 里只有 @deepseek-ai/cordis / schemastery 等少量包;dsplugins 里的插件若要 import 其他 @deepseek-ai/(如本插件的 @deepseek-ai/dsh-llm 取 createUserMessage),需在插件目录建 node_modules/@deepseek-ai/<pkg> 符号链接指向 harness 对应包(packages/llm/llm 等,dsh-channel-wechat 同款做法;链接已入 .gitignore 不进仓库)。
判断依赖是否可解析:node -e "import('@deepseek-ai/<pkg>')" 在插件目录直接验证。
四、边界与后续
- v1 执行面为 headless 进程:每次运行约 5s 启动成本、无 web 会话内的可见性(台账 + deliverTo 承载结果);
- 后续可选:deliverTo 扩展 IM 渠道投递(对接 channel 插件)、cron 表达式描述、每任务模型/提供方覆盖、审批策略(los 同款)、systemd/launchd 平台调度器(cantool 同款)。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
LosEcher/dsh-scheduler
按 cron 或间隔自动执行任务并留存运行记录
相关插件 · UI / 主题
破甲增强版JeffKeeping/dsh-purge-plus
dsh-purge 的非官方分支fork:适配 DeepSeek Harness 0.2.1-rc.2、破甲内核…
★ 387已汉化DeepSeek 客户端
71.9
无代码业务系统平台nocobase/nocobase
NocoBase 是什么
★ 24,804已汉化DSH
71.8
1Panel 服务器面板1Panel-dev/1Panel
全球 250 万+ 自托管用户共同喜爱。
★ 37,344已汉化DSH
71.5
终端编码代理esengine/DeepSeek-Reasonix
· 指南 · ACP · 扩展开发 · 规格 · 官方网站 · Discord · QQ
★ 36,045已汉化DSH
71.2
工作状态行动态ccch1mneyyy/working-activity
作者:chimney@ccch1mneyyy。社区项目,非 DeepSeek 官方项目。
★ 664已汉化DSH
71.2
多模型配置管家farion1231/cc-switch
跨平台桌面一体化助手,支持 Claude Code、Codex、OpenCode、DeepSeek Harness 等工具,唯一官网 ccswitch.io
★ 151,202已汉化DSH
71.1
这是你的仓库?认领条目