@zhushanwen/pi-scheduler 0.5.2 → 0.7.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/replay.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
1
2
  import { getLogger } from '@zhushanwen/pi-extension-logger'
2
3
 
3
- import type { ScheduledTask, SchedulerEntryOp, TaskSnapshot } from './types.js'
4
+ import { appendExecutionRecord, snapshotToTask, TASK_ENTRY_TYPE } from './types.js'
5
+ import type { ScheduledTask, SchedulerEntryOp } from './types.js'
4
6
 
5
7
  const logger = getLogger('scheduler')
6
8
 
@@ -15,16 +17,14 @@ export interface SchedulerEntryLike {
15
17
  data?: unknown
16
18
  }
17
19
 
18
- const HISTORY_LIMIT = 20
19
-
20
20
  /**
21
21
  * 从 CustomEntry 序列折叠恢复 per-task 末态(event sourcing)。
22
22
  *
23
23
  * session_start 重放入口:PiSchedulerBackend.loadTasks() 委托本函数,把当前 session 的
24
- * pi-scheduler:task custom entries 折叠成运行时任务 Map。
24
+ * TASK_ENTRY_TYPE custom entries 折叠成运行时任务 Map。
25
25
  *
26
26
  * 两步:
27
- * ① 全量折叠(foldEntries)——遍历 customType==='pi-scheduler:task' 的 entries,按 op 类型折叠:
27
+ * ① 全量折叠(foldEntries)——遍历 customType===TASK_ENTRY_TYPE 的 entries,按 op 类型折叠:
28
28
  * - upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)
29
29
  * - advance → nextRunAt=新值 / lastRunAt=at / runCount++ / history.push(裁20) / lastStatus=status(gap2)
30
30
  * - toggle → enabled=新值
@@ -50,7 +50,7 @@ export function replayFoldEntries(
50
50
  return tasks
51
51
  } catch (err) {
52
52
  // gap4:session JSONL 损坏 / 迭代器抛错时降级为无任务,不让 session_start 崩溃。
53
- logger.warn('replayFoldEntries failed', { error: err instanceof Error ? err.message : String(err) })
53
+ logger.warn('replayFoldEntries failed', { error: toErrorMessage(err) })
54
54
  return new Map()
55
55
  }
56
56
  }
@@ -58,7 +58,7 @@ export function replayFoldEntries(
58
58
  /** 步骤① 全量折叠:跳过非本扩展 entry 与守卫不过的损坏 entry;单条折叠抛错只跳过该条(MF-2)。 */
59
59
  function foldEntries(entries: Iterable<SchedulerEntryLike>, tasks: Map<string, ScheduledTask>): void {
60
60
  for (const entry of entries) {
61
- if (entry.type !== 'custom' || entry.customType !== 'pi-scheduler:task') continue
61
+ if (entry.type !== 'custom' || entry.customType !== TASK_ENTRY_TYPE) continue
62
62
  if (!isSchedulerEntryOp(entry.data)) continue
63
63
  const op = entry.data // SchedulerEntryOp(守卫 isSchedulerEntryOp 收窄)
64
64
 
@@ -68,7 +68,7 @@ function foldEntries(entries: Iterable<SchedulerEntryLike>, tasks: Map<string, S
68
68
  // MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
69
69
  // snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
70
70
  // 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
71
- logger.warn('skipping corrupted scheduler entry', { error: err instanceof Error ? err.message : String(err) })
71
+ logger.warn('skipping corrupted scheduler entry', { error: toErrorMessage(err) })
72
72
  continue
73
73
  }
74
74
  }
@@ -110,8 +110,7 @@ function applyAdvance(
110
110
  task.nextRunAt = op.nextRunAt
111
111
  task.lastRunAt = op.at
112
112
  task.runCount += 1
113
- task.history.push({ at: op.at, status: op.status })
114
- if (task.history.length > HISTORY_LIMIT) task.history.shift()
113
+ appendExecutionRecord(task, op.at, op.status)
115
114
  task.lastStatus = op.status // gap2:advance 必须恢复 lastStatus(不只 nextRunAt/lastRunAt/runCount/history)
116
115
  }
117
116
 
@@ -193,27 +192,3 @@ function isSchedulerEntryOp(data: unknown): data is SchedulerEntryOp {
193
192
  }
194
193
  return false
195
194
  }
