@zhushanwen/pi-scheduler 0.4.3 → 0.5.1
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 +4 -4
- package/package.json +3 -3
- package/skills/scheduler-ext-config/SKILL.md +4 -4
- package/src/__tests__/commands.test.ts +11 -0
- package/src/__tests__/cron.test.ts +2 -3
- package/src/__tests__/parsing.test.ts +2 -7
- package/src/__tests__/replay.test.ts +14 -0
- package/src/__tests__/service.test.ts +1 -1
- package/src/commands.ts +71 -36
- package/src/format.ts +1 -5
- package/src/parsing.ts +6 -9
- package/src/replay.ts +97 -59
- package/src/runtime.ts +0 -6
- package/src/service.ts +3 -2
- package/src/tool.ts +2 -2
- package/src/types.ts +0 -1
- package/vitest.config.ts +2 -0
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ session_start
|
|
|
57
57
|
|
|
58
58
|
## /schedule 命令用法
|
|
59
59
|
|
|
60
|
-
注册为 `/schedule`
|
|
60
|
+
注册为 `/schedule` 命令。无参数时返回 TUI 未实现提示(用 `/schedule list` 查看任务);第一个参数匹配子命令关键词则走子命令分支,否则尝试创建任务。
|
|
61
61
|
|
|
62
62
|
### 子命令
|
|
63
63
|
|
|
@@ -90,7 +90,7 @@ session_start
|
|
|
90
90
|
|
|
91
91
|
### duration(间隔调度)
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
`<数字><单位>`,数字与单位间不留空格,**大小写不敏感**:
|
|
94
94
|
|
|
95
95
|
| 单位 | 含义 | 乘数 |
|
|
96
96
|
|------|------|------|
|
|
@@ -163,14 +163,14 @@ session_start
|
|
|
163
163
|
| 默认过期 | **7 天**(`DEFAULT_EXPIRY_MS`) | recurring 任务缺省 `expires` 时;`expires: 'never'` 关闭 |
|
|
164
164
|
| once 任务 | 触发后自动删除 | 不参与后续调度 |
|
|
165
165
|
| cron 失效 | 任务停用 + `lastStatus=failed` + `lastError='cron expression invalid'` | 不会用 `now()` 兜底导致每 tick 重触发死循环 |
|
|
166
|
-
| 忙时 dispatch | 非 force
|
|
166
|
+
| 忙时 dispatch | 非 force 任务经 session delivery 内核 park 模式:忙时消息入队等待(等 agent 空闲的 settled 边沿 / 下个 tick flush 投递),不丢弃 | force=true 绕过内核队列直投 |
|
|
167
167
|
| history | 保留最近 **20** 条执行记录 | 超出丢弃最旧;重放折叠时同样裁剪 |
|
|
168
168
|
| 持久化 | custom entry append 到 session JSONL(dispatch 成功后立即 append advance 记录执行) | 任务随 owner session 持久化,resume 后重放恢复,无需额外写盘 |
|
|
169
169
|
| 交付语义 | **at-least-once**(至少一次) | dispatch 成功后内存更新 nextRunAt 并 append advance;append 之前若进程崩溃可能重复注入一次(无精确一次保证,可接受) |
|
|
170
170
|
| 延迟写入窗口 | 新 session 首 turn 内建任务后进程崩溃可能丢失 | pi 延迟写入:首条 assistant 消息前不 flush。窗口窄、概率极低、无恢复手段 |
|
|
171
171
|
| 触发条件 | pi 进程需存活且 session 打开 | 电脑睡眠 / pi 进程未运行 = 不触发(非系统 cron,无后台守护) |
|
|
172
172
|
|
|
173
|
-
错误语义:创建时 schedule 解析失败 → `INVALID_SCHEDULE`;`run`/`toggle`/`delete` 引用不存在的 id → `TASK_NOT_FOUND`;`run` 时任务 disabled /
|
|
173
|
+
错误语义:创建时 schedule 解析失败 → `INVALID_SCHEDULE`;`run`/`toggle`/`delete` 引用不存在的 id → `TASK_NOT_FOUND`;`run` 时任务 disabled / rate-limited / 同任务已在投递队列(TTL 窗口内)→ `DISPATCH_SKIPPED`(message 含 `disabled, rate-limited, or already queued for delivery`)。
|
|
174
174
|
|
|
175
175
|
## 数据存储位置
|
|
176
176
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"xyz-agent": {
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@xyz-agent/session-delivery": "0.3.
|
|
49
|
-
"@zhushanwen/pi-extension-logger": "0.
|
|
48
|
+
"@xyz-agent/session-delivery": "0.3.1",
|
|
49
|
+
"@zhushanwen/pi-extension-logger": "0.4.1"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"test": "vitest run",
|
|
@@ -33,7 +33,7 @@ description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)
|
|
|
33
33
|
|
|
34
34
|
- **`schedule`**(创建):参数 `prompt`(必填,到期注入的消息)、`schedule`(必填,duration 或 cron)、`kind`(`once`/`recurring`,默认 `recurring`)、`name`(可选,缺省从 prompt 自动截取前 30 字)、`expires`(可选,默认 7 天;传 `"never"` 关闭过期)、`force`(可选,默认 `false`)。
|
|
35
35
|
- **`schedule_control`**(管理):`action` = `list`/`toggle`/`delete`/`run`,`id`(toggle/delete/run 必填),`enabled`(toggle 必填)。
|
|
36
|
-
- 两个工具的返回都是结构化 `{content: [{type:'text', text}], details
|
|
36
|
+
- 两个工具的返回都是结构化 `{content: [{type:'text', text}], details}`;业务失败以异常抛出(pi 只对 execute throw 置 `isError:true`,错误 message 作为 toolResult content 返回),不通过返回值表达失败。
|
|
37
37
|
|
|
38
38
|
> 创建/管理操作无需 agent idle——只有**到期 dispatch** 才受 idle/速率限制约束(见「运行限制与 dispatch 行为」)。
|
|
39
39
|
|
|
@@ -52,7 +52,7 @@ description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)
|
|
|
52
52
|
| `h` | `hr`/`hour`/`hours` | 3_600_000 |
|
|
53
53
|
| `d` | `day`/`days` | 86_400_000 |
|
|
54
54
|
|
|
55
|
-
示例:`5m`、`2h`、`1d`、`30seconds`。正则 `/^(\d+)\s*(s|sec|...)$/i
|
|
55
|
+
示例:`5m`、`2h`、`1d`、`30seconds`。正则 `/^(\d+)\s*(s|sec|...)$/i`(注意:含空格的输入在 parseSchedule 分流时一律走 cron 分支,duration 实际不允许空格,`5 m` 解析失败)。
|
|
56
56
|
|
|
57
57
|
### cron(cron 表达式)
|
|
58
58
|
|
|
@@ -119,7 +119,7 @@ description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)
|
|
|
119
119
|
|
|
120
120
|
dispatch 触发条件(`dispatchTask`):
|
|
121
121
|
|
|
122
|
-
- **非 force
|
|
122
|
+
- **非 force 任务**:走统一 session delivery 内核(park 模式)——到期即入队,agent 忙时消息 park 在内核队列,等 agent 空闲的 settled 边沿投递,scheduler 每 30s tick 触发一次 flush 兜底重试;不丢弃。
|
|
123
123
|
- **force=true 任务**:即使 agent busy 也立即触发(用于必须准点执行的场景)。
|
|
124
124
|
- dispatch 成功后:recurring 推进 `nextRunAt` 并 append `advance`;once 删除任务并 append `delete`;失败(`sendMessage` 抛错)记 `lastStatus='failed'` 不 rethrow,下个 tick 重试(transient 失败重试语义,不 append advance)。
|
|
125
125
|
- 注入的消息:`{content: task.prompt, customType: 'pi-scheduler:dispatched', display: true}`,`deliverAs: 'followUp'` + `triggerTurn: true`(排进 followUp 队列并唤醒 agent 开新 turn)。
|
|
@@ -137,7 +137,7 @@ dispatch 触发条件(`dispatchTask`):
|
|
|
137
137
|
- `force`:是否在 agent busy 时强制 dispatch。
|
|
138
138
|
- `createdAt` / `nextRunAt` / `expiresAt?`:时间戳(ms)。
|
|
139
139
|
- `runCount` / `lastRunAt?` / `lastStatus?`(`success`|`failed`)/ `lastError?`:执行统计。
|
|
140
|
-
- `history`:最近 20 条 `ExecutionRecord`(`{at, status
|
|
140
|
+
- `history`:最近 20 条 `ExecutionRecord`(`{at, status}`)。
|
|
141
141
|
- `ownerSessionFile?`:归属 session JSONL 路径(fork 过滤用,非持久化业务字段)。
|
|
142
142
|
- `pending?`:运行时标记「到期待 dispatch」,非持久化(与 `enabled` 正交)。
|
|
143
143
|
|
|
@@ -181,6 +181,17 @@ describe('/schedule command', () => {
|
|
|
181
181
|
expect(result).toBe('Usage: /schedule <schedule> <prompt>')
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
+
it('treats prototype-chain key names as unknown (Map lookup, no Object.prototype hit)', async () => {
|
|
185
|
+
// 路由表用 Map 查找的语义锚:普通对象下标会把 "constructor"/"toString"/"__proto__"
|
|
186
|
+
// 解析到 Object.prototype(constructor 被当 handler 调用返回对象、__proto__ 直接 TypeError),
|
|
187
|
+
// 这里必须与其它未知子命令同走创建任务分支(无 schedule 输入 → usage 文案)。
|
|
188
|
+
for (const key of ['constructor', 'toString', '__proto__']) {
|
|
189
|
+
expect(await executeScheduleCommand(service, key)).toBe(
|
|
190
|
+
'Usage: /schedule <schedule> <prompt>',
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
184
195
|
it('no args returns TUI not-implemented message', async () => {
|
|
185
196
|
const result = await executeScheduleCommand(service, '')
|
|
186
197
|
expect(result).toContain('not yet implemented')
|
|
@@ -58,7 +58,7 @@ describe('computeNextCronRuns', () => {
|
|
|
58
58
|
})
|
|
59
59
|
|
|
60
60
|
describe('parseSchedule (cron 分支)', () => {
|
|
61
|
-
it('5 字段 cron
|
|
61
|
+
it('5 字段 cron 表达式补秒字段', async () => {
|
|
62
62
|
const result = await parseSchedule('*/10 * * * *')
|
|
63
63
|
expect(result).toEqual({
|
|
64
64
|
spec: { mode: 'cron', cronExpression: '0 */10 * * * *' },
|
|
@@ -85,8 +85,7 @@ describe('parseSchedule (cron 分支)', () => {
|
|
|
85
85
|
|
|
86
86
|
describe('normalizeCronExpression', () => {
|
|
87
87
|
it('6 字段 cron 原样返回', () => {
|
|
88
|
-
|
|
89
|
-
expect(result).toEqual({ expression: '0 9 * * 1-5 0' })
|
|
88
|
+
expect(normalizeCronExpression('0 9 * * 1-5 0')).toBe('0 9 * * 1-5 0')
|
|
90
89
|
})
|
|
91
90
|
|
|
92
91
|
it('2 字段返回 undefined', () => {
|
|
@@ -84,16 +84,11 @@ describe('formatDuration', () => {
|
|
|
84
84
|
|
|
85
85
|
describe('normalizeCronExpression', () => {
|
|
86
86
|
it('prepends seconds field for 5-field cron', () => {
|
|
87
|
-
|
|
88
|
-
expect(result).toEqual({
|
|
89
|
-
expression: '0 */10 * * * *',
|
|
90
|
-
note: 'Auto-prepended seconds field (0)',
|
|
91
|
-
})
|
|
87
|
+
expect(normalizeCronExpression('*/10 * * * *')).toBe('0 */10 * * * *')
|
|
92
88
|
})
|
|
93
89
|
|
|
94
90
|
it('keeps 6-field cron as-is', () => {
|
|
95
|
-
|
|
96
|
-
expect(result).toEqual({ expression: '0 */10 * * * *' })
|
|
91
|
+
expect(normalizeCronExpression('0 */10 * * * *')).toBe('0 */10 * * * *')
|
|
97
92
|
})
|
|
98
93
|
|
|
99
94
|
it('returns undefined for invalid field count', () => {
|
|
@@ -310,6 +310,20 @@ describe('replayFoldEntries', () => {
|
|
|
310
310
|
expect(msg).toContain('skipping corrupted scheduler entry')
|
|
311
311
|
})
|
|
312
312
|
|
|
313
|
+
// ── fork 场景安全:未知 taskId 的 advance/toggle 为静默 no-op,不触发逐条 catch ──
|
|
314
|
+
it('advance/toggle 对不存在的 taskId 为 no-op(无 upsert 时不产生副作用、不触发逐条 catch warn)', () => {
|
|
315
|
+
loggerMock.warn.mockClear()
|
|
316
|
+
const entries = [
|
|
317
|
+
entry({ op: 'advance', taskId: 'ghost', nextRunAt: 200, at: 100, status: 'success' }),
|
|
318
|
+
entry({ op: 'toggle', taskId: 'ghost', enabled: false }),
|
|
319
|
+
entry({ op: 'delete', taskId: 'ghost' }),
|
|
320
|
+
]
|
|
321
|
+
const result = replayFoldEntries(entries, '/s.json')
|
|
322
|
+
expect(result.size).toBe(0)
|
|
323
|
+
// no-op 提前返回路径不抛错——MF-2 的逐条跳过 warn 不应被触发
|
|
324
|
+
expect(loggerMock.warn).not.toHaveBeenCalled()
|
|
325
|
+
})
|
|
326
|
+
|
|
313
327
|
afterEach(() => {
|
|
314
328
|
vi.restoreAllMocks()
|
|
315
329
|
})
|
|
@@ -191,7 +191,7 @@ describe('SchedulerService', () => {
|
|
|
191
191
|
expect(result).toEqual({
|
|
192
192
|
success: false,
|
|
193
193
|
errorCode: 'DISPATCH_SKIPPED',
|
|
194
|
-
message: `Task ${id} not dispatched (
|
|
194
|
+
message: `Task ${id} not dispatched (disabled, rate-limited, or already queued for delivery).`,
|
|
195
195
|
})
|
|
196
196
|
})
|
|
197
197
|
|
package/src/commands.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function registerScheduleCommand(
|
|
|
48
48
|
getService: () => SchedulerService | null,
|
|
49
49
|
) {
|
|
50
50
|
pi.registerCommand('schedule', {
|
|
51
|
-
description: 'Manage scheduled tasks.
|
|
51
|
+
description: 'Manage scheduled tasks. /schedule <schedule> <prompt> to create; /schedule list to see tasks.',
|
|
52
52
|
getArgumentCompletions(prefix: string) {
|
|
53
53
|
const service = getService()
|
|
54
54
|
const trimmed = prefix.trimStart()
|
|
@@ -84,11 +84,77 @@ export function registerScheduleCommand(
|
|
|
84
84
|
})
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** 单个子命令处理器。service 非空由 executeScheduleCommand 前置保证;parts 为 tokenize 后参数。 */
|
|
88
|
+
type SubcommandHandler = (service: SchedulerService, parts: string[]) => string | Promise<string>
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* on/off 共享:取 <id>,缺失返回 usage(文案与原 ${first} 一致——handler 仅在
|
|
92
|
+
* first === 'on' | 'off' 时被查表命中,keyword 即 first 的小写值)。
|
|
93
|
+
*/
|
|
94
|
+
async function handleToggleKeyword(
|
|
95
|
+
service: SchedulerService,
|
|
96
|
+
parts: string[],
|
|
97
|
+
keyword: 'on' | 'off',
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const id = parts[1]
|
|
100
|
+
if (!id) return `Usage: /schedule ${keyword} <id>`
|
|
101
|
+
const result = await service.toggle(id, keyword === 'on')
|
|
102
|
+
return result.message
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 子命令路由表。查表未命中(落空)= 创建任务分支,与原 if 链的顺序语义一致。
|
|
107
|
+
* list/rm 保持同步返回、run 保持 await——不改变 async 边界内的微任务时序。
|
|
108
|
+
* 用 Map 而非普通对象:first 是外部输入(/schedule 命令参数),普通对象查表会命中
|
|
109
|
+
* Object.prototype 上的键(如 "constructor"/"__proto__"),导致错误路由;Map 只查自身键。
|
|
110
|
+
*/
|
|
111
|
+
const SUBCOMMAND_HANDLERS: ReadonlyMap<string, SubcommandHandler> = new Map<string, SubcommandHandler>([
|
|
112
|
+
['list', service => service.list().message],
|
|
113
|
+
['on', (service, parts) => handleToggleKeyword(service, parts, 'on')],
|
|
114
|
+
['off', (service, parts) => handleToggleKeyword(service, parts, 'off')],
|
|
115
|
+
[
|
|
116
|
+
'rm',
|
|
117
|
+
(service, parts) => {
|
|
118
|
+
const id = parts[1]
|
|
119
|
+
if (!id) return 'Usage: /schedule rm <id>'
|
|
120
|
+
return service.delete(id).message
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
[
|
|
124
|
+
'run',
|
|
125
|
+
async (service, parts) => {
|
|
126
|
+
const id = parts[1]
|
|
127
|
+
if (!id) return 'Usage: /schedule run <id>'
|
|
128
|
+
const result = await service.run(id)
|
|
129
|
+
return result.message
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
/** 创建任务分支(路由落空):once 前缀 → once 任务;cron 前缀 → recurring cron 任务;其余按 interval schedule 解析。 */
|
|
135
|
+
async function createTaskFromArgs(
|
|
136
|
+
service: SchedulerService,
|
|
137
|
+
parts: string[],
|
|
138
|
+
first: string,
|
|
139
|
+
): Promise<string> {
|
|
140
|
+
const kind = first === 'once' ? 'once' as const : first === 'cron' ? 'recurring' as const : undefined
|
|
141
|
+
const scheduleStart = kind ? 1 : 0
|
|
142
|
+
const scheduleInput = parts[scheduleStart]
|
|
143
|
+
if (!scheduleInput) return 'Usage: /schedule <schedule> <prompt>'
|
|
144
|
+
|
|
145
|
+
const prompt = parts.slice(scheduleStart + 1).join(' ')
|
|
146
|
+
if (!prompt) return 'Usage: /schedule <schedule> <prompt>'
|
|
147
|
+
|
|
148
|
+
const result = await service.create(prompt, scheduleInput, { kind })
|
|
149
|
+
return result.message
|
|
150
|
+
}
|
|
151
|
+
|
|
87
152
|
/**
|
|
88
153
|
* Core logic for /schedule command. Extracted for testability (handler returns
|
|
89
154
|
* void per SDK contract; tests call this function directly to assert output).
|
|
90
155
|
*
|
|
91
156
|
* 命令层只保留参数路由与 usage 文案(C6),业务调用全部走 SchedulerService。
|
|
157
|
+
* 主函数只留编排:service 守卫 → 空 args → tokenize → 查表分发 → 创建分支。
|
|
92
158
|
*/
|
|
93
159
|
export async function executeScheduleCommand(
|
|
94
160
|
service: SchedulerService | null,
|
|
@@ -105,40 +171,9 @@ export async function executeScheduleCommand(
|
|
|
105
171
|
const parts = tokenizeQuoted(trimmed)
|
|
106
172
|
const first = parts[0]!.toLowerCase()
|
|
107
173
|
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (first === 'on' || first === 'off') {
|
|
114
|
-
const id = parts[1]
|
|
115
|
-
if (!id) return `Usage: /schedule ${first} <id>`
|
|
116
|
-
const result = await service.toggle(id, first === 'on')
|
|
117
|
-
return result.message
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (first === 'rm') {
|
|
121
|
-
const id = parts[1]
|
|
122
|
-
if (!id) return 'Usage: /schedule rm <id>'
|
|
123
|
-
return service.delete(id).message
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (first === 'run') {
|
|
127
|
-
const id = parts[1]
|
|
128
|
-
if (!id) return 'Usage: /schedule run <id>'
|
|
129
|
-
const result = await service.run(id)
|
|
130
|
-
return result.message
|
|
131
|
-
}
|
|
174
|
+
// 子命令路由:查表命中走对应分支,落空走创建任务分支
|
|
175
|
+
const handler = SUBCOMMAND_HANDLERS.get(first)
|
|
176
|
+
if (handler) return handler(service, parts)
|
|
132
177
|
|
|
133
|
-
|
|
134
|
-
const kind = first === 'once' ? 'once' as const : first === 'cron' ? 'recurring' as const : undefined
|
|
135
|
-
const scheduleStart = kind ? 1 : 0
|
|
136
|
-
const scheduleInput = parts[scheduleStart]
|
|
137
|
-
if (!scheduleInput) return 'Usage: /schedule <schedule> <prompt>'
|
|
138
|
-
|
|
139
|
-
const prompt = parts.slice(scheduleStart + 1).join(' ')
|
|
140
|
-
if (!prompt) return 'Usage: /schedule <schedule> <prompt>'
|
|
141
|
-
|
|
142
|
-
const result = await service.create(prompt, scheduleInput, { kind })
|
|
143
|
-
return result.message
|
|
178
|
+
return createTaskFromArgs(service, parts, first)
|
|
144
179
|
}
|
package/src/format.ts
CHANGED
|
@@ -82,14 +82,10 @@ export function generateTaskId(): string {
|
|
|
82
82
|
|
|
83
83
|
/** autoName 任务名最大长度。 */
|
|
84
84
|
const AUTO_NAME_MAX_LENGTH = 30
|
|
85
|
-
/** 截断后保留的长度(预留省略号宽度)。 */
|
|
86
|
-
const AUTO_NAME_KEEP_LENGTH = AUTO_NAME_MAX_LENGTH - ELLIPSIS_LENGTH
|
|
87
85
|
|
|
88
86
|
/**
|
|
89
87
|
* 从 prompt 自动生成任务名称:取前 30 字。
|
|
90
88
|
*/
|
|
91
89
|
export function autoName(prompt: string): string {
|
|
92
|
-
|
|
93
|
-
if (trimmed.length <= AUTO_NAME_MAX_LENGTH) return trimmed
|
|
94
|
-
return trimmed.slice(0, AUTO_NAME_KEEP_LENGTH) + '...'
|
|
90
|
+
return truncate(prompt.trim(), AUTO_NAME_MAX_LENGTH)
|
|
95
91
|
}
|
package/src/parsing.ts
CHANGED
|
@@ -82,7 +82,7 @@ async function getCroner(): Promise<typeof import('croner') | null> {
|
|
|
82
82
|
* 规范化 cron 表达式:5 字段自动补秒字段。
|
|
83
83
|
* 返回 undefined 表示无效。
|
|
84
84
|
*/
|
|
85
|
-
export function normalizeCronExpression(input: string):
|
|
85
|
+
export function normalizeCronExpression(input: string): string | undefined {
|
|
86
86
|
const trimmed = input.trim()
|
|
87
87
|
if (!trimmed) return undefined
|
|
88
88
|
|
|
@@ -90,15 +90,12 @@ export function normalizeCronExpression(input: string): { expression: string; no
|
|
|
90
90
|
|
|
91
91
|
// 6 字段原样返回
|
|
92
92
|
if (parts.length === CRON_FIELD_COUNT_WITH_SECONDS) {
|
|
93
|
-
return
|
|
93
|
+
return trimmed
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// 5 字段补秒字段
|
|
97
97
|
if (parts.length === CRON_FIELD_COUNT) {
|
|
98
|
-
return {
|
|
99
|
-
expression: `0 ${trimmed}`,
|
|
100
|
-
note: 'Auto-prepended seconds field (0)',
|
|
101
|
-
}
|
|
98
|
+
return `0 ${trimmed}`
|
|
102
99
|
}
|
|
103
100
|
|
|
104
101
|
return undefined
|
|
@@ -119,7 +116,7 @@ export async function computeNextCronRunAt(
|
|
|
119
116
|
const normalized = normalizeCronExpression(expression)
|
|
120
117
|
if (!normalized) return undefined
|
|
121
118
|
|
|
122
|
-
const job = new croner.Cron(normalized
|
|
119
|
+
const job = new croner.Cron(normalized, { startAt: from ? new Date(from) : undefined })
|
|
123
120
|
const next = job.nextRun()
|
|
124
121
|
return next ? next.getTime() : undefined
|
|
125
122
|
} catch {
|
|
@@ -143,7 +140,7 @@ export async function computeNextCronRuns(
|
|
|
143
140
|
const normalized = normalizeCronExpression(expression)
|
|
144
141
|
if (!normalized) return []
|
|
145
142
|
|
|
146
|
-
const job = new croner.Cron(normalized
|
|
143
|
+
const job = new croner.Cron(normalized, { startAt: from ? new Date(from) : undefined })
|
|
147
144
|
const runs: number[] = []
|
|
148
145
|
let current = from ? new Date(from) : new Date()
|
|
149
146
|
|
|
@@ -192,7 +189,7 @@ export async function parseSchedule(
|
|
|
192
189
|
const nextRun = await computeNextCronRunAt(trimmed)
|
|
193
190
|
if (nextRun !== undefined) {
|
|
194
191
|
return {
|
|
195
|
-
spec: { mode: 'cron', cronExpression: normalized
|
|
192
|
+
spec: { mode: 'cron', cronExpression: normalized },
|
|
196
193
|
}
|
|
197
194
|
}
|
|
198
195
|
}
|
package/src/replay.ts
CHANGED
|
@@ -24,16 +24,16 @@ const HISTORY_LIMIT = 20
|
|
|
24
24
|
* pi-scheduler:task custom entries 折叠成运行时任务 Map。
|
|
25
25
|
*
|
|
26
26
|
* 两步:
|
|
27
|
-
* ①
|
|
27
|
+
* ① 全量折叠(foldEntries)——遍历 customType==='pi-scheduler:task' 的 entries,按 op 类型折叠:
|
|
28
28
|
* - upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)
|
|
29
29
|
* - advance → nextRunAt=新值 / lastRunAt=at / runCount++ / history.push(裁20) / lastStatus=status(gap2)
|
|
30
30
|
* - toggle → enabled=新值
|
|
31
31
|
* - delete → 从 Map 移除(后续同 taskId 的 advance/toggle 对不存在 taskId 为 no-op,安全)
|
|
32
32
|
* advance/toggle/delete 对不存在的 taskId 为 no-op,因此 fork 继承的完整序列必须先全量折叠,
|
|
33
33
|
* 不能在处理 upsert 时按 owner 跳过(否则后续无 owner 字段的 op 会作用于「不存在的 taskId」丢失)。
|
|
34
|
-
* ② 折叠完成后按 task.ownerSessionFile !== currentSessionFile 整体过滤(gap1
|
|
35
|
-
* 继承的非 owner 任务——fork 出的 session B 继承了 owner=A 的 upsert+advance
|
|
36
|
-
* 不应拥有 A 的任务,重放后整体过滤掉。
|
|
34
|
+
* ② 折叠完成后按 task.ownerSessionFile !== currentSessionFile 整体过滤(gap1,filterNonOwnerTasks):
|
|
35
|
+
* 移除 fork 继承的非 owner 任务——fork 出的 session B 继承了 owner=A 的 upsert+advance 序列,
|
|
36
|
+
* 但 B 不应拥有 A 的任务,重放后整体过滤掉。
|
|
37
37
|
*
|
|
38
38
|
* append-only 时序保证 nextRunAt 不回退(D1):advance entry 按写入顺序折叠,自然取到最后推进值。
|
|
39
39
|
*
|
|
@@ -45,61 +45,8 @@ export function replayFoldEntries(
|
|
|
45
45
|
): Map<string, ScheduledTask> {
|
|
46
46
|
try {
|
|
47
47
|
const tasks = new Map<string, ScheduledTask>()
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (entry.type !== 'custom' || entry.customType !== 'pi-scheduler:task') continue
|
|
51
|
-
if (!isSchedulerEntryOp(entry.data)) continue
|
|
52
|
-
const op = entry.data // SchedulerEntryOp(守卫 isSchedulerEntryOp 收窄)
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
switch (op.op) {
|
|
56
|
-
case 'upsert': {
|
|
57
|
-
tasks.set(op.taskId, { ...snapshotToTask(op.task), ownerSessionFile: op.ownerSessionFile })
|
|
58
|
-
break
|
|
59
|
-
}
|
|
60
|
-
case 'advance': {
|
|
61
|
-
const task = tasks.get(op.taskId)
|
|
62
|
-
if (!task) break // no-op for unknown taskId(fork 场景安全)
|
|
63
|
-
task.nextRunAt = op.nextRunAt
|
|
64
|
-
task.lastRunAt = op.at
|
|
65
|
-
task.runCount += 1
|
|
66
|
-
task.history.push({ at: op.at, status: op.status })
|
|
67
|
-
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
68
|
-
task.lastStatus = op.status // gap2:advance 必须恢复 lastStatus(不只 nextRunAt/lastRunAt/runCount/history)
|
|
69
|
-
break
|
|
70
|
-
}
|
|
71
|
-
case 'toggle': {
|
|
72
|
-
const task = tasks.get(op.taskId)
|
|
73
|
-
if (!task) break
|
|
74
|
-
task.enabled = op.enabled
|
|
75
|
-
// P1:enable 重算到未来的 nextRunAt 随 toggle op 持久化;重放时应用,
|
|
76
|
-
// 防 upsert 快照回退到旧过期值导致首个 tick 立即触发
|
|
77
|
-
if (op.nextRunAt !== undefined) task.nextRunAt = op.nextRunAt
|
|
78
|
-
break
|
|
79
|
-
}
|
|
80
|
-
case 'delete': {
|
|
81
|
-
tasks.delete(op.taskId)
|
|
82
|
-
break
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
} catch (err) {
|
|
86
|
-
// MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
|
|
87
|
-
// snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
|
|
88
|
-
// 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
|
|
89
|
-
logger.warn('skipping corrupted scheduler entry', { error: err instanceof Error ? err.message : String(err) })
|
|
90
|
-
continue
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 步骤② fork owner 过滤(gap1):折叠完成后整体移除非 owner 任务。
|
|
95
|
-
// currentSessionFile 为 undefined(--no-session 模式)时,所有带 ownerSessionFile 的任务都被移除
|
|
96
|
-
// (无 session 文件可归属,等同空 session)——该模式下 addTask 用 '' 兜底 owner,此处比较仍一致。
|
|
97
|
-
for (const [id, task] of tasks) {
|
|
98
|
-
if (task.ownerSessionFile !== currentSessionFile) {
|
|
99
|
-
tasks.delete(id)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
48
|
+
foldEntries(entries, tasks)
|
|
49
|
+
filterNonOwnerTasks(tasks, currentSessionFile)
|
|
103
50
|
return tasks
|
|
104
51
|
} catch (err) {
|
|
105
52
|
// gap4:session JSONL 损坏 / 迭代器抛错时降级为无任务,不让 session_start 崩溃。
|
|
@@ -108,6 +55,97 @@ export function replayFoldEntries(
|
|
|
108
55
|
}
|
|
109
56
|
}
|
|
110
57
|
|
|
58
|
+
/** 步骤① 全量折叠:跳过非本扩展 entry 与守卫不过的损坏 entry;单条折叠抛错只跳过该条(MF-2)。 */
|
|
59
|
+
function foldEntries(entries: Iterable<SchedulerEntryLike>, tasks: Map<string, ScheduledTask>): void {
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry.type !== 'custom' || entry.customType !== 'pi-scheduler:task') continue
|
|
62
|
+
if (!isSchedulerEntryOp(entry.data)) continue
|
|
63
|
+
const op = entry.data // SchedulerEntryOp(守卫 isSchedulerEntryOp 收窄)
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
applyOp(tasks, op)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
|
|
69
|
+
// snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
|
|
70
|
+
// 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
|
|
71
|
+
logger.warn('skipping corrupted scheduler entry', { error: err instanceof Error ? err.message : String(err) })
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 按 op 判别值分发到对应折叠函数;无 default——未知 op 静默跳过(守卫已排除,理论不可达)。 */
|
|
78
|
+
function applyOp(tasks: Map<string, ScheduledTask>, op: SchedulerEntryOp): void {
|
|
79
|
+
switch (op.op) {
|
|
80
|
+
case 'upsert':
|
|
81
|
+
applyUpsert(tasks, op)
|
|
82
|
+
break
|
|
83
|
+
case 'advance':
|
|
84
|
+
applyAdvance(tasks, op)
|
|
85
|
+
break
|
|
86
|
+
case 'toggle':
|
|
87
|
+
applyToggle(tasks, op)
|
|
88
|
+
break
|
|
89
|
+
case 'delete':
|
|
90
|
+
applyDelete(tasks, op)
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)。 */
|
|
96
|
+
function applyUpsert(
|
|
97
|
+
tasks: Map<string, ScheduledTask>,
|
|
98
|
+
op: Extract<SchedulerEntryOp, { op: 'upsert' }>,
|
|
99
|
+
): void {
|
|
100
|
+
tasks.set(op.taskId, { ...snapshotToTask(op.task), ownerSessionFile: op.ownerSessionFile })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** advance → 推进运行末态;对不存在的 taskId 为 no-op(fork 场景安全)。 */
|
|
104
|
+
function applyAdvance(
|
|
105
|
+
tasks: Map<string, ScheduledTask>,
|
|
106
|
+
op: Extract<SchedulerEntryOp, { op: 'advance' }>,
|
|
107
|
+
): void {
|
|
108
|
+
const task = tasks.get(op.taskId)
|
|
109
|
+
if (!task) return // no-op for unknown taskId(fork 场景安全)
|
|
110
|
+
task.nextRunAt = op.nextRunAt
|
|
111
|
+
task.lastRunAt = op.at
|
|
112
|
+
task.runCount += 1
|
|
113
|
+
task.history.push({ at: op.at, status: op.status })
|
|
114
|
+
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
115
|
+
task.lastStatus = op.status // gap2:advance 必须恢复 lastStatus(不只 nextRunAt/lastRunAt/runCount/history)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** toggle → enabled=新值;携带 nextRunAt 时一并应用(P1,防快照回退到旧过期值)。 */
|
|
119
|
+
function applyToggle(
|
|
120
|
+
tasks: Map<string, ScheduledTask>,
|
|
121
|
+
op: Extract<SchedulerEntryOp, { op: 'toggle' }>,
|
|
122
|
+
): void {
|
|
123
|
+
const task = tasks.get(op.taskId)
|
|
124
|
+
if (!task) return
|
|
125
|
+
task.enabled = op.enabled
|
|
126
|
+
// P1:enable 重算到未来的 nextRunAt 随 toggle op 持久化;重放时应用,
|
|
127
|
+
// 防 upsert 快照回退到旧过期值导致首个 tick 立即触发
|
|
128
|
+
if (op.nextRunAt !== undefined) task.nextRunAt = op.nextRunAt
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** delete → 从 Map 移除;taskId 不存在时 no-op。 */
|
|
132
|
+
function applyDelete(tasks: Map<string, ScheduledTask>, op: Extract<SchedulerEntryOp, { op: 'delete' }>): void {
|
|
133
|
+
tasks.delete(op.taskId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 步骤② fork owner 过滤(gap1):折叠完成后整体移除非 owner 任务。
|
|
138
|
+
* currentSessionFile 为 undefined(--no-session 模式)时,所有带 ownerSessionFile 的任务都被移除
|
|
139
|
+
* (无 session 文件可归属,等同空 session)——该模式下 addTask 用 '' 兜底 owner,此处比较仍一致。
|
|
140
|
+
*/
|
|
141
|
+
function filterNonOwnerTasks(tasks: Map<string, ScheduledTask>, currentSessionFile: string | undefined): void {
|
|
142
|
+
for (const [id, task] of tasks) {
|
|
143
|
+
if (task.ownerSessionFile !== currentSessionFile) {
|
|
144
|
+
tasks.delete(id)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
111
149
|
/**
|
|
112
150
|
* 类型守卫:判断 entry.data 是否为合法 SchedulerEntryOp——按 op 变体校验必填字段,
|
|
113
151
|
* 不只校验 op 判别值(MF-2 修复)。仅按判别值收窄时,损坏 entry `{op:'upsert'}`(缺 task)
|
package/src/runtime.ts
CHANGED
|
@@ -505,10 +505,4 @@ export class SchedulerRuntime {
|
|
|
505
505
|
const { ownerSessionFile: _o, pending: _p, history, ...rest } = task
|
|
506
506
|
return { ...rest, history: history.slice() }
|
|
507
507
|
}
|
|
508
|
-
|
|
509
|
-
// ── 工具方法 ──
|
|
510
|
-
|
|
511
|
-
getTaskCount(): number {
|
|
512
|
-
return this.tasks.size
|
|
513
|
-
}
|
|
514
508
|
}
|
package/src/service.ts
CHANGED
|
@@ -137,7 +137,8 @@ export class SchedulerService {
|
|
|
137
137
|
/**
|
|
138
138
|
* 立即执行任务。语义细分:
|
|
139
139
|
* 任务不存在 → TASK_NOT_FOUND;任务存在但 dispatch no-op
|
|
140
|
-
* (disabled /
|
|
140
|
+
* (disabled / rate-limited / 同任务入队已在 TTL 窗口内)→ DISPATCH_SKIPPED。
|
|
141
|
+
* busy 不再是 no-op:非 force 任务入队 delivery 内核即成功(park 等后续投递)。
|
|
141
142
|
* 修复了旧实现把 no-op 误报为 not found 的混同。
|
|
142
143
|
*/
|
|
143
144
|
async run(id: string | undefined): Promise<ServiceResult> {
|
|
@@ -152,7 +153,7 @@ export class SchedulerService {
|
|
|
152
153
|
return {
|
|
153
154
|
success: false,
|
|
154
155
|
errorCode: 'DISPATCH_SKIPPED',
|
|
155
|
-
message: `Task ${id} not dispatched (
|
|
156
|
+
message: `Task ${id} not dispatched (disabled, rate-limited, or already queued for delivery).`,
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
return { success: true, message: `Task ${id} executed.` }
|
package/src/tool.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const ScheduleParams = Type.Object({
|
|
|
11
11
|
schedule: Type.String({ description: 'Schedule spec: duration (5m/2h/1d) for interval, or cron expression (*/10 * * * *).' }),
|
|
12
12
|
kind: Type.Optional(Type.Union([Type.Literal('once'), Type.Literal('recurring')], { description: 'Task kind. Default: recurring.' })),
|
|
13
13
|
name: Type.Optional(Type.String({ description: 'Human-readable task name. Auto-generated from prompt if omitted.' })),
|
|
14
|
-
expires: Type.Optional(Type.String({ description: 'Expiry duration (30m/2h/7d). Default: 7d. Pass "never" to disable.' })),
|
|
14
|
+
expires: Type.Optional(Type.String({ description: 'Expiry duration (30m/2h/7d). Default: 7d. Pass "never" to disable. Only applies to recurring tasks (once tasks fire and are removed, expires is ignored).' })),
|
|
15
15
|
force: Type.Optional(Type.Boolean({ description: 'Dispatch even when agent is busy. Default: false.' })),
|
|
16
16
|
})
|
|
17
17
|
|
|
@@ -53,7 +53,7 @@ export const controlGuidelines = [
|
|
|
53
53
|
'Use action="list" to see all scheduled tasks.',
|
|
54
54
|
'After listing, use the returned id for toggle/delete/run.',
|
|
55
55
|
'Prefer toggle(enabled=false) over delete for temporary pauses.',
|
|
56
|
-
'action="run"
|
|
56
|
+
'action="run" dispatches the task now: force tasks are sent directly; non-force tasks are enqueued via the delivery kernel and delivered once the agent is idle (busy messages wait in the queue and are flushed later).',
|
|
57
57
|
]
|
|
58
58
|
|
|
59
59
|
export function createScheduleControlHandler(service: SchedulerService) {
|
package/src/types.ts
CHANGED
package/vitest.config.ts
CHANGED