# dsh-cron-tasks 经验记录 日期:2026-08-23 — 2026-08-24 包名:`@dsh-external/dsh-cron-tasks` Loader id:`dsh-cron-tasks` 源码:本仓库 运行时副本:`~/.dsh/plugins/dsh-cron-tasks`(`file:` 接入 web + desktop) 产品契约:`docs/PRD.md` 这次会话把定时任务从「能调度」推到「能打开历史对话、能被 AI 创建、能当普通工作区会话继续聊」。下面只记本轮踩过的坑和已经定下来的产品语义。 --- ## 产品(一句话) 到点后 Harness **新开一条 root Session** 跑事先写好的提示词,记录进侧栏「定时任务」历史。这类 Session **不得淹没日常工作区列表**。 这不是官方 `@deepseek-ai/dsh-schedule`(同会话 reminder),也不是 OS `crontab`。 三个面都要有: 1. 侧栏「新会话」正下方入口(不是 footer) 2. 设置 → 插件配置里的调度策略 3. 历史记录:点开就是原生对话 重叠 skip、漏跑 skip、Host 没跑就不触发。 --- ## 形态 | 半边 | 入口 | 职责 | |---|---|---| | Host | `lib/index.js` + `lib/host.js` | 定时器、CRUD、fire、归档/揭示、工具、skill、同源 API | | Client | `lib/client.js`(`window.__ModuleLoader__` factory) | 侧栏入口、任务列表、编辑器、隐藏原生 cron 行、打开会话、fork 提升 | `package.json` 必须同时有 `dsh.bundle.patch` 和 `dsh.client`(`platform: web`,`immediately: true`)。Host **不要** import `@deepseek-ai/*`,也不要把它写进 `dependencies`。工具用纯 `ToolDefinition`,不要 `defineTool`。 Client `inject` 只写 `slots` / `locale` / `settingsScope`。静态 `inject: ['sessions']` 会在 boot 死锁;`sessions` / `workspaces` 用 `ctx.inject` 可选 + `internal/service`。 --- ## 本机加载(file:) ```sh # 拷到运行时后再 file: 安装(不要 link:) dsh plugin --profile web add file:$HOME/.dsh/plugins/dsh-cron-tasks dsh plugin --profile desktop add file:$HOME/.dsh/plugins/dsh-cron-tasks ``` 改代码后必须再拷: 1. `~/.dsh/plugins/dsh-cron-tasks` 2. `~/.dsh/profiles/web/node_modules/@dsh-external/dsh-cron-tasks` 3. `~/.dsh/profiles/desktop/node_modules/@dsh-external/dsh-cron-tasks` `file:` 是拷贝。只改仓库,profile 里的副本不会自己变。改 Host / bundle 后用户自己重启 `dsh web`;Desktop 和 web 互不影响。 验收: - `dsh --profile web --dump-config` 能看到 `id: dsh-cron-tasks` - `cd packages/dsh-cron-tasks && node --test test/*.test.js` - curl 本机加 `--noproxy '*'` --- ## 运行会话怎么藏、怎么打开 v1 用 `workspaceRegistry.archiveSession` 从工作区列表拿掉。归档当前会话会把选中清掉,所以 v2 打开历史时必须先 **unarchive** 再 `sessions.open`。 工作区里再用标题前缀 `定时任务 · ` 把原生行藏掉(`hideNativeCronRows`)。藏标题 ≠ 删日志。 打开历史的坑: - `sessions.open` 要求 id 已在 summaries 里;归档中的 id 会抛,catch 后看起来像「点了还停在原来的工作区会话」 - 要等 unarchive 反映到 `archivedSessionIds`,必要时 `sessions.refresh`,再 `open`,确认 `list.current === sessionId` - `ctx.get('sessions')` 在未 inject 时可能抛,必须 `tryGet` / `faces.sessions` `POST /dsh-cron-tasks/runs/:id/open` 负责 reveal;client 再 open。离开定时任务模式时 `POST /conceal` 把**已知运行会话**再归档。fork 出来的子会话不在这份名单里,不要误归档。 --- ## 每个任务要能指定模型 到点跑的会话会打对应厂商的额度。如果只用「当时新会话默认」,用户不知道扣的是哪家,聊天里一换模型,定时任务也跟着变。 定下来: - 任务可存 `provider` + `model`(可选 `reasoningEffort`) - 编辑器下拉已配置的模型;也可选「每次运行用当时的新会话默认」 - 新建时默认钉死**当前**新会话模型,避免空白 - `cron_create` 没传就快照当前会话模型 - 列表标题旁显示模型名 空 provider/model 才在 fire 时读 `agentDefaultModel`。 --- ## 执行失败 `{{model}}` 没有值 `agents.create` 不自动带上「新会话」用的默认模型。Persona 模板插 `{{model}}`,空值会让整轮装配失败。 修法:`ctx.get('agentDefaultModel').currentSelection()`,create 时写入 `agentOptions`,并在 agent ctx 上监听 `system-prompt/assemble` 和 `agent/request` 填 `provider` / `model`。没有选中模型要响亮失败,不要默默跑。 --- ## 模型怎么建任务(不要塞 system-reminder) 一开始在每条会话塞 `` 讲定时任务,不合理。改成: - runtime skill `scheduled-tasks`(**必须** `source: 'runtime'`,否则 skill_load 报 `source must be a string`) - 工具:`cron_create` / `cron_list` / `cron_pause` / `cron_resume` / `cron_delete` MiniMax 锚定会话常驻工具只有 `bash` / `str_replace_editor` / `skill_search` / `skill_load` / `dev_tool_search`。cron 工具不在 resident set 里,模型会先去跑 `crontab -l`。skill 必须写明:先 `skill_search`「定时任务」,再 `skill_load scheduled-tasks`,**禁止 crontab / /etc/cron\***。 session.jsonl 对照过:没 load skill 时模型当 OS cron 用;load 成功后才会调 `cron_*`。 --- ## 日程字段:模型会叠着传 session.jsonl 5:「一分钟后」一次性任务。模型第一次: ```json { "at": "00:33", "hour": 0, "minute": 33 } ``` 插件按互斥报 `pass only one of expr, at, or hour/minute`,错误后缀还说「请传 hour+minute」,模型第二次变成 `expr` + `hour`。第三次才成功,时间已经漂了。 定下来的优先级(多余字段忽略,不要报错): 1. `after_minutes` / `in_minutes`(「一分钟后」用这个,不要自己算钟面) 2. `hour` + `minute`(今晚几点;**hour=0 是午夜**,不能当 falsy 丢掉) 3. `at` 4. `expr` / `cron` 过去的一次性 `at` 仍然拒绝。`datetime-local` 必须按任务时区编解码,禁止对 UTC ISO 做 `slice`(否则 11:13 会显示成 15:13 或 23:13)。 skill 里写清当前本地时间;不要让模型猜日历(曾经把 2026-08-23 写成 2026-05-14)。 --- ## 调度器:一次性和漏跑 5 字段 cron + IANA(默认 `Asia/Shanghai`)。 一次性 `at` 触发后必须把 `nextRunAt` **写成 `null`**。用 `??` 回退会把 due 时间填回去,每 15s tick 再跳一次。判断完成用 `nextRunAt === null`,不要用 `??`。`Object.hasOwn` 才能持久化 null。 漏跑:Host 宕机不回放积压。skip 要去重,避免 misfire skip 刷屏。 暂停:列表可停可恢复;立即运行(播放图标)即使暂停也允许。 --- ## 「在新对话中分支」 官方:`sessions.fork({ sessionId, atSeq, increaseTitle: true })` 然后 `open(childId)`。子会话复制历史,标题变成 `定时任务 · 晨报 (1)`。 看起来像刷新,因为: - 内容几乎一样 - 标题仍带前缀,被 `hideNativeCronRows` 藏掉 - 侧栏还停在定时任务模式 - 父会话未进任何工作区 `sessionIds`,官方 `forkWorkspace` 不 attach 子会话 合理语义: | | 原运行会话 | 分支子会话 | |---|---|---| | 角色 | 历史原件 | 普通可继续聊的对话 | | 列表 | 继续藏 | 出现在工作区或未分组 | | 标题 | `定时任务 · …` | 去掉前缀,如 `晨报 (1)` | | 之后 | 仍可从历史打开 | 切走还能在左边找到 | 插件要:认出 parent 是已知 run → 改标题 → `POST /sessions/:id/adopt` → 退出定时任务模式。不要把子会话写进历史。 ### 工作区绑定 官方 `attachSession` 要求 **header.cwd === workspace.path**。cwd 对不上就不能挂到别人的项目。 - 任务没填 cwd 时,新运行用**最近工作区路径**并 attach,再 archive(槽位还在,列表不显示)。之后 fork 会继承该工作区。 - 已存在的、cwd 仍是 `~/.dsh/cron-tasks/workspace` 的子会话,只能出现在**未分组**。 - `hideNativeCronRows` **禁止**在分组收起(DOM 里没有 session 行)时把整组「未分组」`display:none`。切走找不到会话,多半是这个。 AI 在工作区会话里建任务:`cron_create` 若没传 cwd,用 `exec.agent.session.header.cwd`。手动新建:下拉已有工作区,或「自定义路径」。 --- ## UI 列表视觉对齐工作区(运行时偷 hashed class + `--dsw-*`)。任务当分组,运行当行。点任务出编辑器,点运行打开原生会话。 - 加号是新建任务;运行用播放图标 - 可搜索、可暂停 - 工作目录:工作区下拉 / 自定义 / 留空=最近工作区 不要用 CSS 把输入框圆角削平(那是额度条子的教训,这里侧栏 overlay 用 `data-dsh-ct-mode`)。 --- ## 排错顺序 1. 启动日志 `[dsh-cron-tasks]` 2. `--dump-config` 有没有 row 3. profile `node_modules/@dsh-external/dsh-cron-tasks` 是不是旧副本 4. `~/.dsh/cron-tasks/store.json`(`nextRunAt: null` 是否被写回) 5. 模型路径:session.jsonl 里有没有 `skill_load scheduled-tasks`、`cron_create` 参数是否叠字段 6. 打开会话失败:是否还在 `archivedSessionIds`、summaries 里有没有这个 id 7. 分支后找不到:标题前缀、未分组被整组隐藏、cwd 与工作区路径不一致 --- ## 明确不要做 - 改 DSH 本体;发 npm / 用本机 GitHub PAT - `link:` 安装 - 每条会话塞定时任务 `` - 把 `@deepseek-ai/*` 当 runtime 依赖 - 补跑漏掉的 cron 节拍 - 把 fork 子会话再归档进历史 - 互斥报错逼模型重试日程字段(直接按优先级吞)