LosEcher/dsh-scheduler

dsh-scheduler

DSH scheduler plugin: cron/interval/once jobs, headless execution, run ledger, deliverTo, circuit breaker, session management UI

Uncategorizedcrondeepseek-harnessscheduler

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 ,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-.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 = |,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 位读 /.{sent,skipped}(默认 ~/.dsh/storages/feishu-push,与 push 脚本同源,hash 算法已由门禁锁为跨语言契约)。

  • finish 行新增 push 字段,三态:

标记 · status · 含义 .sent · delivered · 真的发了(含 messageId/sentAt) .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:||alert| —— 同一次触发 只告警一次,失败链变长则换 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:-} 做过过渡(重载前回落旧行为,重载后自动升级为 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 ""(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。