196
-
197
- /**
198
- * 从 TaskSnapshot 重建 ScheduledTask(剥离 pending——运行时标记不持久化)。
199
- * history 深拷贝:避免快照与运行时 task 共享同一数组引用(upsert 后 task 继续被 mutate)。
200
- */
201
- function snapshotToTask(snapshot: TaskSnapshot): ScheduledTask {
202
- return {
203
- id: snapshot.id,
204
- name: snapshot.name,
205
- prompt: snapshot.prompt,
206
- kind: snapshot.kind,
207
- schedule: snapshot.schedule,
208
- enabled: snapshot.enabled,
209
- force: snapshot.force,
210
- createdAt: snapshot.createdAt,
211
- nextRunAt: snapshot.nextRunAt,
212
- expiresAt: snapshot.expiresAt,
213
- runCount: snapshot.runCount,
214
- lastRunAt: snapshot.lastRunAt,
215
- lastStatus: snapshot.lastStatus,
216
- lastError: snapshot.lastError,
217
- history: snapshot.history.map(h => ({ ...h })),
218
- }
219
- }
package/src/runtime.ts CHANGED
@@ -1,31 +1,25 @@
1
- import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
2
1
  import { guardStaleCtx, toErrorMessage } from '@zhushanwen/pi-ext-guards'
3
2
  import { getLogger } from '@zhushanwen/pi-extension-logger'
4
3
 
5
- import type { DeliveryHandle, DeliveryMessage } from '@xyz-agent/session-delivery'
6
-
7
4
  import type { SchedulerBackend } from './backend.js'
8
5
  import { autoName, generateTaskId } from './format.js'
9
6
  import { computeNextRunAt, MS_PER_DAY, MS_PER_MINUTE, parseDuration } from './parsing.js'
7
+ import { appendExecutionRecord, toTaskSnapshot } from './types.js'
10
8
  import type {
11
9
  AddOptions,
12
10
  ScheduledTask,
13
11
  SchedulerEntryOp,
14
12
  ScheduleSpec,
15
- TaskSnapshot,
16
13
  } from './types.js'
17
14
 
18
15
  const logger = getLogger('scheduler')
19
16
 
20
17
  const MAX_TASKS = 50
21
- // 入队防重标记 TTL 分钟数(合批非首条任务无终态回调,过期后放行重投;10 min >> 合批窗口)
22
- const QUEUE_DEDUPE_TTL_MINUTES = 10
23
- const QUEUE_DEDUPE_TTL_MS = QUEUE_DEDUPE_TTL_MINUTES * MS_PER_MINUTE
24
18
  const RATE_LIMIT_PER_MINUTE = 6
25
19
  const TICK_INTERVAL_MS = 30_000
26
20
  const DEFAULT_EXPIRY_DAYS = 7
27
21
  const DEFAULT_EXPIRY_MS = DEFAULT_EXPIRY_DAYS * MS_PER_DAY // 7 days
28
- const HISTORY_LIMIT = 20 // 与 replayFoldEntries 的裁剪上限一致(advance 折叠 / dispatch 累积共用)
22
+ // HISTORY_LIMIT 单点在 types.ts(ext-simplify-08 L5)——与 replay.ts 的 advance 折叠共用
29
23
  // STALE_CTX_MARKER(文案兜底分诊词)已迁移到 ext-guards 共享守卫(guardStaleCtx 内部
30
24
  // 引用,本文件不再直接持有)。语义:G1 模块级代际检测(isCtxStale)为主判;文案子串
31
25
  // 覆盖代际盲区——显式 reload / cwd 变化触发 clearExtensionCache 后 jiti 重新 import 产生
