# dsh-schedule-view · 定时任务插件

[English](./README.md) | **中文**

> 为 DeepSeek Harness (dsh) 桌面端提供基于 cron 的定时任务功能：在设置面板中创建 / 编辑 / 删除 / 立即执行任务，支持跨 session 的 agent follow-up 和多级通知。零 LLM tool —— 纯人驱动的调度。

## 功能概览

| 功能 | 说明 |
|---|---|
| 7 种调度类型 | 间隔 / 每天 / 每周 / 每月 / 每年 / 一次性 / cron |
| Cron 表达式 | 完整 5 字段 cron，实时校验 + 人类可读预览（"每个工作日 09:00"） |
| 跨 session 触发 | Timer 在 host 进程级存活，目标 session 关闭也能触发 |
| Agent follow-up | 到期以 user role 注入提示词（`请根据系统指令开始执行任务。`），AI 立即执行 |
| 执行生命周期 | 追踪 `delivered → running → completed/failed`，记录 AI 回复摘要 |
| 多级通知 | 页面 toast（8s 自动消失）+ WebAudio 提示音（零文件）+ Electron 桌面通知 + 未读徽标 |
| 模型选择 | 每个任务可指定 provider/model，未指定则用部署默认 |
| 工作目录 | 每个任务可绑定 cwd，上下文感知执行 |
| 错过处理 | 跳过错过窗口，或下次启动补跑一次（应用重启 / 休眠场景） |
| 零 LLM Tool | 不注册任何 LLM tool，零 schema 开销，纯 UI 驱动 |

## 背景

dsh 官方 `@deepseek-ai/dsh-schedule` 插件存在以下局限：

| 局限 | 官方 | 本插件 |
|---|---|---|
| 调度粒度 | `after_seconds` / `at` / `every_seconds` | 完整 cron + 7 种类型 |
| Session 范围 | 仅 session 本地 | 跨 session（host 级 timer） |
| 通知 | 仅对话内 | toast + 提示音 + 桌面通知 + 徽标 |
| UI 管理 | 无（纯 LLM tool） | 完整设置面板 |
| LLM schema 开销 | 3 个 tool（`schedule_create`/`list`/`delete`） | 零 tool |

## 安装

### 前置条件

- DeepSeek Harness (dsh) 桌面端
- Node.js >= 18

### 安装

```bash
dsh plugin add @lijian-ui/dsh-schedule-view
```

### 本地开发

```bash
# 进入插件目录
cd extensions/dsh-schedule-view

# 安装依赖
npm install

# 构建
npm run build

# 监听模式
npm run watch

# 类型检查
npm run typecheck
```

构建产物在 `lib/` 目录下，通过 junction 自动同步到 `node_modules/@lijian-ui/dsh-schedule-view`。每次构建后需重启桌面端加载新 bundle。

## 使用方式

1. 打开 dsh 桌面端
2. 进入 **设置** → **定时任务**
3. 在任务列表中：
   - 点击 **新建任务** 创建定时任务
   - 点击滑块按钮启用/停用
   - 点击 **编辑** 修改调度规则、提示词或模型
   - 点击 **立即执行** 手动触发一次
   - 点击 **删除** 永久移除
   - 点击 **执行历史** 查看运行记录

### 任务字段

| 字段 | 必填 | 说明 |
|---|---|---|
| 任务名称 | 是 | 任务名 |
| 调度规则 | 是 | 7 种类型：间隔 / 每天 / 每周 / 每月 / 每年 / 一次性 / cron |
| 提示词指令 | 是 | 到期注入目标 agent 的指令 |
| 目标 Session | 是 | 接收提示词的 session |
| 工作目录 | 否 | 任务 agent 绑定的 cwd（绝对路径） |
| 执行模型 | 否 | provider/model 覆盖，未指定则用部署默认 |
| 时区 | 否 | cron 解析用的本地时区（默认系统时区） |

