@zhushanwen/pi-scheduler 0.1.0 → 0.2.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 +236 -0
- package/package.json +4 -3
- package/src/__tests__/backend.test.ts +120 -0
- package/src/__tests__/commands.test.ts +59 -60
- package/src/__tests__/cron.test.ts +1 -2
- package/src/__tests__/format.test.ts +35 -0
- package/src/__tests__/importer.test.ts +283 -0
- package/src/__tests__/parsing.test.ts +29 -0
- package/src/__tests__/property.test.ts +131 -0
- package/src/__tests__/replay.test.ts +309 -0
- package/src/__tests__/runtime.test.ts +282 -48
- package/src/__tests__/sdk-contract.test.ts +54 -19
- package/src/__tests__/service.test.ts +214 -0
- package/src/__tests__/tool.test.ts +40 -23
- package/src/backend.ts +151 -0
- package/src/commands.ts +28 -34
- package/src/format.ts +6 -4
- package/src/importer.ts +261 -0
- package/src/index.ts +62 -34
- package/src/parsing.ts +19 -1
- package/src/replay.ts +181 -0
- package/src/runtime.ts +152 -63
- package/src/service.ts +156 -0
- package/src/tool.ts +51 -75
- package/src/types.ts +43 -5
- package/src/__tests__/store.test.ts +0 -161
- package/src/store.ts +0 -117
package/src/runtime.ts
CHANGED
|
@@ -1,30 +1,39 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
|
|
3
|
+
import type { SchedulerBackend } from './backend.js'
|
|
3
4
|
import { autoName, generateTaskId } from './format.js'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import { computeNextRunAt, parseDuration } from './parsing.js'
|
|
6
|
+
import type {
|
|
7
|
+
AddOptions,
|
|
8
|
+
ScheduledTask,
|
|
9
|
+
SchedulerEntryOp,
|
|
10
|
+
ScheduleSpec,
|
|
11
|
+
TaskSnapshot,
|
|
12
|
+
} from './types.js'
|
|
7
13
|
|
|
8
14
|
const MAX_TASKS = 50
|
|
9
15
|
const RATE_LIMIT_PER_MINUTE = 6
|
|
10
16
|
const TICK_INTERVAL_MS = 30_000
|
|
11
17
|
const DEFAULT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
18
|
+
const HISTORY_LIMIT = 20 // 与 replayFoldEntries 的裁剪上限一致(advance 折叠 / dispatch 累积共用)
|
|
12
19
|
|
|
13
20
|
export class SchedulerRuntime {
|
|
14
21
|
private tasks: Map<string, ScheduledTask> = new Map()
|
|
15
|
-
private
|
|
16
|
-
private pi: Pick<ExtensionAPI, 'sendMessage'>
|
|
22
|
+
private backend: SchedulerBackend
|
|
17
23
|
private ctx: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>
|
|
18
24
|
private tickTimer: ReturnType<typeof setInterval> | null = null
|
|
19
25
|
private dispatchTimestamps: number[] = []
|
|
26
|
+
private onAfterTickCallback: (() => void) | null = null
|
|
20
27
|
|
|
28
|
+
/**
|
|
29
|
+
* 依赖反转构造:backend 承担 appendEntry/pi.sendMessage/时间源,runtime 只持有内存态。
|
|
30
|
+
* 不触碰任何 FS / session JSONL(测试可用 MockSchedulerBackend 零副作用注入)。
|
|
31
|
+
*/
|
|
21
32
|
constructor(
|
|
22
|
-
|
|
23
|
-
pi: Pick<ExtensionAPI, 'sendMessage'>,
|
|
33
|
+
backend: SchedulerBackend,
|
|
24
34
|
ctx: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
|
|
25
35
|
) {
|
|
26
|
-
this.
|
|
27
|
-
this.pi = pi
|
|
36
|
+
this.backend = backend
|
|
28
37
|
this.ctx = ctx
|
|
29
38
|
}
|
|
30
39
|
|
|
@@ -36,7 +45,7 @@ export class SchedulerRuntime {
|
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
const id = generateTaskId()
|
|
39
|
-
const now =
|
|
48
|
+
const now = this.backend.now()
|
|
40
49
|
const kind = options.kind ?? 'recurring'
|
|
41
50
|
const name = options.name ?? autoName(prompt)
|
|
42
51
|
|
|
@@ -48,16 +57,12 @@ export class SchedulerRuntime {
|
|
|
48
57
|
expiresAt = now + expiryMs
|
|
49
58
|
}
|
|
50
59
|
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (next === undefined) {
|
|
58
|
-
throw new Error(`Invalid cron expression: ${schedule.cronExpression}`)
|
|
59
|
-
}
|
|
60
|
-
nextRunAt = next
|
|
60
|
+
// 统一 nextRunAt 计算:interval → now + intervalMs;cron → 下次命中
|
|
61
|
+
const nextRunAt = await computeNextRunAt(schedule, now)
|
|
62
|
+
if (nextRunAt === undefined) {
|
|
63
|
+
// 创建时校验失败报错给用户(仅 cron 可能 undefined,interval 恒有值)
|
|
64
|
+
const expr = schedule.mode === 'cron' ? schedule.cronExpression : '<unknown>'
|
|
65
|
+
throw new Error(`Invalid cron expression: ${expr}`)
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
const task: ScheduledTask = {
|
|
@@ -76,7 +81,14 @@ export class SchedulerRuntime {
|
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
this.tasks.set(id, task)
|
|
79
|
-
|
|
84
|
+
// append-only:写 upsert op 到 owner session JSONL(ER-APPEND-FAIL catch,内存态已更新)
|
|
85
|
+
this.appendEntrySafe({
|
|
86
|
+
op: 'upsert',
|
|
87
|
+
taskId: id,
|
|
88
|
+
// getSessionFile() 在 --no-session 模式返回 undefined → '' 兜底(该模式 appendEntry 无 owner 不落盘)
|
|
89
|
+
ownerSessionFile: this.backend.getSessionFile() ?? '',
|
|
90
|
+
task: this.toSnapshot(task),
|
|
91
|
+
})
|
|
80
92
|
return task
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -92,31 +104,56 @@ export class SchedulerRuntime {
|
|
|
92
104
|
const task = this.tasks.get(id)
|
|
93
105
|
if (!task) return false
|
|
94
106
|
task.enabled = enabled
|
|
107
|
+
// enable 重算到未来后的新 nextRunAt;携带到 toggle op 持久化,
|
|
108
|
+
// 防 resume 重放从 upsert 快照回退到旧过期 nextRunAt(P1 跨 session 持久化)
|
|
109
|
+
let recalcedNext: number | undefined
|
|
95
110
|
// enable 时若 nextRunAt 已过期,重算,避免 enable 瞬间立即触发
|
|
96
|
-
if (enabled && task.nextRunAt <
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
111
|
+
if (enabled && task.nextRunAt < this.backend.now()) {
|
|
112
|
+
const next = await computeNextRunAt(task.schedule, this.backend.now())
|
|
113
|
+
if (next === undefined) {
|
|
114
|
+
// ERR-2 fallback:cron 表达式失效 → 停用任务并记录失败原因。
|
|
115
|
+
// 禁止 `?? now()` 类 fallback(会使 nextRunAt=now,下个 tick 立即重算 → 死循环)
|
|
116
|
+
task.enabled = false
|
|
117
|
+
task.lastStatus = 'failed'
|
|
118
|
+
task.lastError = 'cron expression invalid'
|
|
119
|
+
// nextRunAt 保留原值(enabled=false 后 tick 不再触发)
|
|
100
120
|
} else {
|
|
101
|
-
task.nextRunAt =
|
|
121
|
+
task.nextRunAt = next
|
|
122
|
+
recalcedNext = next
|
|
123
|
+
// MF-1:重算到未来后清除残留 pending。pending 是「到期待 dispatch」标记,
|
|
124
|
+
// 由 busy tick 的 step2 置位(W4 跨 tick 重试保留)。nextRunAt 已推到未来则该标记过期,
|
|
125
|
+
// 否则下个 tick step3 `pending && enabled` 会在重算的未来时间点之前提前 dispatch,
|
|
126
|
+
// 违背上方注释「避免 enable 瞬间立即触发」承诺。
|
|
127
|
+
task.pending = false
|
|
102
128
|
}
|
|
103
129
|
}
|
|
104
|
-
|
|
130
|
+
// 全部 mutation 完成后 append toggle:确保 append 的 enabled 是最终值
|
|
131
|
+
// (LOW4:cron-invalid 回退 enabled=false 的路径,append enabled=false 而非入参 true)
|
|
132
|
+
// P1:nextRunAt 仅 enable 重算到未来时携带——持久化重算值,防 resume 重放回退到 upsert 快照的旧过期值。
|
|
133
|
+
// 普通 toggle / cron 失效回退(recalcedNext=undefined)不带,重放时保持 upsert 快照值。
|
|
134
|
+
this.appendEntrySafe({
|
|
135
|
+
op: 'toggle',
|
|
136
|
+
taskId: id,
|
|
137
|
+
enabled: task.enabled,
|
|
138
|
+
...(recalcedNext !== undefined && { nextRunAt: recalcedNext }),
|
|
139
|
+
})
|
|
105
140
|
return true
|
|
106
141
|
}
|
|
107
142
|
|
|
108
143
|
deleteTask(id: string): boolean {
|
|
109
144
|
const deleted = this.tasks.delete(id)
|
|
110
|
-
if (deleted)
|
|
145
|
+
if (deleted) {
|
|
146
|
+
this.appendEntrySafe({ op: 'delete', taskId: id })
|
|
147
|
+
}
|
|
111
148
|
return deleted
|
|
112
149
|
}
|
|
113
150
|
|
|
114
151
|
async runTaskNow(id: string): Promise<boolean> {
|
|
115
152
|
const task = this.tasks.get(id)
|
|
116
153
|
if (!task) return false
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return
|
|
154
|
+
// gap3:持久化由 dispatchTask 成功后 append advance(recurring)/delete(once) 隐式覆盖,
|
|
155
|
+
// 不在此重复 append(advance 已在 dispatchTask chokepoint)
|
|
156
|
+
return await this.dispatchTask(task)
|
|
120
157
|
}
|
|
121
158
|
|
|
122
159
|
// ── 调度 ──
|
|
@@ -133,26 +170,38 @@ export class SchedulerRuntime {
|
|
|
133
170
|
}
|
|
134
171
|
}
|
|
135
172
|
|
|
173
|
+
/**
|
|
174
|
+
* 注册 tick 后回调(W2)。index.ts 注册 refreshWidget 替代独立 widgetTimer——
|
|
175
|
+
* 每次 tickScheduler 末尾调用,对齐 TICK_INTERVAL_MS 刷新 widget。
|
|
176
|
+
*/
|
|
177
|
+
onAfterTick(callback: () => void): void {
|
|
178
|
+
this.onAfterTickCallback = callback
|
|
179
|
+
}
|
|
180
|
+
|
|
136
181
|
async tickScheduler(): Promise<void> {
|
|
137
|
-
const now =
|
|
182
|
+
const now = this.backend.now()
|
|
138
183
|
|
|
139
|
-
// 1.
|
|
184
|
+
// 1. 过期清理(must-fix 2 / CL9:append delete 抵消残留 upsert,防 resume 复活已过期任务)
|
|
185
|
+
// append-only 下 upsert entry 永久残留 JSONL(D10 不裁剪),若无 delete entry 抵消,
|
|
186
|
+
// resume 时 replayFoldEntries 会从 upsert 重放出已过期任务 → 每 resume 复活直到首个 tick。
|
|
140
187
|
for (const [id, task] of this.tasks) {
|
|
141
188
|
if (task.expiresAt && now >= task.expiresAt) {
|
|
142
189
|
this.tasks.delete(id)
|
|
190
|
+
this.appendEntrySafe({ op: 'delete', taskId: id })
|
|
143
191
|
}
|
|
144
192
|
}
|
|
145
193
|
|
|
146
|
-
// 2.
|
|
194
|
+
// 2. 标记到期(pending 是运行时标记,与 enabled 正交)
|
|
147
195
|
for (const task of this.tasks.values()) {
|
|
148
196
|
if (task.enabled && now >= task.nextRunAt) {
|
|
149
197
|
task.pending = true
|
|
150
198
|
}
|
|
151
199
|
}
|
|
152
200
|
|
|
153
|
-
// 3. dispatch pending 任务(按 nextRunAt
|
|
201
|
+
// 3. dispatch pending 任务(按 nextRunAt 排序)。W4:显式 +t.enabled,
|
|
202
|
+
// 防御标记后到 dispatch 之间被 toggle disabled 的竞态(pending 与 enabled 正交)
|
|
154
203
|
const pending = [...this.tasks.values()]
|
|
155
|
-
.filter(t => t.pending)
|
|
204
|
+
.filter(t => t.pending && t.enabled)
|
|
156
205
|
.sort((a, b) => a.nextRunAt - b.nextRunAt)
|
|
157
206
|
|
|
158
207
|
for (const task of pending) {
|
|
@@ -161,7 +210,8 @@ export class SchedulerRuntime {
|
|
|
161
210
|
}
|
|
162
211
|
}
|
|
163
212
|
|
|
164
|
-
|
|
213
|
+
// W2:tick 完成后刷新 widget(index.ts 注册 refreshWidget)
|
|
214
|
+
this.onAfterTickCallback?.()
|
|
165
215
|
}
|
|
166
216
|
|
|
167
217
|
// ── dispatch ──
|
|
@@ -170,6 +220,9 @@ export class SchedulerRuntime {
|
|
|
170
220
|
* dispatch 单个任务。返回 true 表示真的发送了 message,false 表示 no-op
|
|
171
221
|
* (task disabled / rate-limited / 非 force 且 busy)。
|
|
172
222
|
* sendMessage 抛错时记录 failed 状态但不 rethrow,让 tick 继续处理其他任务。
|
|
223
|
+
*
|
|
224
|
+
* 持久化(append-only):recurring 成功推进 nextRunAt → append advance(status='success' CL8);
|
|
225
|
+
* once 成功 → append delete。失败 dispatch 不 append(CL7 重试语义,transient 失败 nextRunAt 未推进)。
|
|
173
226
|
*/
|
|
174
227
|
async dispatchTask(task: ScheduledTask): Promise<boolean> {
|
|
175
228
|
if (!task.enabled) return false
|
|
@@ -182,43 +235,61 @@ export class SchedulerRuntime {
|
|
|
182
235
|
}
|
|
183
236
|
|
|
184
237
|
// 检查速率限制
|
|
185
|
-
if (!this.hasDispatchCapacity(
|
|
238
|
+
if (!this.hasDispatchCapacity(this.backend.now())) return false
|
|
186
239
|
|
|
187
|
-
// 注入 message
|
|
240
|
+
// 注入 message(await:async 错误必须被捕获,fire-and-forget 会漏)
|
|
188
241
|
try {
|
|
189
|
-
this.
|
|
242
|
+
await this.backend.sendMessage(
|
|
190
243
|
{ content: task.prompt, customType: 'pi-scheduler:dispatched', display: true },
|
|
191
244
|
{ deliverAs: 'followUp', triggerTurn: true },
|
|
192
245
|
)
|
|
193
246
|
} catch {
|
|
194
247
|
task.lastStatus = 'failed'
|
|
195
248
|
task.pending = false
|
|
196
|
-
task.history.push({ at:
|
|
197
|
-
if (task.history.length >
|
|
249
|
+
task.history.push({ at: this.backend.now(), status: 'failed' })
|
|
250
|
+
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
198
251
|
return false
|
|
199
252
|
}
|
|
200
253
|
|
|
201
|
-
//
|
|
254
|
+
// 更新状态(收敛到一个 now 值,避免多次 backend.now() 在真实时钟下漂移)
|
|
255
|
+
const now = this.backend.now()
|
|
202
256
|
task.runCount++
|
|
203
|
-
task.lastRunAt =
|
|
257
|
+
task.lastRunAt = now
|
|
204
258
|
task.lastStatus = 'success'
|
|
205
259
|
task.pending = false
|
|
206
|
-
task.
|
|
207
|
-
|
|
260
|
+
task.lastError = undefined // 成功 dispatch 后清除历史错误
|
|
261
|
+
task.history.push({ at: now, status: 'success' })
|
|
262
|
+
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
208
263
|
|
|
209
|
-
// 计算下次执行
|
|
264
|
+
// 计算下次执行 + 持久化(append-only)
|
|
210
265
|
if (task.kind === 'once') {
|
|
211
266
|
this.tasks.delete(task.id)
|
|
267
|
+
// once 成功 → append delete(CL7:抵消 upsert,防 resume 复活已执行的 once 任务)
|
|
268
|
+
this.appendEntrySafe({ op: 'delete', taskId: task.id })
|
|
212
269
|
} else {
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
215
|
-
|
|
270
|
+
const next = await computeNextRunAt(task.schedule, now)
|
|
271
|
+
if (next === undefined) {
|
|
272
|
+
// ERR-2 fallback:cron 表达式失效 → 停用任务,避免 `?? now()` 死循环。
|
|
273
|
+
// 不 append advance(nextRunAt 未推进,CL7 重试语义);cron 失效停用的 enabled=false
|
|
274
|
+
// 持久化缺口属 at-least-once 已知窗口(首个 tick 会再停用),非 must-fix
|
|
275
|
+
task.enabled = false
|
|
276
|
+
task.lastStatus = 'failed'
|
|
277
|
+
task.lastError = 'cron expression invalid'
|
|
278
|
+
// nextRunAt 保留原值(enabled=false 后 tick 不再触发)
|
|
216
279
|
} else {
|
|
217
|
-
task.nextRunAt =
|
|
280
|
+
task.nextRunAt = next
|
|
281
|
+
// recurring 成功推进 nextRunAt → append advance(D1 核心:持久化新 nextRunAt,防 resume 回退重放)
|
|
282
|
+
this.appendEntrySafe({
|
|
283
|
+
op: 'advance',
|
|
284
|
+
taskId: task.id,
|
|
285
|
+
nextRunAt: next,
|
|
286
|
+
at: now,
|
|
287
|
+
status: 'success', // CL8:对齐 TaskStatus,非 'ok'
|
|
288
|
+
})
|
|
218
289
|
}
|
|
219
290
|
}
|
|
220
291
|
|
|
221
|
-
this.dispatchTimestamps.push(
|
|
292
|
+
this.dispatchTimestamps.push(now)
|
|
222
293
|
return true
|
|
223
294
|
}
|
|
224
295
|
|
|
@@ -228,21 +299,39 @@ export class SchedulerRuntime {
|
|
|
228
299
|
return this.dispatchTimestamps.length < RATE_LIMIT_PER_MINUTE
|
|
229
300
|
}
|
|
230
301
|
|
|
231
|
-
// ──
|
|
302
|
+
// ── 装配与回调 ──
|
|
232
303
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
this.tasks = new Map(
|
|
304
|
+
/** 装配点注入初始任务数组(读盘/重放由 backend 完成,runtime 只持有内存态)。 */
|
|
305
|
+
loadTasks(tasks: ScheduledTask[]): void {
|
|
306
|
+
this.tasks = new Map(tasks.map(t => [t.id, t]))
|
|
236
307
|
}
|
|
237
308
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
309
|
+
// ── append-only 持久化辅助 ──
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 委托 backend.appendEntry。失败 → console.warn + 不 rethrow(ER-APPEND-FAIL)。
|
|
313
|
+
* 内存态已先行更新(at-least-once 已知恶化窗口:append 失败则该 op 丢失,resume 重放回退)。
|
|
314
|
+
* 不再设 task.lastError='persist failed'(append 失败是 transient,不应污染业务态)。
|
|
315
|
+
*/
|
|
316
|
+
private appendEntrySafe(op: SchedulerEntryOp): void {
|
|
317
|
+
try {
|
|
318
|
+
this.backend.appendEntry(op)
|
|
319
|
+
} catch (err) {
|
|
320
|
+
// best-effort 降级(ER-APPEND-FAIL):append-only 模型下 append 失败仅丢失该 op 的持久化,
|
|
321
|
+
// 内存态已先行更新、不 rethrow,业务流程继续。at-least-once 已知恶化窗口(resume 重放回退)。
|
|
322
|
+
console.warn(
|
|
323
|
+
`[scheduler] appendEntry failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
241
326
|
}
|
|
242
327
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
328
|
+
/**
|
|
329
|
+
* ScheduledTask → TaskSnapshot:剥离 ownerSessionFile(在 op 顶层)与 pending(运行时标记),
|
|
330
|
+
* history 深拷贝(避免快照与运行时 task 共享数组引用)。
|
|
331
|
+
*/
|
|
332
|
+
private toSnapshot(task: ScheduledTask): TaskSnapshot {
|
|
333
|
+
const { ownerSessionFile: _o, pending: _p, history, ...rest } = task
|
|
334
|
+
return { ...rest, history: history.slice() }
|
|
246
335
|
}
|
|
247
336
|
|
|
248
337
|
// ── 工具方法 ──
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { formatRelativeTime, formatSchedule } from './format.js'
|
|
2
|
+
import { computeNextRuns, parseSchedule } from './parsing.js'
|
|
3
|
+
import type { SchedulerRuntime } from './runtime.js'
|
|
4
|
+
import type { AddOptions, ScheduledTask } from './types.js'
|
|
5
|
+
|
|
6
|
+
// recurring 预览行数(once 只回显 1 次)
|
|
7
|
+
const PREVIEW_RUN_COUNT = 5
|
|
8
|
+
|
|
9
|
+
// ── 结构化结果 ──
|
|
10
|
+
|
|
11
|
+
export type ServiceErrorCode =
|
|
12
|
+
| 'TASK_NOT_FOUND'
|
|
13
|
+
| 'INVALID_SCHEDULE'
|
|
14
|
+
| 'TASK_LIMIT_REACHED'
|
|
15
|
+
| 'DISPATCH_SKIPPED'
|
|
16
|
+
| 'INVALID_PARAMS'
|
|
17
|
+
| 'INTERNAL'
|
|
18
|
+
|
|
19
|
+
export interface ServiceResult<T = unknown> {
|
|
20
|
+
success: boolean
|
|
21
|
+
message: string
|
|
22
|
+
errorCode?: ServiceErrorCode
|
|
23
|
+
data?: T
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── SchedulerService ──
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* tool 与 command 的唯一业务入口(IF-4 去双轨):
|
|
30
|
+
* 5 个动作单一实现,返回结构化 ServiceResult。
|
|
31
|
+
* - 成功: { success: true, message, data }
|
|
32
|
+
* - 失败: { success: false, errorCode, message }
|
|
33
|
+
*
|
|
34
|
+
* message 为用户可读纯文本(tool 用作 content 文本、command 直接输出),
|
|
35
|
+
* data 供 tool details(create: {task, nextRuns};list: {tasks})。
|
|
36
|
+
*/
|
|
37
|
+
export class SchedulerService {
|
|
38
|
+
constructor(public readonly runtime: SchedulerRuntime, private readonly now: () => number) {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 创建任务。
|
|
42
|
+
* 注意:create 接收原始 schedule 字符串、内部 parseSchedule(而非已解析的
|
|
43
|
+
* ScheduleSpec)——这是对 IF-4 草案 create(parseResult) 的有意细化:
|
|
44
|
+
* 解析失败需要结构化 INVALID_SCHEDULE 返回,把解析责任留在 service 内,
|
|
45
|
+
* tool/command 两层都不需要重复 parseSchedule。
|
|
46
|
+
*/
|
|
47
|
+
async create(
|
|
48
|
+
prompt: string,
|
|
49
|
+
scheduleInput: string,
|
|
50
|
+
options: AddOptions = {},
|
|
51
|
+
): Promise<ServiceResult<{ task: ScheduledTask; nextRuns: number[] }>> {
|
|
52
|
+
const parsed = await parseSchedule(scheduleInput)
|
|
53
|
+
if (!parsed) {
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
errorCode: 'INVALID_SCHEDULE',
|
|
57
|
+
message: `Invalid schedule: "${scheduleInput}". Use duration (5m/2h/1d) or cron expression (*/10 * * * *).`,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let task: ScheduledTask
|
|
62
|
+
try {
|
|
63
|
+
task = await this.runtime.addTask(prompt, parsed.spec, options)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
66
|
+
if (message.startsWith('Task limit reached')) {
|
|
67
|
+
return { success: false, errorCode: 'TASK_LIMIT_REACHED', message }
|
|
68
|
+
}
|
|
69
|
+
// 意外错误兜底(正常路径不会到达:parseSchedule 已校验 cron 有效性)
|
|
70
|
+
return { success: false, errorCode: 'INTERNAL', message }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const count = task.kind === 'once' ? 1 : PREVIEW_RUN_COUNT
|
|
74
|
+
const nextRuns = await computeNextRuns(task.schedule, this.now(), count)
|
|
75
|
+
// once 单行内联回显(只执行 1 次,编号列表会误导);recurring 保持 5 行编号列表
|
|
76
|
+
const runPreview =
|
|
77
|
+
task.kind === 'once'
|
|
78
|
+
? `Next run: ${formatRelativeTime(nextRuns[0]!)}`
|
|
79
|
+
: [
|
|
80
|
+
'Next 5 runs:',
|
|
81
|
+
...nextRuns.map((t, i) => ` ${i + 1}. ${formatRelativeTime(t)}`),
|
|
82
|
+
].join('\n')
|
|
83
|
+
// 一行紧凑:name(id) + schedule(含 kind 信息) + expires + force。
|
|
84
|
+
// 删冗余 Kind 行(formatSchedule 已含 once/every);Expires/Force 合并(默认 no-expires/no-force 显式)。
|
|
85
|
+
const expiresLabel = task.expiresAt
|
|
86
|
+
? `expires ${formatRelativeTime(task.expiresAt)}`
|
|
87
|
+
: 'no-expires'
|
|
88
|
+
const forceLabel = task.force ? 'force' : 'no-force'
|
|
89
|
+
const message = [
|
|
90
|
+
`Task "${task.name}" (${task.id}) created. ${formatSchedule(task.schedule, task.kind)}, ${expiresLabel}, ${forceLabel}`,
|
|
91
|
+
runPreview,
|
|
92
|
+
].join('\n')
|
|
93
|
+
|
|
94
|
+
return { success: true, message, data: { task, nextRuns } }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
list(): ServiceResult<{ tasks: ScheduledTask[] }> {
|
|
98
|
+
const tasks = this.runtime.listTasks()
|
|
99
|
+
if (tasks.length === 0) {
|
|
100
|
+
return { success: true, message: 'No scheduled tasks.', data: { tasks: [] } }
|
|
101
|
+
}
|
|
102
|
+
const message = tasks.map(t =>
|
|
103
|
+
`${t.enabled ? '●' : '○'} ${t.id} ${t.name} · ${formatSchedule(t.schedule, t.kind)} · ${formatRelativeTime(t.nextRunAt)}`
|
|
104
|
+
).join('\n')
|
|
105
|
+
return { success: true, message, data: { tasks } }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async toggle(id: string | undefined, enabled: boolean | undefined): Promise<ServiceResult> {
|
|
109
|
+
if (!id) {
|
|
110
|
+
return { success: false, errorCode: 'INVALID_PARAMS', message: 'id is required for toggle.' }
|
|
111
|
+
}
|
|
112
|
+
if (enabled === undefined) {
|
|
113
|
+
return { success: false, errorCode: 'INVALID_PARAMS', message: 'enabled is required for toggle.' }
|
|
114
|
+
}
|
|
115
|
+
const success = await this.runtime.toggleTask(id, enabled)
|
|
116
|
+
if (!success) {
|
|
117
|
+
return { success: false, errorCode: 'TASK_NOT_FOUND', message: `Task ${id} not found.` }
|
|
118
|
+
}
|
|
119
|
+
return { success: true, message: `Task ${id} ${enabled ? 'enabled' : 'disabled'}.` }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
delete(id: string | undefined): ServiceResult {
|
|
123
|
+
if (!id) {
|
|
124
|
+
return { success: false, errorCode: 'INVALID_PARAMS', message: 'id is required for delete.' }
|
|
125
|
+
}
|
|
126
|
+
const success = this.runtime.deleteTask(id)
|
|
127
|
+
if (!success) {
|
|
128
|
+
return { success: false, errorCode: 'TASK_NOT_FOUND', message: `Task ${id} not found.` }
|
|
129
|
+
}
|
|
130
|
+
return { success: true, message: `Task ${id} deleted.` }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 立即执行任务。语义细分:
|
|
135
|
+
* 任务不存在 → TASK_NOT_FOUND;任务存在但 dispatch no-op
|
|
136
|
+
* (disabled / busy / rate-limited)→ DISPATCH_SKIPPED。
|
|
137
|
+
* 修复了旧实现把 no-op 误报为 not found 的混同。
|
|
138
|
+
*/
|
|
139
|
+
async run(id: string | undefined): Promise<ServiceResult> {
|
|
140
|
+
if (!id) {
|
|
141
|
+
return { success: false, errorCode: 'INVALID_PARAMS', message: 'id is required for run.' }
|
|
142
|
+
}
|
|
143
|
+
if (!this.runtime.getTask(id)) {
|
|
144
|
+
return { success: false, errorCode: 'TASK_NOT_FOUND', message: `Task ${id} not found.` }
|
|
145
|
+
}
|
|
146
|
+
const dispatched = await this.runtime.runTaskNow(id)
|
|
147
|
+
if (!dispatched) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
errorCode: 'DISPATCH_SKIPPED',
|
|
151
|
+
message: `Task ${id} not dispatched (busy, disabled, or rate-limited).`,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { success: true, message: `Task ${id} executed.` }
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/tool.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { Static, Type } from 'typebox'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { computeNextRuns, parseSchedule } from './parsing.js'
|
|
5
|
-
import type { SchedulerRuntime } from './runtime.js'
|
|
3
|
+
import type { SchedulerService, ServiceResult } from './service.js'
|
|
6
4
|
|
|
7
5
|
// TODO: add renderResult/renderCall to registerTool calls (standards.md §4.3)
|
|
8
6
|
|
|
@@ -23,37 +21,20 @@ export const scheduleGuidelines = [
|
|
|
23
21
|
'This tool creates a scheduled task.',
|
|
24
22
|
'Schedule accepts duration (5m, 2h, 1d) for interval-based or cron expression for time-based.',
|
|
25
23
|
'Default kind is recurring. Set kind="once" for one-time reminders.',
|
|
26
|
-
'After creation, the response includes task id and next
|
|
24
|
+
'After creation, the response includes task id and next run time(s).',
|
|
27
25
|
'Default expiry is 7 days. Use expires="never" for long-term tasks.',
|
|
28
26
|
]
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
/**
|
|
29
|
+
* schedule tool handler(SchedulerService 瘦壳,无独立业务逻辑)。
|
|
30
|
+
* 业务失败 → 结构化 isError result(不 throw);service 未初始化等初始化异常
|
|
31
|
+
* 不在此 catch——穿透到 index.ts execute 的 catch 兜底(R3)。
|
|
32
|
+
*/
|
|
33
|
+
export function createScheduleHandler(service: SchedulerService) {
|
|
31
34
|
return async (params: ScheduleParamsT) => {
|
|
32
35
|
const { prompt, schedule: scheduleInput, kind, name, expires, force } = params
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!parsed) {
|
|
36
|
-
throw new Error(`Invalid schedule: "${scheduleInput}". Use duration (5m/2h/1d) or cron expression (*/10 * * * *).`)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const task = await runtime.addTask(prompt, parsed.spec, { kind, name, expires, force })
|
|
40
|
-
|
|
41
|
-
const nextRuns = await computeNextRuns(task.schedule, Date.now(), 5)
|
|
42
|
-
const summary = [
|
|
43
|
-
`Task "${task.name}" (${task.id}) created.`,
|
|
44
|
-
`Schedule: ${formatSchedule(task.schedule)}`,
|
|
45
|
-
`Kind: ${task.kind}`,
|
|
46
|
-
`Expires: ${task.expiresAt ? formatRelativeTime(task.expiresAt) : 'never'}`,
|
|
47
|
-
`Force: ${task.force ? 'yes' : 'no'}`,
|
|
48
|
-
'',
|
|
49
|
-
'Next 5 runs:',
|
|
50
|
-
...nextRuns.map((t, i) => ` ${i + 1}. ${formatRelativeTime(t)}`),
|
|
51
|
-
].join('\n')
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
content: [{ type: 'text' as const, text: summary }],
|
|
55
|
-
details: { task, nextRuns },
|
|
56
|
-
}
|
|
36
|
+
const result = await service.create(prompt, scheduleInput, { kind, name, expires, force })
|
|
37
|
+
return toToolResult(result)
|
|
57
38
|
}
|
|
58
39
|
}
|
|
59
40
|
|
|
@@ -74,58 +55,53 @@ export const controlGuidelines = [
|
|
|
74
55
|
'action="run" fires the task immediately.',
|
|
75
56
|
]
|
|
76
57
|
|
|
77
|
-
export function createScheduleControlHandler(
|
|
58
|
+
export function createScheduleControlHandler(service: SchedulerService) {
|
|
78
59
|
return async (params: ScheduleControlParamsT) => {
|
|
79
60
|
const { action, id, enabled } = params
|
|
80
61
|
|
|
62
|
+
let result: ServiceResult
|
|
81
63
|
switch (action) {
|
|
82
|
-
case 'list':
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const success = await runtime.toggleTask(id, enabled)
|
|
100
|
-
if (!success) throw new Error(`Task ${id} not found.`)
|
|
101
|
-
return {
|
|
102
|
-
content: [{ type: 'text' as const, text: `Task ${id} ${enabled ? 'enabled' : 'disabled'}.` }],
|
|
103
|
-
details: { success },
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
case 'delete': {
|
|
108
|
-
if (!id) throw new Error('id is required for delete.')
|
|
109
|
-
const success = runtime.deleteTask(id)
|
|
110
|
-
if (!success) throw new Error(`Task ${id} not found.`)
|
|
111
|
-
return {
|
|
112
|
-
content: [{ type: 'text' as const, text: `Task ${id} deleted.` }],
|
|
113
|
-
details: { success },
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
case 'run': {
|
|
118
|
-
if (!id) throw new Error('id is required for run.')
|
|
119
|
-
const success = await runtime.runTaskNow(id)
|
|
120
|
-
if (!success) throw new Error(`Task ${id} not found.`)
|
|
121
|
-
return {
|
|
122
|
-
content: [{ type: 'text' as const, text: `Task ${id} executed.` }],
|
|
123
|
-
details: { success },
|
|
64
|
+
case 'list':
|
|
65
|
+
result = service.list()
|
|
66
|
+
break
|
|
67
|
+
case 'toggle':
|
|
68
|
+
result = await service.toggle(id, enabled)
|
|
69
|
+
break
|
|
70
|
+
case 'delete':
|
|
71
|
+
result = service.delete(id)
|
|
72
|
+
break
|
|
73
|
+
case 'run':
|
|
74
|
+
result = await service.run(id)
|
|
75
|
+
break
|
|
76
|
+
default:
|
|
77
|
+
result = {
|
|
78
|
+
success: false,
|
|
79
|
+
errorCode: 'INVALID_PARAMS',
|
|
80
|
+
message: `Unknown action: ${action}`,
|
|
124
81
|
}
|
|
125
|
-
|
|
82
|
+
}
|
|
83
|
+
return toToolResult(result)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
126
86
|
|
|
127
|
-
|
|
128
|
-
|
|
87
|
+
/**
|
|
88
|
+
* ServiceResult → tool execute 返回。
|
|
89
|
+
* 成功 → {content: [message], details: data};失败 → isError + details.errorCode。
|
|
90
|
+
*/
|
|
91
|
+
function toToolResult(result: ServiceResult): {
|
|
92
|
+
content: { type: 'text'; text: string }[]
|
|
93
|
+
details: unknown
|
|
94
|
+
isError?: boolean
|
|
95
|
+
} {
|
|
96
|
+
if (!result.success) {
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: 'text' as const, text: result.message }],
|
|
99
|
+
details: { errorCode: result.errorCode },
|
|
100
|
+
isError: true,
|
|
129
101
|
}
|
|
130
102
|
}
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: 'text' as const, text: result.message }],
|
|
105
|
+
details: result.data ?? {},
|
|
106
|
+
}
|
|
131
107
|
}
|