@@ -43,15 +37,6 @@ export class SchedulerRuntime {
43
37
  private readonly isCtxStale: (() => boolean) | undefined
44
38
  // R3-S1:同任务 dispatch 在途标记(Set<taskId>),见 dispatchTask 注释
45
39
  private readonly dispatchesInFlight = new Set<string>()
46
- // 入队防重标记(Map<taskId, enqueuedAt>):非 force 任务 send 进 delivery 内核后、
47
- // 终态回调(handleSettled)前,nextRunAt 未推进——tick step2 会按 `now >= nextRunAt`
48
- // 重新置 pending,若无此标记,agent busy 的每个 tick 都会再压一份同 prompt 副本进队列
49
- // (合批后重复注入)。入队置位、delivered/rejected 清除;任务删除(delete/过期)同步清除。
50
- // TTL 兜底:合批投递时 onSettled 只带首条 dedupeKey,非首条任务收不到终态回调——
51
- // 标记过期后允许重投,保持 at-least-once(与旧「nextRunAt 未推进下 tick 重投」等价)。
52
- private readonly queuedInDeliveryAt = new Map<string, number>()
53
- // delivery handle(装配点注入;非 force 任务走内核队列)
54
- private delivery: DeliveryHandle | undefined
55
40
 
56
41
  /**
57
42
  * 依赖反转构造:backend 承担 appendEntry/pi.sendMessage/时间源,runtime 只持有内存态。
@@ -61,17 +46,9 @@ export class SchedulerRuntime {
61
46
  * 替换。index.ts 装配点注入(模块级代数比对,R3-M1),使 stale 分诊不依赖 pi 错误文案;
62
47
  * 缺省(不注入)恒视为非 stale——纯 runtime 单测与旧装配路径行为不变。
63
48
  */
64
- constructor(
65
- backend: SchedulerBackend,
66
- ctx?: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
67
- isCtxStale?: () => boolean,
68
- ) {
49
+ constructor(backend: SchedulerBackend, isCtxStale?: () => boolean) {
69
50
  this.backend = backend
70
- // ctx 不再存实例变量(gate 已交内核);isCtxStale 保留用于代际检测
71
- void ctx
72
51
  this.isCtxStale = isCtxStale
73
- // 从 backend 获取 delivery handle(装配点注入)
74
- this.delivery = backend.getDeliveryHandle?.()
75
52
  }
76
53
 
77
54
  // ── 任务 CRUD ──
@@ -94,8 +71,8 @@ export class SchedulerRuntime {
94
71
  expiresAt = now + expiryMs
95
72
  }
96
73
 
97
- // 统一 nextRunAt 计算:interval → now + intervalMs;cron → 下次命中
98
- const nextRunAt = await computeNextRunAt(schedule, now)
74
+ // 统一 nextRunAt 计算:interval → now + intervalMs;cron → 下次命中(D2 后同步)
75
+ const nextRunAt = computeNextRunAt(schedule, now)
99
76
  if (nextRunAt === undefined) {
100
77
  // 创建时校验失败报错给用户(仅 cron 可能 undefined,interval 恒有值)
101
78
  const expr = schedule.mode === 'cron' ? schedule.cronExpression : '<unknown>'
@@ -109,7 +86,6 @@ export class SchedulerRuntime {
109
86
  kind,
110
87
  schedule,
111
88
  enabled: true,
112
- force: options.force ?? false,
113
89
  createdAt: now,
114
90
  nextRunAt,
115
91
  expiresAt,
@@ -124,7 +100,7 @@ export class SchedulerRuntime {
124
100
  taskId: id,
125
101
  // getSessionFile() 在 --no-session 模式返回 undefined → '' 兜底(该模式 appendEntry 无 owner 不落盘)
126
102
  ownerSessionFile: this.backend.getSessionFile() ?? '',
127
- task: this.toSnapshot(task),
103
+ task: toTaskSnapshot(task),
128
104
  })
129
105
  return task
130
106
  }
@@ -146,14 +122,9 @@ export class SchedulerRuntime {
146
122
  let recalcedNext: number | undefined
147
123
  // enable 时若 nextRunAt 已过期,重算,避免 enable 瞬间立即触发
148
124
  if (enabled && task.nextRunAt < this.backend.now()) {
149
- const next = await computeNextRunAt(task.schedule, this.backend.now())
125
+ const next = computeNextRunAt(task.schedule, this.backend.now())
150
126
  if (next === undefined) {
151
- // ERR-2 fallback:cron 表达式失效 → 停用任务并记录失败原因。
152
- // 禁止 `?? now()` 类 fallback(会使 nextRunAt=now,下个 tick 立即重算 → 死循环)
153
- task.enabled = false
154
- task.lastStatus = 'failed'
155
- task.lastError = 'cron expression invalid'
156
- // nextRunAt 保留原值(enabled=false 后 tick 不再触发)
127
+ this.disableForInvalidCron(task)
157
128
  } else {
158
129
  task.nextRunAt = next
159
130
  recalcedNext = next
@@ -179,7 +150,6 @@ export class SchedulerRuntime {
179
150
 
180
151
  deleteTask(id: string): boolean {
181
152
  const deleted = this.tasks.delete(id)
182
- if (deleted) this.queuedInDeliveryAt.delete(id)
183
153
  if (deleted) {
184
154
  this.appendEntrySafe({ op: 'delete', taskId: id })
185
155
  }
@@ -259,7 +229,6 @@ export class SchedulerRuntime {
259
229
  for (const [id, task] of this.tasks) {
260
230
  if (task.expiresAt && now >= task.expiresAt) {
261
231
  this.tasks.delete(id)
262
- this.queuedInDeliveryAt.delete(id)
263
232
  this.appendEntrySafe({ op: 'delete', taskId: id })
264
233
  }
265
234
  }
@@ -285,24 +254,20 @@ export class SchedulerRuntime {
285
254
 
286
255
  // W2:tick 完成后刷新 widget(index.ts 注册 refreshWidget)
287
256
  this.onAfterTickCallback?.()
288
-
289
- // delivery flush:park 模式下内核不主动重试,由 scheduler tick 外部触发 flush
290
- // 让积压在队列中的消息在每个 tick 尝试投递
291
- this.delivery?.flush()
292
257
  }
293
258
 
294
259
  // ── dispatch ──
295
260
 
296
261
  /**
297
- * dispatch 单个任务。返回 true 表示真的发送了 message,false 表示 no-op
298
- * (task disabled / 已有同任务在途 / rate-limited / 非 force 且 busy)。
262
+ * dispatch 单个任务。返回 true 表示消息已发出(pi.sendMessage 受理),false 表示
263
+ * no-op(task disabled / 已有同任务在途 / rate-limited)。
299
264
  *
300
265
  * R3-S1 in-flight 守卫:tick 为 fire-and-forget,若 tick1 的 `await backend.sendMessage`
301
266
  * 挂起超过 TICK_INTERVAL_MS(如 pi 卡死),tick2 的 step2 会再标 pending、step3 对同一
302
- * task 并发第二个 dispatch → 同一 prompt 双注入(force 任务绕过 isIdle gate 直接受影响)。
303
- * 参照 subagent-workflow resumesInFlight 模式:入口同步置位、finally 清除(覆盖 gate /
304
- * rate-limit / sendMessage 抛错 / 成功推进全部退出路径);命中时 skip 本轮并 warn
305
- * (不 throw——tick 继续处理其他任务,本任务 pending 保留到下轮重试)。
267
+ * task 并发第二个 dispatch → 同一 prompt 双注入。参照 subagent-workflow resumesInFlight
268
+ * 模式:入口同步置位、finally 清除(覆盖 gate / rate-limit / sendMessage 抛错 / 成功推进
269
+ * 全部退出路径);命中时 skip 本轮并 warn(不 throw——tick 继续处理其他任务,本任务
270
+ * pending 保留到下轮重试)。
306
271
  */
307
272
  async dispatchTask(task: ScheduledTask): Promise<boolean> {
308
273
  if (!task.enabled) return false
@@ -321,7 +286,13 @@ export class SchedulerRuntime {
321
286
  /**
322
287
  * dispatch 本体(dispatchTask 守卫置位后执行;runTaskNow 与 tick step3 共用入口,
323
288
  * 手动 run-now 与挂起中的 tick dispatch 并发时同样被守卫拦截)。
324
- * sendMessage 抛错时记录 failed 状态但不 rethrow,让 tick 继续处理其他任务。
289
+ * steer 直投(scheduler-steer-direct-dispatch 设计):{deliverAs:'steer', triggerTurn:true}
290
+ * 在 pi 侧的两分支——busy 时 steer 插入当前 turn(立即被模型看到)、idle 时开新 turn。
291
+ * 受理即记账:pi extension API sendMessage 是 fire-and-forget(返回 void,错误走
292
+ * pi 内部 emitError 通道),await 立即通过,无「入队未终态」窗口——nextRunAt 调用即推进,
293
+ * tick 不重标 pending,无需防重标记。
294
+ * sendMessage 抛错(同步异常,session 关闭等)时记录 failed 状态但不 rethrow,
295
+ * 让 tick 继续处理其他任务。
325
296
  *
326
297
  * 持久化(append-only):recurring 成功推进 nextRunAt → append advance(status='success' CL8);
327
298
  * once 成功 → append delete。失败 dispatch 不 append(CL7 重试语义,transient 失败 nextRunAt 未推进)。
@@ -330,95 +301,39 @@ export class SchedulerRuntime {
330
301
  // 检查速率限制
331
302
  if (!this.hasDispatchCapacity(this.backend.now())) return false
332
303
 
333
- if (task.force || !this.delivery) {
334
- // force 任务或无 delivery handle 时直投(绕过内核队列)
335
- return this.dispatchDirect(task)
336
- }
337
-
338
- // 已在内核队列中(入队后未终态且未过 TTL)——step2 会按未推进的 nextRunAt 重新置
339
- // pending,此处拦截防重复入队(见 queuedInDeliveryAt 字段注释)
340
- const queuedAt = this.queuedInDeliveryAt.get(task.id)
341
- if (queuedAt !== undefined && this.backend.now() - queuedAt < QUEUE_DEDUPE_TTL_MS) return false
342
-
343
- // 非 force 任务走 delivery 内核(park 模式:busy 入队等下次 tick flush)
344
- return this.dispatchViaDelivery(task)
345
- }
346
-
347
- /**
348
- * force 任务直投:绕过 delivery 内核队列,直接调 backend.sendMessage。
349
- * 无 delivery handle 时也走此路径(向后兼容)。
350
- */
351
- private async dispatchDirect(task: ScheduledTask): Promise<boolean> {
352
304
  try {
353
305
  await this.backend.sendMessage(
354
306
  { content: task.prompt, customType: 'pi-scheduler:dispatched', display: true },
355
- { deliverAs: 'followUp', triggerTurn: true },
307
+ { deliverAs: 'steer', triggerTurn: true },
356
308
  )
357
309
  } catch {
358
310
  task.lastStatus = 'failed'
359
311
  task.pending = false
360
- task.history.push({ at: this.backend.now(), status: 'failed' })
361
- if (task.history.length > HISTORY_LIMIT) task.history.shift()
312
+ appendExecutionRecord(task, this.backend.now(), 'failed')
362
313
  return false
363
314
  }
364
315
  return this.onDispatchSuccess(task)
365
316
  }
366
317
 
367
318
  /**
368
- * 非 force 任务走 delivery 内核:入队后由内核 flush 时投递。
369
- * gate(isIdle/hasPendingMessages)由内核管理,busy 时入队不重试(park 模式)。
370
- * onSettled 回调处理成功/失败记账。
371
- * 返回 true 表示已入队(非实际发送)。
372
- */
373
- private dispatchViaDelivery(task: ScheduledTask): boolean {
374
- const delivery = this.delivery!
375
-
376
- delivery.send({
377
- payload: {
378
- kind: 'custom',
379
- customType: 'pi-scheduler:dispatched',
380
- content: task.prompt,
381
- display: true,
382
- },
383
- intent: 'after-run',
384
- // #11:task.id 作为 onSettled 反查键(本 handle 未开 dedupe,dedupeKey 不驱动
385
- // 去重,仅随消息透传给 onSettled 回调)。content 反查在同 prompt 多任务下错配。
386
- dedupeKey: task.id,
387
- })
388
-
389
- // send() 不 throw(park 模式下入队即返回)。
390
- // 入队即挂防重标记 + 计入速率限制(nextRunAt 要等 delivered 后才推进,期间 step2
391
- // 会持续重标 pending——防重标记拦截重复入队,速率记账覆盖入队侧消耗)。
392
- // 成功/失败记账由 onSettled 回调异步处理。
393
- this.queuedInDeliveryAt.set(task.id, this.backend.now())
394
- this.dispatchTimestamps.push(this.backend.now())
395
- task.pending = false
396
- return true
397
- }
398
-
399
- /**
400
- * dispatch 成功后的状态更新与持久化(dispatchDirect 成功后、onSettled delivered 后共用)。
401
- * countRate=false 时不再计速率(delivery 路径入队时已计入,delivered 再计会双算)。
319
+ * dispatch 成功后的状态更新与持久化(dispatchTaskInner 受理成功后调用)。
402
320
  */
403
- private async onDispatchSuccess(task: ScheduledTask, countRate = true): Promise<boolean> {
321
+ private async onDispatchSuccess(task: ScheduledTask): Promise<boolean> {
404
322
  const now = this.backend.now()
405
323
  task.runCount++
406
324
  task.lastRunAt = now
407
325
  task.lastStatus = 'success'
408
326
  task.pending = false
409
327
  task.lastError = undefined
410
- task.history.push({ at: now, status: 'success' })
411
- if (task.history.length > HISTORY_LIMIT) task.history.shift()
328
+ appendExecutionRecord(task, now, 'success')
412
329
 
413
330
  if (task.kind === 'once') {
414
331
  this.tasks.delete(task.id)
415
332
  this.appendEntrySafe({ op: 'delete', taskId: task.id })
416
333
  } else {
417
- const next = await computeNextRunAt(task.schedule, now)
334
+ const next = computeNextRunAt(task.schedule, now)
418
335
  if (next === undefined) {
419
- task.enabled = false
420
- task.lastStatus = 'failed'
421
- task.lastError = 'cron expression invalid'
336
+ this.disableForInvalidCron(task)
422
337
  } else {
423
338
  task.nextRunAt = next
424
339
  this.appendEntrySafe({
@@ -431,47 +346,28 @@ export class SchedulerRuntime {
431
346
  }
432
347
  }
433
348
 
434
- if (countRate) this.dispatchTimestamps.push(now)
349
+ this.dispatchTimestamps.push(now)
435
350
  return true
436
351
  }
437
352
 
438
- /**
439
- * onSettled 回调入口(index.ts 装配点绑定):delivery 内核投递终态时调用。
440
- * delivered → 成功记账(onDispatchSuccess);rejected → 失败记账。
441
- * once 任务失败不删持久化(at-least-once 语义)。
442
- * #11:按 msg.dedupeKey(dispatch 时挂的 task.id,见 dispatchViaDelivery)精确反查
443
- * tasks Map——旧 content 反查在「同 prompt 多任务」下错配(find 取首个命中),
444
- * 任务已删除时静默丢弃不误记。反查未命中(once 成功已删 / 批量合投非首条 /
445
- * 非 scheduler 消息)直接返回。
446
- * 已知限制:内核 busy 期间多条消息合投为一批时(doSend splice 全队列),onSettled
447
- * 只收到保留首条 dedupeKey 的 composed 消息——非首条任务不记账,靠下个 tick 的
448
- * nextRunAt 未推进重投(at-least-once 兜底),与旧 content 反查行为等价不劣化。
449
- */
450
- handleSettled(msg: DeliveryMessage, outcome: 'delivered' | 'rejected'): void {
451
- const taskId = msg.dedupeKey
452
- // 防重标记先清(任务可能已被删除,反查未命中也要清)
453
- if (taskId !== undefined) this.queuedInDeliveryAt.delete(taskId)
454
- const task = taskId !== undefined ? this.tasks.get(taskId) : undefined
455
- if (!task) return // 任务已被删除(once 成功后删)或非 scheduler 发出的消息
456
-
457
- if (outcome === 'delivered') {
458
- // fire-and-forget:onDispatchSuccess 内部 catch 不 rethrow(countRate=false:入队时已计速率)
459
- void this.onDispatchSuccess(task, false).catch(() => {})
460
- } else {
461
- // 失败记账
462
- task.lastStatus = 'failed'
463
- task.history.push({ at: this.backend.now(), status: 'failed' })
464
- if (task.history.length > HISTORY_LIMIT) task.history.shift()
465
- // once 任务失败不删持久化(at-least-once 语义)
466
- }
467
- }
468
-
469
353
  private hasDispatchCapacity(now: number): boolean {
470
354
  const oneMinuteAgo = now - MS_PER_MINUTE
471
355
  this.dispatchTimestamps = this.dispatchTimestamps.filter(t => t > oneMinuteAgo)
472
356
  return this.dispatchTimestamps.length < RATE_LIMIT_PER_MINUTE
473
357
  }
474
358
 
359
+ /**
360
+ * ERR-2 fallback(ext-simplify-17 B3 抽取):cron 表达式失效 → 停用任务并记录失败原因
361
+ * (toggle enable 重算与 dispatch 成功推进两处共用)。禁止 `?? now()` 类 fallback
362
+ * (会使 nextRunAt=now,下个 tick 立即重算 → 死循环);nextRunAt 保留原值——
363
+ * enabled=false 后 tick 不再触发。
364
+ */
365
+ private disableForInvalidCron(task: ScheduledTask): void {
366
+ task.enabled = false
367
+ task.lastStatus = 'failed'
368
+ task.lastError = 'cron expression invalid'
369
+ }
370
+
475
371
  // ── 装配与回调 ──
476
372
 
477
373
  /** 装配点注入初始任务数组(读盘/重放由 backend 完成,runtime 只持有内存态)。 */
@@ -492,16 +388,7 @@ export class SchedulerRuntime {
492
388
  } catch (err) {
493
389
  // best-effort 降级(ER-APPEND-FAIL):append-only 模型下 append 失败仅丢失该 op 的持久化,
494
390
  // 内存态已先行更新、不 rethrow,业务流程继续。at-least-once 已知恶化窗口(resume 重放回退)。
495
- logger.warn('appendEntry failed', { error: err instanceof Error ? err.message : String(err) })
391
+ logger.warn('appendEntry failed', { error: toErrorMessage(err) })
496
392
  }
497
393
  }
498
-
499
- /**
500
- * ScheduledTask → TaskSnapshot:剥离 ownerSessionFile(在 op 顶层)与 pending(运行时标记),
501
- * history 深拷贝(避免快照与运行时 task 共享数组引用)。
502
- */
503
- private toSnapshot(task: ScheduledTask): TaskSnapshot {
504
- const { ownerSessionFile: _o, pending: _p, history, ...rest } = task
505
- return { ...rest, history: history.slice() }
506
- }
507
394
  }
package/src/service.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
2
+
1
3
  import { formatRelativeTime, formatSchedule } from './format.js'
2
4
  import { computeNextRuns, parseSchedule } from './parsing.js'
3
5
  import type { SchedulerRuntime } from './runtime.js'
@@ -8,18 +10,12 @@ const PREVIEW_RUN_COUNT = 5
8
10
 
9
11
  // ── 结构化结果 ──
10
12
 
11
- export type ServiceErrorCode =
12
- | 'TASK_NOT_FOUND'
13
- | 'INVALID_SCHEDULE'
14
- | 'TASK_LIMIT_REACHED'
15
- | 'DISPATCH_SKIPPED'
16
- | 'INVALID_PARAMS'
17
- | 'INTERNAL'
13
+ // L1(ext-simplify-08):errorCode 枚举与字段已删——W4 后失败通道收敛为 throw,
14
+ // tool/command 两层均零消费方;失败语义由 message 文案承载。
18
15
 
19
16
  export interface ServiceResult<T = unknown> {
20
17
  success: boolean
21
18
  message: string
22
- errorCode?: ServiceErrorCode
23
19
  data?: T
24
20
  }
25
21
 
@@ -29,7 +25,7 @@ export interface ServiceResult<T = unknown> {
29
25
  * tool 与 command 的唯一业务入口(IF-4 去双轨):
30
26
  * 5 个动作单一实现,返回结构化 ServiceResult。
31
27
  * - 成功: { success: true, message, data }
32
- * - 失败: { success: false, errorCode, message }
28
+ * - 失败: { success: false, message }
33
29
  *
34
30
  * message 为用户可读纯文本(tool 用作 content 文本、command 直接输出),
35
31
  * data 供 tool details(create: {task, nextRuns};list: {tasks})。
@@ -41,39 +37,37 @@ export class SchedulerService {
41
37
  * 创建任务。
42
38
  * 注意:create 接收原始 schedule 字符串、内部 parseSchedule(而非已解析的
43
39
  * ScheduleSpec)——这是对 IF-4 草案 create(parseResult) 的有意细化:
44
- * 解析失败需要结构化 INVALID_SCHEDULE 返回,把解析责任留在 service 内,
45
- * tool/command 两层都不需要重复 parseSchedule。
40
+ * 解析失败需要结构化失败返回(success=false + 用户可读 message),把解析责任留在
41
+ * service 内,tool/command 两层都不需要重复 parseSchedule。
46
42
  */
47
43
  async create(
48
44
  prompt: string,
49
45
  scheduleInput: string,
50
46
  options: AddOptions = {},
51
47
  ): Promise<ServiceResult<{ task: ScheduledTask; nextRuns: number[] }>> {
52
- const parsed = await parseSchedule(scheduleInput)
48
+ // parseSchedule 同步化(D2)后返回值即 ScheduleSpec(L7:不再包装 { spec });
49
+ // undefined 唯一语义 = 表达式无效——croner 是 dependencies 恒在盘,不存在解析器缺失
50
+ const parsed = parseSchedule(scheduleInput)
53
51
  if (!parsed) {
54
52
  return {
55
53
  success: false,
56
- errorCode: 'INVALID_SCHEDULE',
57
54
  message: `Invalid schedule: "${scheduleInput}". Use duration (5m/2h/1d) or cron expression (*/10 * * * *).`,
58
55
  }
59
56
  }
60
57
 
61
58
  let task: ScheduledTask
62
59
  try {
63
- task = await this.runtime.addTask(prompt, parsed.spec, options)
60
+ task = await this.runtime.addTask(prompt, parsed, options)
64
61
  } 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 }
62
+ // 失败归一 message 通道(L1):任务上限 / 意外错误(正常路径不会到达——
63
+ // parseSchedule 已校验 cron 有效性)同样回传原始 message
64
+ return { success: false, message: toErrorMessage(err) }
71
65
  }
72
66
 
73
67
  const count = task.kind === 'once' ? 1 : PREVIEW_RUN_COUNT
74
68
  // 消息内相对时间与 nextRuns 必须同基准:分开读时钟会在整点边界漂移(in 1h → in 59m)
75
69
  const now = this.now()
76
- const nextRuns = await computeNextRuns(task.schedule, now, count)
70
+ const nextRuns = computeNextRuns(task.schedule, now, count)
77
71
  // once 单行内联回显(只执行 1 次,编号列表会误导);recurring 保持 5 行编号列表
78
72
  const runPreview =
79
73
  task.kind === 'once'
@@ -82,14 +76,13 @@ export class SchedulerService {
82
76
  'Next 5 runs:',
83
77
  ...nextRuns.map((t, i) => ` ${i + 1}. ${formatRelativeTime(t, now)}`),
84
78
  ].join('\n')
85
- // 一行紧凑:name(id) + schedule(含 kind 信息) + expires + force。
86
- // 删冗余 Kind 行(formatSchedule 已含 once/every);Expires/Force 合并(默认 no-expires/no-force 显式)。
79
+ // 一行紧凑:name(id) + schedule(含 kind 信息) + expires。
80
+ // 删冗余 Kind 行(formatSchedule 已含 once/every);Expires 合并(默认 no-expires 显式)。
87
81
  const expiresLabel = task.expiresAt
88
82
  ? `expires ${formatRelativeTime(task.expiresAt, now)}`
89
83
  : 'no-expires'
90
- const forceLabel = task.force ? 'force' : 'no-force'
91
84
  const message = [
92
- `Task "${task.name}" (${task.id}) created. ${formatSchedule(task.schedule, task.kind)}, ${expiresLabel}, ${forceLabel}`,
85
+ `Task "${task.name}" (${task.id}) created. ${formatSchedule(task.schedule, task.kind)}, ${expiresLabel}`,
93
86
  runPreview,
94
87
  ].join('\n')
95
88
 
@@ -111,49 +104,47 @@ export class SchedulerService {
111
104
 
112
105
  async toggle(id: string | undefined, enabled: boolean | undefined): Promise<ServiceResult> {
113
106
  if (!id) {
114
- return { success: false, errorCode: 'INVALID_PARAMS', message: 'id is required for toggle.' }
107
+ return { success: false, message: 'id is required for toggle.' }
115
108
  }
116
109
  if (enabled === undefined) {
117
- return { success: false, errorCode: 'INVALID_PARAMS', message: 'enabled is required for toggle.' }
110
+ return { success: false, message: 'enabled is required for toggle.' }
118
111
  }
119
112
  const success = await this.runtime.toggleTask(id, enabled)
120
113
  if (!success) {
121
- return { success: false, errorCode: 'TASK_NOT_FOUND', message: `Task ${id} not found.` }
114
+ return { success: false, message: `Task ${id} not found.` }
122
115
  }
123
116
  return { success: true, message: `Task ${id} ${enabled ? 'enabled' : 'disabled'}.` }
124
117
  }
125
118
 
126
119
  delete(id: string | undefined): ServiceResult {
127
120
  if (!id) {
128
- return { success: false, errorCode: 'INVALID_PARAMS', message: 'id is required for delete.' }
121
+ return { success: false, message: 'id is required for delete.' }
129
122
  }
130
123
  const success = this.runtime.deleteTask(id)
131
124
  if (!success) {
132
- return { success: false, errorCode: 'TASK_NOT_FOUND', message: `Task ${id} not found.` }
125
+ return { success: false, message: `Task ${id} not found.` }
133
126
  }
134
127
  return { success: true, message: `Task ${id} deleted.` }
135
128
  }
136
129
 
137
130
  /**
138
131
  * 立即执行任务。语义细分:
139
- * 任务不存在 → TASK_NOT_FOUND;任务存在但 dispatch no-op
140
- * (disabled / rate-limited / 同任务入队已在 TTL 窗口内)→ DISPATCH_SKIPPED。
141
- * busy 不再是 no-op:非 force 任务入队 delivery 内核即成功(park 等后续投递)。
132
+ * 任务不存在 → not found 文案;任务存在但 dispatch no-op
133
+ * (disabled / rate-limited / 同任务在途)→ not dispatched 文案。
142
134
  * 修复了旧实现把 no-op 误报为 not found 的混同。
143
135
  */
144
136
  async run(id: string | undefined): Promise<ServiceResult> {
145
137
  if (!id) {
146
- return { success: false, errorCode: 'INVALID_PARAMS', message: 'id is required for run.' }
138
+ return { success: false, message: 'id is required for run.' }
147
139
  }
148
140
  if (!this.runtime.getTask(id)) {
149
- return { success: false, errorCode: 'TASK_NOT_FOUND', message: `Task ${id} not found.` }
141
+ return { success: false, message: `Task ${id} not found.` }
150
142
  }
151
143
  const dispatched = await this.runtime.runTaskNow(id)
152
144
  if (!dispatched) {
153
145
  return {
154
146
  success: false,
155
- errorCode: 'DISPATCH_SKIPPED',
156
- message: `Task ${id} not dispatched (disabled, rate-limited, or already queued for delivery).`,
147
+ message: `Task ${id} not dispatched (disabled, rate-limited, or dispatch in flight).`,
157
148
  }
158
149
  }
159
150
  return { success: true, message: `Task ${id} executed.` }