@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/src/runtime.ts CHANGED
@@ -1,30 +1,39 @@
1
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
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 { computeNextCronRunAt, parseDuration } from './parsing.js'
5
- import { createStore } from './store.js'
6
- import type { AddOptions, ScheduledTask, SchedulerStore, ScheduleSpec } from './types.js'
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 store: ReturnType<typeof createStore>
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
- cwd: string,
23
- pi: Pick<ExtensionAPI, 'sendMessage'>,
33
+ backend: SchedulerBackend,
24
34
  ctx: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
25
35
  ) {
26
- this.store = createStore(cwd)
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 = Date.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
- // 计算 nextRunAt:interval 模式 now + intervalMs;cron 模式首跑时间
52
- let nextRunAt: number
53
- if (schedule.mode === 'interval') {
54
- nextRunAt = now + schedule.intervalMs
55
- } else {
56
- const next = await computeNextCronRunAt(schedule.cronExpression, now)
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
- this.persist()
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 < Date.now()) {
97
- const schedule = task.schedule
98
- if (schedule.mode === 'interval') {
99
- task.nextRunAt = Date.now() + schedule.intervalMs
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 = (await computeNextCronRunAt(schedule.cronExpression, Date.now())) ?? Date.now()
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
- this.persist()
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) this.persist()
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
- const dispatched = await this.dispatchTask(task)
118
- this.persist()
119
- return dispatched
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 = Date.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
- this.persist()
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(Date.now())) return false
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.pi.sendMessage(
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: Date.now(), status: 'failed' })
197
- if (task.history.length > 20) task.history.shift()
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 = Date.now()
257
+ task.lastRunAt = now
204
258
  task.lastStatus = 'success'
205
259
  task.pending = false
206
- task.history.push({ at: Date.now(), status: 'success' })
207
- if (task.history.length > 20) task.history.shift()
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 schedule = task.schedule
214
- if (schedule.mode === 'interval') {
215
- task.nextRunAt = Date.now() + schedule.intervalMs
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 = (await computeNextCronRunAt(schedule.cronExpression, Date.now())) ?? Date.now()
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(Date.now())
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
- loadTasks(): void {
234
- const store = this.store.load()
235
- this.tasks = new Map(store.tasks.map(t => [t.id, t]))
304
+ /** 装配点注入初始任务数组(读盘/重放由 backend 完成,runtime 只持有内存态)。 */
305
+ loadTasks(tasks: ScheduledTask[]): void {
306
+ this.tasks = new Map(tasks.map(t => [t.id, t]))
236
307
  }
237
308
 
238
- persist(): void {
239
- const store: SchedulerStore = { version: 1, tasks: Array.from(this.tasks.values()) }
240
- this.store.persist(store)
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
- persistSync(): void {
244
- const store: SchedulerStore = { version: 1, tasks: Array.from(this.tasks.values()) }
245
- this.store.persistSync(store)
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 { formatRelativeTime, formatSchedule } from './format.js'
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 5 run times.',
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
- export function createScheduleHandler(runtime: SchedulerRuntime) {
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
- const parsed = await parseSchedule(scheduleInput)
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(runtime: SchedulerRuntime) {
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
- const tasks = runtime.listTasks()
84
- if (tasks.length === 0) {
85
- return { content: [{ type: 'text' as const, text: 'No scheduled tasks.' }], details: { tasks: [] } }
86
- }
87
- const lines = tasks.map(t =>
88
- `${t.enabled ? '●' : '○'} ${t.id} ${t.name} · ${formatSchedule(t.schedule)} · ${formatRelativeTime(t.nextRunAt)}`
89
- )
90
- return {
91
- content: [{ type: 'text' as const, text: lines.join('\n') }],
92
- details: { tasks },
93
- }
94
- }
95
-
96
- case 'toggle': {
97
- if (!id) throw new Error('id is required for toggle.')
98
- if (enabled === undefined) throw new Error('enabled is required for toggle.')
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
- default:
128
- throw new Error(`Unknown action: ${action}`)
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
  }