### 调度类型

| 类型 | 示例 | 说明 |
|---|---|---|
| 间隔 | 每 60 分钟 | 固定分钟间隔 |
| 每天 | 每天 09:00 | 壁钟时间 |
| 每周 | 周一、三、五 09:00 | 选择星期 |
| 每月 | 每月 1 号 09:00 | 每月第几天（1-31 或最后一天） |
| 每年 | 每年 1 月 1 日 09:00 | 月份 + 日期 |
| 一次性 | 2026-09-01T09:00:00 | 单次触发，触发后自动停用 |
| Cron | `0 9 * * 1-5` | 完整 5 字段 cron 表达式 |

### 错过处理

应用关闭或休眠时错过了触发窗口：

| 策略 | 行为 |
|---|---|
| 跳过 | 丢弃错过窗口，等待下次调度时间 |
| 补跑一次 | 下次 tick 时补跑一次（一次性任务默认） |

### 执行生命周期

任务触发时：

```
delivered → running → completed/failed
```

| 阶段 | 触发条件 | 处理 |
|---|---|---|
| 已投递 | `agent.followup` 成功 | 创建历史记录，发送桌面通知 |
| 执行中 | `user/message` 事件匹配注入的 messageId | 捕获 AI 回复摘要，记录耗时 |
| 已完成 | `turn/end` 事件 | 最终状态、endReason、toast + 提示音通知 |
| 已失败 | `turn/end` 含错误 | 标记失败，持久 toast，错误提示音 |
| 已跳过 | 触发时 agent 不 live | 不 followup，跳过通知，历史标记 skipped |

## 技术架构

### 目录结构

```
extensions/dsh-schedule-view/
├── src/
│   ├── index.ts                    # Host 入口（安装设置面板 + 启动 timer）
│   ├── remote.ts                   # Host RPC 方法（list/create/update/delete/fireNow）
│   ├── timer-runtime.ts            # 核心 timer 引擎（cron 解析 + tick 轮询 + 触发）
│   ├── schedule-core.ts            # 调度计算（下次触发时间）
│   ├── lifecycle-tracker.ts        # Session/event 监听器，追踪执行生命周期
│   ├── notify.ts                   # 多级通知（toast + 提示音 + 桌面通知）
│   ├── guarded.ts                  # 故障隔离包装（所有回调 try-catch）
│   ├── types.ts                    # TimerTask、RunRecord、TaskSchedule 类型
│   ├── schema.ts                   # 配置 schema（schemastery）
│   └── client/
│       ├── index.ts                # Client 入口（设置 section 注册）
│       ├── TimerSettingsSection.tsx # 主设置 UI（列表 + 表单 + 历史）
│       ├── client-i18n.ts          # 国际化（中/英）
│       ├── config-api.ts           # Client 端 RPC 包装
│       ├── model-catalog.ts        # 模型选择下拉 UI
│       └── chime.ts                # WebAudio 双音提示音合成
├── lib/                            # 构建产物
├── cordis.patch.yml                # Bundle patch 声明
├── package.json
└── tsdown.config.ts
```

### Host 端（`src/`）

| 模块 | 职责 |
|---|---|
| `index.ts` | 插件启动：安装设置面板、启动 timer、配置变更时同步 |
| `remote.ts` | RPC API：list / create / update / delete / fireNow / runs |
| `timer-runtime.ts` | cron 解析、tick 轮询、agent followup 注入、生命周期追踪 |
| `lifecycle-tracker.ts` | 监听 `session/event`，匹配注入的 messageId，更新 run 状态 |
| `notify.ts` | toast（React portal）+ 提示音（WebAudio）+ 桌面通知（Electron IPC） |
| `guarded.ts` | 所有回调包在 try-catch 中；插件故障不拖垮宿主 |
| `schema.ts` | schemastery 配置校验 |

### Client 端（`src/client/`）

