DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

LosEcher /

LosEcher/dsh-scheduler

Verified

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

★ 0 Stars0 Forks1 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@9cb38515

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 CLI(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 同款)。
—/ 5

No ratings yet

Verified DSH bundle

Commit 9cb38515a33a

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout