@zhushanwen/pi-scheduler 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -24
- package/package.json +5 -2
- package/skills/scheduler-ext-config/SKILL.md +191 -0
- package/src/__tests__/backend.test.ts +68 -11
- package/src/__tests__/commands.test.ts +3 -1
- package/src/__tests__/importer.test.ts +311 -0
- package/src/__tests__/replay.test.ts +309 -0
- package/src/__tests__/runtime.test.ts +129 -15
- package/src/__tests__/sdk-contract.test.ts +41 -12
- package/src/__tests__/service.test.ts +31 -2
- package/src/__tests__/tool.test.ts +4 -2
- package/src/backend.ts +72 -35
- package/src/importer.ts +263 -0
- package/src/index.ts +38 -18
- package/src/replay.ts +181 -0
- package/src/runtime.ts +108 -49
- package/src/service.ts +22 -10
- package/src/tool.ts +1 -1
- package/src/types.ts +41 -1
- package/src/__tests__/store-roundtrip.test.ts +0 -218
- package/src/__tests__/store.test.ts +0 -161
- package/src/store.ts +0 -118
package/README.md
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
# scheduler
|
|
2
2
|
|
|
3
|
-
定时任务调度扩展:按 duration(`5m` / `2h` / `1d`)间隔或 cron 表达式,在指定时间向 agent 注入消息。支持一次性提醒(once)、强制触发(force
|
|
3
|
+
定时任务调度扩展:按 duration(`5m` / `2h` / `1d`)间隔或 cron 表达式,在指定时间向 agent 注入消息。支持一次性提醒(once)、强制触发(force)与过期策略(expires)。任务随 owner session 持久化,resume 后继续触发。
|
|
4
|
+
|
|
5
|
+
## 产品定位
|
|
6
|
+
|
|
7
|
+
pi-scheduler 是 **session 存活期间的 AI 提醒器**——在 pi 进程运行、session 打开时,按计划向当前 session 注入消息。
|
|
8
|
+
|
|
9
|
+
**非系统级 cron**:
|
|
10
|
+
|
|
11
|
+
- pi 进程不开 = 不触发(与常驻后台的系统 cron daemon 不同,本扩展不是后台守护进程)
|
|
12
|
+
- 电脑睡眠 / 关机 = 不触发
|
|
13
|
+
- 不依赖系统 crontab,不注册任何开机自启
|
|
14
|
+
|
|
15
|
+
**任务归属创建它的 session**:任务物理存储在创建它的 session 的 JSONL 文件内,**只在 owner session 打开(继续对话 / resume)时才触发**。
|
|
16
|
+
|
|
17
|
+
> **这是设计决策,不是 bug(D9)**:本扩展从 cwd 级定时器改为 **session 级 AI 提醒器**——任务归属创建它的 session,只在 owner session 存活时触发。如果你每天开新 session,昨天建的"明早检查 CI"任务今天不会响,除非你 resume 昨天创建该任务的那个 session。这不是"任务丢了":任务随 session 持久化,session 不打开就不调度。**用户若发现「昨天建的任务今天没响」,这是预期行为**,请 resume 创建该任务的 session。
|
|
4
18
|
|
|
5
19
|
## 简介与安装
|
|
6
20
|
|
|
@@ -20,15 +34,26 @@ npm install @zhushanwen/pi-scheduler
|
|
|
20
34
|
|
|
21
35
|
```
|
|
22
36
|
session_start
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
├─ runtime.loadTasks(
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
├─ PiSchedulerBackend(pi.sendMessage + 时间源 + appendEntry + getEntries)
|
|
38
|
+
│ └─ SchedulerRuntime(内存态 + 30s tick 调度 + 限流)
|
|
39
|
+
│ └─ SchedulerService(tool/command 唯一业务入口)
|
|
40
|
+
├─ runtime.loadTasks(replay(getEntries())) ← 重放 session JSONL 的 custom entry 折叠恢复任务
|
|
41
|
+
│ (仅 ownerSessionFile 匹配的加载,fork 副本被过滤)
|
|
42
|
+
├─ runtime.startScheduler() ← 启动 30s tick
|
|
43
|
+
└─ 注册 scheduler widget(每个 tick 后刷新)
|
|
29
44
|
```
|
|
30
45
|
|
|
31
|
-
|
|
46
|
+
运行时每次状态变更会 append 一条 custom entry 到创建任务的 session 的 JSONL(统一 `customType: pi-scheduler:task`,`op` 字段区分):
|
|
47
|
+
|
|
48
|
+
| op | 触发时机 | 携带数据 |
|
|
49
|
+
|----|---------|---------|
|
|
50
|
+
| `upsert` | 创建 / 更新任务 | task 全快照(含 nextRunAt 初值、ownerSessionFile) |
|
|
51
|
+
| `advance` | dispatch 成功后 | 推进后的 nextRunAt、本次执行 at / status |
|
|
52
|
+
| `toggle` | 启用 / 停用 | enabled |
|
|
53
|
+
| `delete` | 删除;once 触发后自动 delete | taskId |
|
|
54
|
+
|
|
55
|
+
- fork 出的 session 重放时按 `ownerSessionFile` 过滤,不加载、不执行继承的任务副本(原 session resume 照常)
|
|
56
|
+
- `session_shutdown` 停止 tick + 兜底执行延迟删除 cleanup(正常路径已由首个 turn_end 完成)——任务已 append 到 JSONL,无需额外写盘
|
|
32
57
|
|
|
33
58
|
## /schedule 命令用法
|
|
34
59
|
|
|
@@ -93,7 +118,7 @@ session_start
|
|
|
93
118
|
|------|------|------|
|
|
94
119
|
| `kind` | `recurring`(默认)/ `once` | recurring 每次触发后按 schedule 重算下次时间;once 触发一次后自动删除 |
|
|
95
120
|
| `name` | 字符串 | 任务可读名称,缺省从 prompt 自动生成(≤30 字原样,超长截前 27 字加省略号) |
|
|
96
|
-
| `expires` | duration 字符串 / `never` | recurring 任务的过期时间:`now + duration`;`never` 永不过期;缺省 7 天。**once
|
|
121
|
+
| `expires` | duration 字符串 / `never` | recurring 任务的过期时间:`now + duration`;`never` 永不过期;缺省 7 天。**once 任务忽略 expires 参数**(触发即删,传不传都不生效) |
|
|
97
122
|
| `force` | `true` / `false`(默认) | `true` 时即使 agent 忙(非 idle 或有 pending 消息)也强制 dispatch;`false` 时忙则延迟到下次 tick |
|
|
98
123
|
|
|
99
124
|
## 示例
|
|
@@ -122,7 +147,7 @@ session_start
|
|
|
122
147
|
{"prompt": "deploy staging", "schedule": "*/10 * * * *", "force": true}
|
|
123
148
|
```
|
|
124
149
|
|
|
125
|
-
**永不过期**(tool
|
|
150
|
+
**永不过期**(tool 调用:长期 recurring 任务不设 7 天默认过期):
|
|
126
151
|
|
|
127
152
|
```json
|
|
128
153
|
{"prompt": "monthly report", "schedule": "1d", "expires": "never"}
|
|
@@ -138,25 +163,61 @@ session_start
|
|
|
138
163
|
| 默认过期 | **7 天**(`DEFAULT_EXPIRY_MS`) | recurring 任务缺省 `expires` 时;`expires: 'never'` 关闭 |
|
|
139
164
|
| once 任务 | 触发后自动删除 | 不参与后续调度 |
|
|
140
165
|
| cron 失效 | 任务停用 + `lastStatus=failed` + `lastError='cron expression invalid'` | 不会用 `now()` 兜底导致每 tick 重触发死循环 |
|
|
141
|
-
| persist 失败 | `console.warn` + 内存态保留 + `lastError='persist failed'` | 不打断调度,下次 tick 继续尝试 |
|
|
142
166
|
| 忙时 dispatch | 非 force 任务在 agent 忙(非 idle / 有 pending 消息)时跳过,延迟到下次 tick | force=true 可绕过 |
|
|
143
|
-
| history | 保留最近 **20** 条执行记录 |
|
|
144
|
-
| 持久化 |
|
|
167
|
+
| history | 保留最近 **20** 条执行记录 | 超出丢弃最旧;重放折叠时同样裁剪 |
|
|
168
|
+
| 持久化 | custom entry append 到 session JSONL(dispatch 成功后立即 append advance 记录执行) | 任务随 owner session 持久化,resume 后重放恢复,无需额外写盘 |
|
|
169
|
+
| 交付语义 | **at-least-once**(至少一次) | dispatch 成功后内存更新 nextRunAt 并 append advance;append 之前若进程崩溃可能重复注入一次(无精确一次保证,可接受) |
|
|
170
|
+
| 延迟写入窗口 | 新 session 首 turn 内建任务后进程崩溃可能丢失 | pi 延迟写入:首条 assistant 消息前不 flush。窗口窄、概率极低、无恢复手段 |
|
|
171
|
+
| 触发条件 | pi 进程需存活且 session 打开 | 电脑睡眠 / pi 进程未运行 = 不触发(非系统 cron,无后台守护) |
|
|
145
172
|
|
|
146
173
|
错误语义:创建时 schedule 解析失败 → `INVALID_SCHEDULE`;`run`/`toggle`/`delete` 引用不存在的 id → `TASK_NOT_FOUND`;`run` 时任务 disabled / busy / rate-limited → `DISPATCH_SKIPPED`(message 含 `busy, disabled, or rate-limited`)。
|
|
147
174
|
|
|
148
175
|
## 数据存储位置
|
|
149
176
|
|
|
150
|
-
|
|
177
|
+
### 当前机制
|
|
151
178
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
179
|
+
任务存储为 custom entry,写入创建它的 session 的 JSONL 文件(统一 `customType: pi-scheduler:task`,`op` 字段区分 upsert / advance / toggle / delete)。任务物理归属于创建它的 session——`appendEntry` 把 entry 写入当前 session 的 JSONL,`getEntries()` 重放折叠恢复内存态。
|
|
180
|
+
|
|
181
|
+
### append-only
|
|
182
|
+
|
|
183
|
+
custom entry 物理追加到 JSONL,不修改、不删除——pi 依赖 JSONL 物理保留来维持 session tree 的 parentId 链。重放时 per-taskId 按 entry 顺序折叠得到当前态:
|
|
184
|
+
|
|
185
|
+
- `upsert` → 任务以快照覆盖(last-write-wins,含 ownerSessionFile / nextRunAt 初值)
|
|
186
|
+
- `advance` → 推进 nextRunAt、记录本次执行(at / status)
|
|
187
|
+
- `toggle` → 切换 enabled
|
|
188
|
+
- `delete` → 该任务标记消失(once 触发后自动 delete,重放即不见)
|
|
189
|
+
|
|
190
|
+
末态 = per taskId 最后一个非 delete op 的结果。
|
|
191
|
+
|
|
192
|
+
### 不进入 LLM context
|
|
193
|
+
|
|
194
|
+
pi 的 context 构建对 custom entry 无 case(被过滤)——任务数据零污染对话上下文,不影响 token / 模型上下文。
|
|
195
|
+
|
|
196
|
+
### 归属即结构性质
|
|
197
|
+
|
|
198
|
+
任务物理存在于创建 session 的 JSONL 内。**session 文件删除 = 任务消失**,无残留、无需 GC、无分片文件。fork 出的 session 不加载继承的任务副本(ownerSessionFile 过滤),subagent 也不受主 session 任务干扰。
|
|
199
|
+
|
|
200
|
+
### 旧版迁移
|
|
201
|
+
|
|
202
|
+
升级前任务存在 cwd 共享的旧 store(`~/.pi/agent/scheduler/<cwd>/scheduler.json`)。升级后首个检测到旧文件的 session 原子 `rename` 为 `scheduler.json.imported`,逐任务 appendEntry upsert 到自己的 JSONL,然后删除 `.imported`(⚠️ 删除时机依赖 flush:resumed session 已落盘可立即删;新 session(pi 延迟写入,entries 仅内存)延迟到首个 `turn_end`(该轮 message_end 已全部持久化,flush 必已发生)确认 flush 后删,`session_shutdown` 兜底;未 flush 保留 `.imported` 供崩溃恢复重导入,避免源文件销毁 + 数据未落盘的双重丢失):
|
|
203
|
+
|
|
204
|
+
- **归属**:旧任务无 owner 信息,**归属首个完成导入的 session**(无更好近似)
|
|
205
|
+
- **过期任务立即触发**:导入后若 nextRunAt 已过期,**首个 tick 立即 dispatch**(once 立即注入、recurring 补跑)
|
|
206
|
+
|
|
207
|
+
### entry 累积
|
|
208
|
+
|
|
209
|
+
recurring 长期 session 的 scheduler entry 会持续累积(每次 dispatch append 一条 advance)。量级可控:约 100B/条,1h 任务运行一年约 8760 条 ≈ 876KB。且 custom entry 不进 LLM context,不影响 token / 模型上下文。**不做物理裁剪**(append-only 约束 + advance 是 nextRunAt 正确性的必要记录,不可省)。未来若成问题,方向是等 pi 提供 compaction hook,不是本 extension 自建裁剪。
|
|
210
|
+
|
|
211
|
+
## 依赖的 pi 行为清单
|
|
212
|
+
|
|
213
|
+
本扩展的存储方案(custom entry event sourcing)依赖以下 pi 源码行为。这些是**实测存在但非 SDK 契约承诺**的隐式行为,pi 升级后需逐条复核:
|
|
155
214
|
|
|
156
|
-
|
|
157
|
-
|
|
215
|
+
1. **`pi.appendEntry` / `ctx.sessionManager.getEntries()` 存在且 custom entry 不进 LLM context**:custom entry 在 pi 的 context 构建(`sessionEntryToContextMessages`)中无 case,被 flatMap 过滤,任务数据零污染对话上下文。若 pi 未来把 custom entry 纳入 context,会污染 token / 模型输入
|
|
216
|
+
2. **fork(`forkFrom`)全文件复制 custom entry**:forkFrom 是全文件复制(含被放弃分支的 entries,无 fork 点概念),不是 fork 点路径复制。本扩展靠 owner 过滤兜底两条复制路径。若 pi 改为按分支选择性复制,fork 隔离逻辑需重新评估
|
|
217
|
+
3. **`getEntries()` 返回全量 entries(不按当前分支过滤)**:实测 `getEntries()` 返回全部 fileEntries(session-manager.js:980-982),navigate 只改 leafId 指针不改 entries。因此任务不随 navigate 消失。若 pi 改为按 leafId / 分支过滤 getEntries,切换分支会导致任务丢失
|
|
218
|
+
4. **navigate / 切换分支不改任务 entries**:navigate 只移动 leafId 指针,不增删 custom entry,任务 entries 跨分支稳定存在。若 pi 未来在 navigate 时裁剪 entries,任务持久性会破坏
|
|
158
219
|
|
|
159
|
-
|
|
220
|
+
任一条行为变更都需重新验证 design 的 D1 / D2 断言与验收场景(尤其 resume、fork 场景)。
|
|
160
221
|
|
|
161
222
|
## 开发
|
|
162
223
|
|
|
@@ -167,9 +228,9 @@ npx vitest run src/__tests__/<file>.test.ts # 单个文件
|
|
|
167
228
|
|
|
168
229
|
测试策略:
|
|
169
230
|
|
|
170
|
-
- **依赖反转**:`SchedulerRuntime` 只依赖 `SchedulerBackend` 接口(`sendMessage` / `
|
|
231
|
+
- **依赖反转**:`SchedulerRuntime` 只依赖 `SchedulerBackend` 接口(`sendMessage` / `appendEntry` / `now`),不触碰 FS/pi。测试注入 `MockSchedulerBackend`(`src/backend.ts` 同文件 export)实现零副作用测试
|
|
171
232
|
- **纯函数**:`parseDuration` / `formatDuration` / `parseSchedule` / `computeNextRunAt` / `computeNextRuns`(`src/parsing.ts`)无副作用,可直接断言
|
|
172
|
-
-
|
|
173
|
-
-
|
|
233
|
+
- **重放折叠**:custom entry 折叠协议(upsert / advance / toggle / delete,含 nextRunAt 重放恢复、fork owner 过滤)
|
|
234
|
+
- **旧 store 导入**:rename `.imported` 原子收敛(单成功者、崩溃恢复)
|
|
174
235
|
|
|
175
|
-
扩展内部结构:`backend.ts`(后端抽象)→ `runtime.ts`(调度核心)→ `service.ts`(业务入口)→ `tool.ts
|
|
236
|
+
扩展内部结构:`backend.ts`(后端抽象)→ `replay.ts`(custom entry 重放折叠)→ `runtime.ts`(调度核心)→ `service.ts`(业务入口)→ `tool.ts` / `commands.ts`(tool 与 /schedule 命令适配层)→ `widget.ts`(状态栏 widget)→ `importer.ts`(旧 store 导入)。
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"pi": {
|
|
7
7
|
"extensions": [
|
|
8
8
|
"./index.ts"
|
|
9
9
|
],
|
|
10
|
-
"skills": [
|
|
10
|
+
"skills": [
|
|
11
|
+
"./skills"
|
|
12
|
+
]
|
|
11
13
|
},
|
|
12
14
|
"keywords": [
|
|
13
15
|
"pi-package"
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
"files": [
|
|
20
22
|
"index.ts",
|
|
21
23
|
"src/**/*.ts",
|
|
24
|
+
"skills/",
|
|
22
25
|
"vitest.config.ts"
|
|
23
26
|
],
|
|
24
27
|
"peerDependencies": {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scheduler-ext-config
|
|
3
|
+
description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)时加载。说明任务创建/管理方式(/schedule 命令与 schedule 工具)、调度格式(interval duration 与 cron)、数据存储机制(session JSONL 的 append-only event sourcing,无独立配置文件)、旧版 store 迁移、运行限制。触发词:配置定时任务、scheduler 配置、定时调度、cron 任务、interval 任务、scheduler 存储、scheduler 数据在哪、定时任务排查、scheduler-ext-config。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# scheduler 使用与存储指南
|
|
7
|
+
|
|
8
|
+
> @zhushanwen/pi-scheduler:定时任务调度扩展。任务到期时向当前 session 注入一条 message(`deliverAs: 'followUp'` + `triggerTurn: true`),唤醒 agent 开新一轮 turn 处理。
|
|
9
|
+
|
|
10
|
+
**重要前提**:scheduler **没有独立的配置文件**。任务通过命令/工具交互创建,数据以 append-only event sourcing 方式存储在 session JSONL 中(见下文「数据存储位置」)。排查「任务存哪 / 为什么 resume 后任务变了 / fork 后任务是否继承」都必须基于此模型理解,不要去找独立的 `scheduler.json`(那是已废弃的旧版格式,仅迁移探测时使用)。
|
|
11
|
+
|
|
12
|
+
## 如何创建/管理定时任务
|
|
13
|
+
|
|
14
|
+
两条入口,底层都走 `SchedulerService`(单一业务实现,无双轨):
|
|
15
|
+
|
|
16
|
+
### 1. `/schedule` slash 命令(用户/AI 直接输入)
|
|
17
|
+
|
|
18
|
+
| 用法 | 作用 |
|
|
19
|
+
|------|------|
|
|
20
|
+
| `/schedule <schedule> <prompt>` | 创建 recurring 任务(默认) |
|
|
21
|
+
| `/schedule once <delay> <prompt>` | 创建一次性任务(执行一次后自动删除) |
|
|
22
|
+
| `/schedule cron '<cron表达式>' <prompt>` | 创建 cron 任务(**必须用引号**包裹,否则空格会被 tokenize 拆散) |
|
|
23
|
+
| `/schedule list` | 列出全部任务(按 nextRunAt 排序) |
|
|
24
|
+
| `/schedule on <id>` / `/schedule off <id>` | 启用 / 禁用某任务 |
|
|
25
|
+
| `/schedule rm <id>` | 删除某任务 |
|
|
26
|
+
| `/schedule run <id>` | 立即触发一次某任务 |
|
|
27
|
+
|
|
28
|
+
- 无参数 `/schedule`:当前返回「TUI 未实现」提示,用 `/schedule list` 查看任务。
|
|
29
|
+
- `on`/`off`/`rm`/`run` 的 `<id>` 支持命令补全(`getArgumentCompletions` 会列出 `id · name · schedule`)。
|
|
30
|
+
- cron 表达式含空格,**必须用单/双引号**包成一个 token,例:`/schedule cron '*/10 * * * *' 跑测试`。
|
|
31
|
+
|
|
32
|
+
### 2. `schedule` / `schedule_control` 工具(AI 调用)
|
|
33
|
+
|
|
34
|
+
- **`schedule`**(创建):参数 `prompt`(必填,到期注入的消息)、`schedule`(必填,duration 或 cron)、`kind`(`once`/`recurring`,默认 `recurring`)、`name`(可选,缺省从 prompt 自动截取前 30 字)、`expires`(可选,默认 7 天;传 `"never"` 关闭过期)、`force`(可选,默认 `false`)。
|
|
35
|
+
- **`schedule_control`**(管理):`action` = `list`/`toggle`/`delete`/`run`,`id`(toggle/delete/run 必填),`enabled`(toggle 必填)。
|
|
36
|
+
- 两个工具的返回都是结构化 `{content: [{type:'text', text}], details, isError?}`,业务失败返回 `isError:true` + `details.errorCode`(不抛异常)。
|
|
37
|
+
|
|
38
|
+
> 创建/管理操作无需 agent idle——只有**到期 dispatch** 才受 idle/速率限制约束(见「运行限制与 dispatch 行为」)。
|
|
39
|
+
|
|
40
|
+
## 调度格式
|
|
41
|
+
|
|
42
|
+
`parseSchedule` 的分流规则:**输入不含空格 → duration(interval 模式);含空格 → cron(cron 模式)**。
|
|
43
|
+
|
|
44
|
+
### interval(duration 字符串)
|
|
45
|
+
|
|
46
|
+
格式 `<数字><单位>`,单位不区分大小写、支持单复数:
|
|
47
|
+
|
|
48
|
+
| 单位 | 别名 | 毫秒 |
|
|
49
|
+
|------|------|------|
|
|
50
|
+
| `s` | `sec`/`second`/`seconds` | 1000 |
|
|
51
|
+
| `m` | `min`/`minute`/`minutes` | 60_000 |
|
|
52
|
+
| `h` | `hr`/`hour`/`hours` | 3_600_000 |
|
|
53
|
+
| `d` | `day`/`days` | 86_400_000 |
|
|
54
|
+
|
|
55
|
+
示例:`5m`、`2h`、`1d`、`30seconds`。正则 `/^(\d+)\s*(s|sec|...)$/i`,不匹配则解析失败。
|
|
56
|
+
|
|
57
|
+
### cron(cron 表达式)
|
|
58
|
+
|
|
59
|
+
- **5 字段**(分 时 日 月 周):自动在最前面补秒字段 `0`,变成 6 字段。例 `*/10 * * * *` → `0 */10 * * * *`(每 10 分钟)。
|
|
60
|
+
- **6 字段**(秒 分 时 日 月 周):原样使用。
|
|
61
|
+
- 其他字段数(<5 或 >6)视为无效。
|
|
62
|
+
- 底层用 `croner` 库(peerDependency,运行时动态 `import('croner')`;未安装时 cron 任务全部解析失败,interval 不受影响)。
|
|
63
|
+
- 创建时即校验表达式有效性(算不出下次执行时间 → `INVALID_SCHEDULE`);运行中表达式失效(极少见,如月份边界)→ 任务被停用并记 `lastError='cron expression invalid'`。
|
|
64
|
+
|
|
65
|
+
示例:`*/30 * * * *`(每 30 分)、`0 9 * * 1-5`(工作日早 9 点)、`0 0 * * *`(每天 0 点)。
|
|
66
|
+
|
|
67
|
+
## 数据存储位置(排查必读)
|
|
68
|
+
|
|
69
|
+
**当前版本采用 session JSONL 的 append-only event sourcing,没有独立数据文件。**
|
|
70
|
+
|
|
71
|
+
- 任务的所有变更以 custom entry 写入**创建该任务的 session 的 JSONL 文件**:
|
|
72
|
+
- 调用 `pi.appendEntry('pi-scheduler:task', op)`,`customType` 固定为 `pi-scheduler:task`。
|
|
73
|
+
- op 有四种:`upsert`(创建,携带全量 `TaskSnapshot`)、`advance`(recurring dispatch 成功后推进 `nextRunAt`)、`toggle`(启用/禁用)、`delete`(删除 / once 执行后 / 过期清理)。
|
|
74
|
+
- session 启动时(`session_start` 事件),`PiSchedulerBackend.loadTasks()` 调 `replayFoldEntries` 折叠当前 session 的全部 `pi-scheduler:task` custom entries,重放出当前任务状态。**append-only 不做全量 persist**——没有「保存」动作,每次操作即时 append。
|
|
75
|
+
- 因此「任务存哪」的答案是:**创建它的那个 session 的 JSONL 文件**。该文件位于 pi agent 目录下(`getAgentDir()` 读 `PI_CODING_AGENT_DIR`,默认 `~/.pi/agent`;xyz-agent 数据目录隔离时指向隔离目录如 `~/.xyz-agent/...`)。
|
|
76
|
+
|
|
77
|
+
### owner 隔离(fork 行为)
|
|
78
|
+
|
|
79
|
+
- 每个 `upsert` op 顶层带 `ownerSessionFile`(创建任务时所属 session 的 JSONL 路径)。
|
|
80
|
+
- `replayFoldEntries` 重放时**过滤掉 owner 不是当前 session 的任务**(防 fork/branch 继承导致同一逻辑任务跨 session 重复触发)。
|
|
81
|
+
- 含义:在 session A 创建的任务,fork 出 session B 后,B 的 replay 看不到 A 的任务(owner 不匹配)。任务「归属」于创建它的 session。
|
|
82
|
+
|
|
83
|
+
### 为什么找不到 `scheduler.json`
|
|
84
|
+
|
|
85
|
+
当前版本**不写** `scheduler.json`。如果你在文档或旧讨论里看到 `scheduler.json`,那是指**已废弃的旧版 store 格式**(npm 0.1.1 及更早),仅用于一次性迁移探测(见下文「旧版数据迁移」)。不要试图手动编辑或查找该文件来管理当前任务。
|
|
86
|
+
|
|
87
|
+
## 旧版数据迁移
|
|
88
|
+
|
|
89
|
+
旧版(npm ≤ 0.1.1)用独立 store 文件,按 **cwd 隔离**存储:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
<agentDir>/scheduler/<root>/<segments>/scheduler.json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `<agentDir>` = `getAgentDir()`(候选 1)或 `~/.pi/agent`(候选 2,旧版硬编码)。
|
|
96
|
+
- `<root>` = cwd 根盘符 sanitize:mac/linux 的 `/` → `root`;Windows `C:\` → `c`(非字母数字转 `-`,trim 首尾,小写)。
|
|
97
|
+
- `<segments>` = cwd 去根盘符后的路径段,按 `path.sep` 拆分。例 cwd `/Users/foo/project` → `Users/foo/project`。
|
|
98
|
+
- 完整示例(mac):`~/.pi/agent/scheduler/root/Users/foo/project/scheduler.json`。
|
|
99
|
+
|
|
100
|
+
**迁移机制**(`importLegacyStore`,session_start 时自动执行,无需用户介入):
|
|
101
|
+
|
|
102
|
+
1. 双候选探测:优先 `getAgentDir()` 路径,不存在则 fallback `~/.pi/agent/scheduler/...`(兼容 xyz-agent 数据目录隔离前的旧版写入位置)。
|
|
103
|
+
2. 原子 rename `scheduler.json` → `scheduler.json.imported` 独占迁移;rename 抛 ENOENT 说明并发/崩溃已被别人处理,走 `.imported` 残留恢复。
|
|
104
|
+
3. 读取 `.imported`,逐任务 `appendEntry('pi-scheduler:task', upsert)` 写入当前 session(owner 归属当前 session)。
|
|
105
|
+
4. 删除 `.imported`:**新 session 首次 flush 前(尚未收到 assistant 消息)延迟删除**,由首个 `turn_end` / `session_shutdown` 确认 flush 后再删(防未 flush 即退出导致任务永久丢失 + 源文件已毁)。
|
|
106
|
+
5. 迁移失败(read/parse/append 异常)整体降级:`console.warn` + 不阻断 session 启动,`.imported` 保留供下次重试。
|
|
107
|
+
|
|
108
|
+
迁移是一次性的:迁移完成后旧 `scheduler.json` 已被 rename 走并删除,后续 session 不再有旧格式数据。
|
|
109
|
+
|
|
110
|
+
## 运行限制与 dispatch 行为
|
|
111
|
+
|
|
112
|
+
| 限制 | 值 | 含义 |
|
|
113
|
+
|------|-----|------|
|
|
114
|
+
| 每 session 任务数上限 | 50(`MAX_TASKS`) | 超出创建报错 `Task limit reached` |
|
|
115
|
+
| dispatch 速率 | 6 次/分钟(`RATE_LIMIT_PER_MINUTE`) | 滑动窗口计数,超出则当前 tick 跳过、下个 tick 重试 |
|
|
116
|
+
| tick 间隔 | 30 秒(`TICK_INTERVAL_MS`) | 每 30 秒检查一次到期任务 |
|
|
117
|
+
| 默认过期 | 7 天(`DEFAULT_EXPIRY_MS`) | 仅 recurring;`expires="never"` 关闭 |
|
|
118
|
+
| 历史记录 | 最近 20 条(`HISTORY_LIMIT`) | 每任务的执行历史 |
|
|
119
|
+
|
|
120
|
+
dispatch 触发条件(`dispatchTask`):
|
|
121
|
+
|
|
122
|
+
- **非 force 任务**:仅在 `ctx.isIdle() && !ctx.hasPendingMessages()` 时触发;否则**延迟到下个 tick**(不丢弃,标记 `pending`,下个 30s tick 重试)。
|
|
123
|
+
- **force=true 任务**:即使 agent busy 也立即触发(用于必须准点执行的场景)。
|
|
124
|
+
- dispatch 成功后:recurring 推进 `nextRunAt` 并 append `advance`;once 删除任务并 append `delete`;失败(`sendMessage` 抛错)记 `lastStatus='failed'` 不 rethrow,下个 tick 重试(transient 失败重试语义,不 append advance)。
|
|
125
|
+
- 注入的消息:`{content: task.prompt, customType: 'pi-scheduler:dispatched', display: true}`,`deliverAs: 'followUp'` + `triggerTurn: true`(排进 followUp 队列并唤醒 agent 开新 turn)。
|
|
126
|
+
|
|
127
|
+
## 任务数据结构
|
|
128
|
+
|
|
129
|
+
`ScheduledTask`(内存态,`types.ts`)核心字段:
|
|
130
|
+
|
|
131
|
+
- `id`:8 位 hex,自动生成。
|
|
132
|
+
- `name`:可读名称(用户指定或从 prompt 自动截取前 30 字)。
|
|
133
|
+
- `prompt`:到期注入的 message 内容。
|
|
134
|
+
- `kind`:`once` | `recurring`。
|
|
135
|
+
- `schedule`:`{mode:'cron', cronExpression}` | `{mode:'interval', intervalMs}`。
|
|
136
|
+
- `enabled`:是否启用。
|
|
137
|
+
- `force`:是否在 agent busy 时强制 dispatch。
|
|
138
|
+
- `createdAt` / `nextRunAt` / `expiresAt?`:时间戳(ms)。
|
|
139
|
+
- `runCount` / `lastRunAt?` / `lastStatus?`(`success`|`failed`)/ `lastError?`:执行统计。
|
|
140
|
+
- `history`:最近 20 条 `ExecutionRecord`(`{at, status, snippet?}`,snippet 为 agent 回复前 100 字)。
|
|
141
|
+
- `ownerSessionFile?`:归属 session JSONL 路径(fork 过滤用,非持久化业务字段)。
|
|
142
|
+
- `pending?`:运行时标记「到期待 dispatch」,非持久化(与 `enabled` 正交)。
|
|
143
|
+
|
|
144
|
+
持久化写入 session JSONL 的是 `TaskSnapshot`(剥离 `ownerSessionFile`/`pending` 后的 15 字段)。
|
|
145
|
+
|
|
146
|
+
## 示例
|
|
147
|
+
|
|
148
|
+
创建一个每 5 分钟检查构建状态的任务:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
/schedule 5m 检查当前项目的构建状态,失败则报告原因
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
创建 2 小时后的一次性提醒:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
/schedule once 2h 提醒我 review 这个 PR
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
创建每 30 分钟跑测试的 cron 任务(注意引号):
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
/schedule cron '*/30 * * * *' 跑一次 vitest 并报告结果
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
创建工作日早 9 点的早会提醒(不过期、force):
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
/schedule cron '0 9 * * 1-5' 早会时间到了,总结昨天进展和今天计划
|
|
170
|
+
```
|
|
171
|
+
(如需 force + 不过期,用 `schedule` 工具传 `force:true, expires:"never"`,命令行暂未暴露这两个开关)
|
|
172
|
+
|
|
173
|
+
列出并禁用某任务:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
/schedule list
|
|
177
|
+
/schedule off abc12345
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
AI 通过工具创建(force + 永不过期):
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
schedule({ prompt: "...", schedule: "1h", kind: "recurring", force: true, expires: "never", name: " hourly-check" })
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## 备注
|
|
187
|
+
|
|
188
|
+
- **croner 依赖**:cron 模式依赖 `croner`(peerDependency)。未安装时所有 cron 任务解析失败(返回 `INVALID_SCHEDULE`),interval 任务不受影响。集成方(如 xyz-agent mandatory 安装)需确保 `croner` 可用。
|
|
189
|
+
- **数据目录隔离**:任务存储在 `getAgentDir()` 指向的 session JSONL。xyz-agent 通过 `XYZ_AGENT_DATA_DIR` / `PI_CODING_AGENT_DIR` 隔离实例时,任务随 session 落在隔离目录,与 `~/.pi/agent` 互不干扰。
|
|
190
|
+
- **无配置 schema 可编辑**:scheduler 的所有状态都由运行时命令产生,没有可手动编辑的配置文件。要「批量预置任务」只能在 session 内逐条创建(或迁移旧 store)。
|
|
191
|
+
- **TUI 管理器未实现**:无参 `/schedule` 当前只返回提示,任务管理请用 `list`/`on`/`off`/`rm`/`run` 子命令或 `schedule_control` 工具。
|
|
@@ -2,10 +2,28 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
|
|
3
3
|
import { MockSchedulerBackend } from '../backend.js'
|
|
4
4
|
import { SchedulerRuntime } from '../runtime.js'
|
|
5
|
-
import type {
|
|
5
|
+
import type { SchedulerEntryOp, TaskSnapshot } from '../types.js'
|
|
6
6
|
|
|
7
7
|
const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
|
|
8
8
|
|
|
9
|
+
/** 构造 base task 快照(upsert op 用)。 */
|
|
10
|
+
function snapshot(overrides: Partial<TaskSnapshot> = {}): TaskSnapshot {
|
|
11
|
+
return {
|
|
12
|
+
id: 'aaa',
|
|
13
|
+
name: 'test',
|
|
14
|
+
prompt: 'p',
|
|
15
|
+
kind: 'recurring',
|
|
16
|
+
schedule: { mode: 'interval', intervalMs: 60000 },
|
|
17
|
+
enabled: true,
|
|
18
|
+
force: false,
|
|
19
|
+
createdAt: 0,
|
|
20
|
+
nextRunAt: 100,
|
|
21
|
+
runCount: 0,
|
|
22
|
+
history: [],
|
|
23
|
+
...overrides,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
describe('MockSchedulerBackend', () => {
|
|
10
28
|
it('records sendMessage calls', async () => {
|
|
11
29
|
const backend = new MockSchedulerBackend()
|
|
@@ -22,17 +40,17 @@ describe('MockSchedulerBackend', () => {
|
|
|
22
40
|
expect(backend.sentMessages[0]!.opts).toEqual({ deliverAs: 'followUp', triggerTurn: true })
|
|
23
41
|
})
|
|
24
42
|
|
|
25
|
-
it('records
|
|
43
|
+
it('records appendEntry calls and throws injected appendError', () => {
|
|
26
44
|
const backend = new MockSchedulerBackend()
|
|
27
|
-
const
|
|
45
|
+
const op: SchedulerEntryOp = { op: 'delete', taskId: 'aaa' }
|
|
28
46
|
|
|
29
|
-
|
|
30
|
-
expect(backend.
|
|
31
|
-
expect(backend.
|
|
47
|
+
backend.appendEntry(op)
|
|
48
|
+
expect(backend.appendedOps).toHaveLength(1)
|
|
49
|
+
expect(backend.appendedOps[0]).toBe(op)
|
|
32
50
|
|
|
33
|
-
//
|
|
34
|
-
backend.
|
|
35
|
-
|
|
51
|
+
// appendError 注入:appendEntry 抛该错(ER-APPEND-FAIL 语义——错误必须能传到调用栈供 runtime 捕获)
|
|
52
|
+
backend.appendError = new Error('pi internal')
|
|
53
|
+
expect(() => backend.appendEntry(op)).toThrow('pi internal')
|
|
36
54
|
})
|
|
37
55
|
|
|
38
56
|
it('now() returns injected nowValue or Date.now()', () => {
|
|
@@ -42,6 +60,44 @@ describe('MockSchedulerBackend', () => {
|
|
|
42
60
|
expect(backend.now()).toBe(123456)
|
|
43
61
|
})
|
|
44
62
|
|
|
63
|
+
// ── TC-W-BACKEND-REPLAY:loadTasks 委托 replayFoldEntries(IF-BACKEND-REPLAY)──
|
|
64
|
+
it('TC-W-BACKEND-REPLAY: loadTasks 经 replayFoldEntries 恢复 owner 匹配的任务', () => {
|
|
65
|
+
const backend = new MockSchedulerBackend()
|
|
66
|
+
backend.fakeSessionFile = '/a.json'
|
|
67
|
+
backend.fakeEntries = [
|
|
68
|
+
// 非 scheduler entry:应被折叠忽略
|
|
69
|
+
{ type: 'message', data: {} },
|
|
70
|
+
{ type: 'custom', customType: 'other-ext', data: {} },
|
|
71
|
+
// owner 匹配的 upsert
|
|
72
|
+
{
|
|
73
|
+
type: 'custom',
|
|
74
|
+
customType: 'pi-scheduler:task',
|
|
75
|
+
data: { op: 'upsert', taskId: 'X', ownerSessionFile: '/a.json', task: snapshot({ id: 'X' }) },
|
|
76
|
+
},
|
|
77
|
+
// owner 不匹配的 upsert(fork 继承):应被过滤
|
|
78
|
+
{
|
|
79
|
+
type: 'custom',
|
|
80
|
+
customType: 'pi-scheduler:task',
|
|
81
|
+
data: { op: 'upsert', taskId: 'Y', ownerSessionFile: '/other.json', task: snapshot({ id: 'Y' }) },
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const tasks = backend.loadTasks()
|
|
86
|
+
|
|
87
|
+
expect(tasks).toHaveLength(1)
|
|
88
|
+
expect(tasks[0]!.id).toBe('X')
|
|
89
|
+
expect(tasks[0]!.ownerSessionFile).toBe('/a.json')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('TC-W-BACKEND-REPLAY: getSessionFile 返回 fakeSessionFile(缺省值)', () => {
|
|
93
|
+
const backend = new MockSchedulerBackend()
|
|
94
|
+
expect(backend.getSessionFile()).toBe('/test/session.json')
|
|
95
|
+
backend.fakeSessionFile = '/custom.json'
|
|
96
|
+
expect(backend.getSessionFile()).toBe('/custom.json')
|
|
97
|
+
backend.fakeSessionFile = undefined
|
|
98
|
+
expect(backend.getSessionFile()).toBeUndefined()
|
|
99
|
+
})
|
|
100
|
+
|
|
45
101
|
// ── TC2:new SchedulerRuntime(mockBackend) 可注入单测,零 FS ──
|
|
46
102
|
|
|
47
103
|
it('TC2: SchedulerRuntime with MockSchedulerBackend constructs and dispatches via mock', async () => {
|
|
@@ -50,8 +106,9 @@ describe('MockSchedulerBackend', () => {
|
|
|
50
106
|
const runtime = new SchedulerRuntime(backend, mockCtx)
|
|
51
107
|
const task = await runtime.addTask('probe', { mode: 'interval', intervalMs: 60000 })
|
|
52
108
|
expect(task).toBeDefined()
|
|
53
|
-
// addTask
|
|
54
|
-
expect(backend.
|
|
109
|
+
// addTask 后 append upsert op(append-only,零 FS)
|
|
110
|
+
expect(backend.appendedOps).toHaveLength(1)
|
|
111
|
+
expect(backend.appendedOps[0]!.op).toBe('upsert')
|
|
55
112
|
|
|
56
113
|
await runtime.dispatchTask(task)
|
|
57
114
|
|
|
@@ -25,8 +25,10 @@ describe('/schedule command', () => {
|
|
|
25
25
|
commandOpts = opts
|
|
26
26
|
},
|
|
27
27
|
}
|
|
28
|
+
const backend = new MockSchedulerBackend()
|
|
28
29
|
service = new SchedulerService(
|
|
29
|
-
new SchedulerRuntime(
|
|
30
|
+
new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false }),
|
|
31
|
+
() => backend.now(),
|
|
30
32
|
)
|
|
31
33
|
registerScheduleCommand(mockPi as never, () => service)
|
|
32
34
|
})
|