| 模块 | 职责 |
|---|---|
| `index.ts` | 通过 `ctx.slots.inject` 注册设置 section |
| `TimerSettingsSection.tsx` | React 组件：任务列表、创建/编辑表单、执行历史面板 |
| `client-i18n.ts` | 中英文翻译 |
| `config-api.ts` | RPC 客户端包装 |
| `model-catalog.ts` | 模型选择下拉 UI |
| `chime.ts` | WebAudio 双音提示音合成（零音频文件） |

### 持久化

| 数据 | 存储方式 | 说明 |
|---|---|---|
| 任务定义 | `dsh-settings` | UI 可编辑，revision 防冲突 |
| 执行历史 | `dsh-storage-domain` | 结构化 KV，封顶 500 条 |

### Tick 轮询策略

使用 `setInterval` tick 轮询（默认 15s）而非每个任务一个 `setTimeout`：

- 避开 `setTimeout` 的 `2^31 - 1` ms（~24.8 天）上限
- 重启恢复：从持久化任务重新计算 `nextFireMap`
- 错过窗口：重启后首次 tick 立即触发，由 catch-up 策略处理
- 触发精度：受 `tickSeconds` 限制（定时任务场景完全可接受）

## 已知问题与解决方案

### 重启后 Session ID 冲突

**问题**：桌面重启后，任务配置中持久化的 `sessionId` 可能与已存在的 agent session 冲突，导致 `agents.create` 报 "session already exists"。

**根因**：`sessionId` 被持久化到 settings 中；恢复时旧 ID 与 agent 在磁盘上的会话日志冲突。

**我们的方案**：`sessionId` **不持久化**。每次触发时通过 `agents.create` 用新 UUID 创建专属会话。配置中的 `sessionId` 是临时的——只在 session 生命周期内追踪 agent handle。

### Agent 被 dispose 后 handle 过期

**问题**：Agent 被外部 dispose（如用户关闭 session），但 runtime 仍持有过期 handle。

**我们的方案**：`ensureAgent` 检测 handle 是否过期（已 dispose 或不在 `agents.list()` 中）。若过期则 dispose handle 并轮换 `sessionId` 以创建新会话。

### 会话日志文件被删除后 ENOENT

**问题**：用户删除了会话日志文件，但 agent 仍在运行，无法写日志。

**我们的方案**：`agent/error` 监听器检测 `ENOENT` → dispose agent → 轮换 `sessionId` → 创建新会话。

### 归档会话

**问题**：dsh 通过标记 `archivedSessionIds` 归档会话，但不 dispose agent。`agents.get()` 仍返回 agent，`followup` 照常执行但用户 UI 看不见。

**我们的方案**：`ensureAgent` 中检查 `workspaceRegistry.archivedSessionIds`，若已归档则 dispose agent 并轮换 `sessionId` 创建新会话。

### 未挂载 preset 导致模型选择失效

**问题**：`agents.create` 不带 `setup` 回调不会挂载 `standard` preset，agent 无工具且 prompt assembly 无法解析 `{{provider}}` / `{{model}}` 变量。

**我们的方案**：所有 `agents.create` / `agents.resume` 调用均带 `setup` 回调，在 agent 发布前挂载 `agentPresets`（`'standard'`）并安装模型选择。

## 国际化

支持中文和英文。翻译文件在 `src/client/client-i18n.ts`。语言切换跟随 dsh 桌面端设置。

## 技术栈

- **语言**：TypeScript
- **构建**：tsdown (rolldown)
- **前端**：React 18
- **Cron 解析**：`cron-parser`（~30KB）
- **人类可读 Cron**：`cronstrue`
- **配置 Schema**：`@deepseek-ai/schemastery`
- **设置持久化**：`@deepseek-ai/dsh-settings`
- **存储**：`@deepseek-ai/dsh-storage-domain`

## 许可证

MIT

## 相关链接

- [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/dsh)
- [设计文档](./design.md)