@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.
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { snapshotToTask, toTaskSnapshot } from '../types.js'
4
+ import type { ScheduledTask, TaskSnapshot } from '../types.js'
5
+
6
+ /**
7
+ * B2 转换器收敛(ext-simplify-17)等价守卫:toTaskSnapshot canonical 取解构式,
8
+ * 替换 importer 旧显式 14 字段式——期望值按旧显式式的输出形状逐字段锚定
9
+ * (toEqual 严格比较键集合,解构式多带/漏带键即红),防字段演进中静默漂移。
10
+ * 既有间接断言(importer/runtime 测试的 objectContaining)不覆盖键集合精确性与
11
+ * 可选字段传递,此处补直接等价证据。
12
+ */
13
+
14
+ /** 全字段 ScheduledTask(含全部可选字段 + 两个运行时字段 + 非空 history)。 */
15
+ function fullTask(): ScheduledTask {
16
+ return {
17
+ id: 'abcd1234',
18
+ name: 'test task',
19
+ prompt: 'do something',
20
+ kind: 'recurring',
21
+ schedule: { mode: 'interval', intervalMs: 60000 },
22
+ enabled: true,
23
+ createdAt: 1000,
24
+ nextRunAt: 2000,
25
+ expiresAt: 9999,
26
+ runCount: 3,
27
+ lastRunAt: 1500,
28
+ lastStatus: 'success',
29
+ lastError: 'prev error',
30
+ history: [
31
+ { at: 1400, status: 'failed' },
32
+ { at: 1500, status: 'success' },
33
+ ],
34
+ ownerSessionFile: '/x/sess.json',
35
+ pending: true,
36
+ }
37
+ }
38
+
39
+ /**
40
+ * fullTask 对应的期望快照——字面量逐字段写出(不用解构从 fullTask 推导,
41
+ * 避免期望值与被测实现共享同一剥离逻辑而失去独立性),形状 = 旧显式式输出。
42
+ */
43
+ function expectedSnapshot(): TaskSnapshot {
44
+ return {
45
+ id: 'abcd1234',
46
+ name: 'test task',
47
+ prompt: 'do something',
48
+ kind: 'recurring',
49
+ schedule: { mode: 'interval', intervalMs: 60000 },
50
+ enabled: true,
51
+ createdAt: 1000,
52
+ nextRunAt: 2000,
53
+ expiresAt: 9999,
54
+ runCount: 3,
55
+ lastRunAt: 1500,
56
+ lastStatus: 'success',
57
+ lastError: 'prev error',
58
+ history: [
59
+ { at: 1400, status: 'failed' },
60
+ { at: 1500, status: 'success' },
61
+ ],
62
+ }
63
+ }
64
+
65
+ describe('toTaskSnapshot / snapshotToTask(B2 收敛等价)', () => {
66
+ it('toTaskSnapshot: 全字段 task → 恰好 14 字段快照(剥离 ownerSessionFile/pending,值逐一相等)', () => {
67
+ expect(toTaskSnapshot(fullTask())).toEqual(expectedSnapshot())
68
+ })
69
+
70
+ it('toTaskSnapshot: history 是数组拷贝——task 后续 push/shift 不污染已产出的快照', () => {
71
+ const task = fullTask()
72
+ const snapshot = toTaskSnapshot(task)
73
+ expect(snapshot.history).not.toBe(task.history)
74
+ task.history.push({ at: 9999, status: 'failed' })
75
+ task.history.shift()
76
+ expect(snapshot.history).toEqual([
77
+ { at: 1400, status: 'failed' },
78
+ { at: 1500, status: 'success' },
79
+ ])
80
+ })
81
+
82
+ it('snapshotToTask: 快照 → 恢复 task 不含运行时字段、字段逐一相等、history 逐项拷贝', () => {
83
+ const source = expectedSnapshot()
84
+ const task = snapshotToTask(source)
85
+ expect(task).toEqual(source)
86
+ expect('ownerSessionFile' in task).toBe(false)
87
+ expect('pending' in task).toBe(false)
88
+ // 逐项深拷贝:恢复的 task 后续 mutate 不污染快照(数组与元素均独立)
89
+ expect(task.history).not.toBe(source.history)
90
+ expect(task.history[0]).not.toBe(source.history[0])
91
+ })
92
+ })
@@ -10,7 +10,6 @@ const makeTask = (overrides: Partial<ScheduledTask> = {}): ScheduledTask => ({
10
10
  kind: 'recurring',
11
11
  schedule: { mode: 'interval', intervalMs: 60000 },
12
12
  enabled: true,
13
- force: false,
14
13
  createdAt: Date.now(),
15
14
  nextRunAt: Date.now() + 60000,
16
15
  runCount: 0,
package/src/backend.ts CHANGED
@@ -1,17 +1,34 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
- import type { DeliveryHandle } from '@xyz-agent/session-delivery'
3
2
 
4
3
  import { replayFoldEntries, type SchedulerEntryLike } from './replay.js'
4
+ import { TASK_ENTRY_TYPE } from './types.js'
5
5
  import type { ScheduledTask, SchedulerEntryOp } from './types.js'
6
6
 
7
7
  // ── SchedulerBackend 接口 ──
8
8
 
9
+ /**
10
+ * sendMessage 的 msg 形状(ext-simplify-17 B5):与 pi 的 CustomMessage 鸭子对齐
11
+ * (customType/display 必填)——pi 根入口不导出 CustomMessage,鸭子解耦只能文件内收敛,
12
+ * 接口与实现两处共用(测试 mock-backend 持结构兼容的内联签名,不引用本 alias)。
13
+ */
14
+ interface SchedulerMessage {
15
+ content: string
16
+ customType: string
17
+ display: boolean
18
+ }
19
+
20
+ /** sendMessage 的 opts 形状:steer 直投选项(scheduler-steer-direct-dispatch 投递模型)。 */
21
+ interface SchedulerSendOptions {
22
+ deliverAs?: 'steer'
23
+ triggerTurn?: boolean
24
+ }
25
+
9
26
  /**
10
27
  * 运行时协作后端抽象(依赖反转)。SchedulerRuntime 只依赖此接口:
11
28
  * 不触碰 session JSONL、不持有 pi。
12
29
  *
13
30
  * - sendMessage: 到期 dispatch 的消息注入(生产实现委托 pi.sendMessage)
14
- * - appendEntry: 按 op 写 pi-scheduler:task custom entry(event sourcing)。
31
+ * - appendEntry: 按 op 写 TASK_ENTRY_TYPE custom entry(event sourcing)。
15
32
  * 生产实现委托 pi.appendEntry(同步落盘)。失败必须被调用方 try-catch(ER-APPEND-FAIL:
16
33
  * runtime 捕获后 logger.warn + 不 rethrow,内存态已更新,at-least-once 已知恶化窗口)
17
34
  * - getSessionFile: 当前 session JSONL 路径(addTask 构建 upsert op 的 ownerSessionFile 用;
@@ -25,17 +42,16 @@ import type { ScheduledTask, SchedulerEntryOp } from './types.js'
25
42
  *
26
43
  * sendMessage 的 msg 签名与 pi 的 CustomMessage 对齐(customType/display 必填):
27
44
  * 调用方必须显式提供,PiSchedulerBackend 直接透传无需兜底默认值。
45
+ *
46
+ * delivery handle 不在本接口(ext-simplify-08 L2):backend 自身不消费,曾以
47
+ * setDeliveryHandle/getDeliveryHandle 中转给 runtime 属穿层传参——现由装配点经
48
+ * SchedulerRuntime 构造器直传。
28
49
  */
29
50
  export interface SchedulerBackend {
30
- sendMessage(
31
- msg: { content: string; customType: string; display: boolean },
32
- opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean },
33
- ): Promise<void>
51
+ sendMessage(msg: SchedulerMessage, opts?: SchedulerSendOptions): Promise<void>
34
52
  appendEntry(op: SchedulerEntryOp): void
35
53
  getSessionFile(): string | undefined
36
54
  now(): number
37
- /** 获取 delivery handle(装配点注入;未注入时 force 路径仍用 sendMessage 直投)。 */
38
- getDeliveryHandle?(): DeliveryHandle | undefined
39
55
  }
40
56
 
41
57
  /**
@@ -62,7 +78,6 @@ export interface SchedulerBackendCtx {
62
78
  export class PiSchedulerBackend implements SchedulerBackend {
63
79
  private ctx: SchedulerBackendCtx
64
80
  private pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>
65
- private deliveryHandle: DeliveryHandle | undefined
66
81
 
67
82
  constructor(ctx: SchedulerBackendCtx, pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>) {
68
83
  this.ctx = ctx
@@ -70,7 +85,7 @@ export class PiSchedulerBackend implements SchedulerBackend {
70
85
  }
71
86
 
72
87
  /**
73
- * 读路径:折叠当前 session 的 pi-scheduler:task custom entries 恢复任务(非接口成员,
88
+ * 读路径:折叠当前 session 的 TASK_ENTRY_TYPE custom entries 恢复任务(非接口成员,
74
89
  * 由装配点 session_start 调用)。replayFoldEntries 内部含 fork owner 过滤与异常兜底。
75
90
  */
76
91
  loadTasks(): ScheduledTask[] {
@@ -79,15 +94,12 @@ export class PiSchedulerBackend implements SchedulerBackend {
79
94
  ]
80
95
  }
81
96
 
82
- async sendMessage(
83
- msg: { content: string; customType: string; display: boolean },
84
- opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean },
85
- ): Promise<void> {
97
+ async sendMessage(msg: SchedulerMessage, opts?: SchedulerSendOptions): Promise<void> {
86
98
  await this.pi.sendMessage(msg, opts)
87
99
  }
88
100
 
89
101
  appendEntry(op: SchedulerEntryOp): void {
90
- this.pi.appendEntry('pi-scheduler:task', op)
102
+ this.pi.appendEntry(TASK_ENTRY_TYPE, op)
91
103
  }
92
104
 
93
105
  getSessionFile(): string | undefined {
@@ -97,76 +109,4 @@ export class PiSchedulerBackend implements SchedulerBackend {
97
109
  now(): number {
98
110
  return Date.now()
99
111
  }
100
-
101
- /**
102
- * 注入 delivery handle(装配点创建后调用)。
103
- * runtime 通过 getDeliveryHandle() 获取,非 force 任务走内核队列。
104
- */
105
- setDeliveryHandle(handle: DeliveryHandle): void {
106
- this.deliveryHandle = handle
107
- }
108
-
109
- getDeliveryHandle(): DeliveryHandle | undefined {
110
- return this.deliveryHandle
111
- }
112
- }
113
-
114
- // ── 测试实现 ──
115
-
116
- export interface SentMessage {
117
- msg: { content: string; customType: string; display: boolean }
118
- opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean }
119
- }
120
-
121
- /**
122
- * Mock 后端:零 session/FS 副作用,记录 sendMessage/appendEntry 调用,支持注入固定时间与 fake entries。
123
- * 与 SchedulerBackend/PiSchedulerBackend 同文件 export(测试从 './backend.js' import)。
124
- *
125
- * 能力:
126
- * - sentMessages: 每次 sendMessage 的 {msg, opts} 记录
127
- * - appendedOps: 每次 appendEntry 收到的 SchedulerEntryOp(测 runtime 各 op 断言)
128
- * - fakeEntries / fakeSessionFile: loadTasks 经 replayFoldEntries 的注入源(测 backend→replay 委托)
129
- * - nowValue: now() 返回固定值,缺省 Date.now()
130
- * - appendError: 注入后 appendEntry 抛该错(测 ER-APPEND-FAIL 捕获路径)
131
- */
132
- export class MockSchedulerBackend implements SchedulerBackend {
133
- sentMessages: SentMessage[] = []
134
- appendedOps: SchedulerEntryOp[] = []
135
- fakeEntries: SchedulerEntryLike[] = []
136
- fakeSessionFile: string | undefined = '/test/session.json'
137
- nowValue: number | undefined
138
- appendError: Error | null = null
139
- deliveryHandle: DeliveryHandle | undefined
140
-
141
- async sendMessage(
142
- msg: { content: string; customType: string; display: boolean },
143
- opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean },
144
- ): Promise<void> {
145
- this.sentMessages.push({ msg, opts })
146
- }
147
-
148
- appendEntry(op: SchedulerEntryOp): void {
149
- if (this.appendError) throw this.appendError
150
- this.appendedOps.push(op)
151
- }
152
-
153
- getSessionFile(): string | undefined {
154
- return this.fakeSessionFile
155
- }
156
-
157
- now(): number {
158
- return this.nowValue ?? Date.now()
159
- }
160
-
161
- getDeliveryHandle(): DeliveryHandle | undefined {
162
- return this.deliveryHandle
163
- }
164
-
165
- /**
166
- * 读路径(非接口成员,与 PiSchedulerBackend.loadTasks 对称):经 replayFoldEntries 折叠
167
- * fakeEntries + fakeSessionFile 恢复任务。测试用它验证 backend→replay 委托(TC-W-BACKEND-REPLAY)。
168
- */
169
- loadTasks(): ScheduledTask[] {
170
- return [...replayFoldEntries(this.fakeEntries, this.fakeSessionFile).values()]
171
- }
172
112
  }
package/src/importer.ts CHANGED
@@ -4,9 +4,11 @@ import * as path from 'node:path'
4
4
 
5
5
  import { getAgentDir } from '@earendil-works/pi-coding-agent'
6
6
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
7
+ import { isEnoentError, toErrorMessage } from '@zhushanwen/pi-ext-guards'
7
8
  import { getLogger } from '@zhushanwen/pi-extension-logger'
8
9
 
9
- import type { ScheduledTask, SchedulerEntryOp, SchedulerStore, TaskSnapshot } from './types.js'
10
+ import { TASK_ENTRY_TYPE, toTaskSnapshot } from './types.js'
11
+ import type { ScheduledTask, SchedulerEntryOp, SchedulerStore } from './types.js'
10
12
 
11
13
  const logger = getLogger('scheduler')
12
14
 
@@ -40,7 +42,7 @@ export function getLegacyStorePath(cwd: string): string {
40
42
 
41
43
  /**
42
44
  * 旧 store 任务字段补全(参考 store.ts load() 默认值兜底):旧数据可能缺字段,逐字段给默认值。
43
- * ownerSessionFile/pending 不补全——旧数据本无此二字段,由 toTaskSnapshot 显式剥离。
45
+ * ownerSessionFile/pending 不补全——旧数据本无此二字段,由 toTaskSnapshot 剥离。
44
46
  */
45
47
  function normalizeLegacyTask(t: Partial<ScheduledTask>): ScheduledTask {
46
48
  return {
@@ -53,7 +55,6 @@ function normalizeLegacyTask(t: Partial<ScheduledTask>): ScheduledTask {
53
55
  nextRunAt: t.nextRunAt ?? 0,
54
56
  runCount: t.runCount ?? 0,
55
57
  enabled: t.enabled ?? true,
56
- force: t.force ?? false,
57
58
  history: t.history ?? [],
58
59
  expiresAt: t.expiresAt,
59
60
  lastRunAt: t.lastRunAt,
@@ -62,46 +63,6 @@ function normalizeLegacyTask(t: Partial<ScheduledTask>): ScheduledTask {
62
63
  }
63
64
  }
64
65
 
65
- /**
66
- * ScheduledTask → TaskSnapshot:显式构造 15 字段
67
- * (id/name/prompt/kind/schedule/enabled/force/createdAt/nextRunAt/expiresAt?/
68
- * runCount/lastRunAt?/lastStatus?/lastError?/history),剥离 ownerSessionFile(在 op 顶层)
69
- * 与 pending(运行时标记),history 用 slice() 深拷贝。
70
- *
71
- * 显式构造而非复用 runtime.toSnapshot 的解构写法(T-C3-explicit):旧 store 数据本无
72
- * ownerSessionFile/pending 运行时字段,显式构造更安全。两处构造逻辑需保持形状一致
73
- * (对照 types.ts TaskSnapshot 15 字段)。
74
- */
75
- function toTaskSnapshot(task: ScheduledTask): TaskSnapshot {
76
- return {
77
- id: task.id,
78
- name: task.name,
79
- prompt: task.prompt,
80
- kind: task.kind,
81
- schedule: task.schedule,
82
- enabled: task.enabled,
83
- force: task.force,
84
- createdAt: task.createdAt,
85
- nextRunAt: task.nextRunAt,
86
- expiresAt: task.expiresAt,
87
- runCount: task.runCount,
88
- lastRunAt: task.lastRunAt,
89
- lastStatus: task.lastStatus,
90
- lastError: task.lastError,
91
- history: task.history.slice(),
92
- }
93
- }
94
-
95
- /**
96
- * 判断 err 是否为 ENOENT(fs.renameSync 源文件不存在)。
97
- * Record 守卫模式与 replay.ts isSchedulerEntryOp 同款(避免 taste/no-unsafe-cast)。
98
- */
99
- function isENOENT(err: unknown): boolean {
100
- if (!(err instanceof Error)) return false
101
- if (!('code' in err)) return false
102
- return (err as Record<'code', unknown>).code === 'ENOENT'
103
- }
104
-
105
66
  /**
106
67
  * 从 .imported 路径读取旧 store JSON、逐任务 pi.appendEntry upsert、按 flush 状态删除 .imported。
107
68
  * read/parse/append 任一异常由外层 importLegacyStore 的 try/catch 兜底(C1 整体降级)。
@@ -139,8 +100,7 @@ function importFromFile(
139
100
  ownerSessionFile: currentSessionFile,
140
101
  task: toTaskSnapshot(task),
141
102
  }
142
- // customType 与 backend.ts PiSchedulerBackend.appendEntry 同源,改一处必改两处
143
- pi.appendEntry('pi-scheduler:task', op)
103
+ pi.appendEntry(TASK_ENTRY_TYPE, op)
144
104
  }
145
105
 
146
106
  // 空 store(0 任务)无持久化依赖,直接删;resumed session(sessionFile 已存在)已即时落盘,删
@@ -171,7 +131,7 @@ function importFromFile(
171
131
  fs.unlinkSync(importedPath)
172
132
  } catch (err) {
173
133
  // MF-2:并发——另一进程(resumed B)已 unlink → ENOENT 静默;其他 fs 错误(EACCES)不吞
174
- if (!isENOENT(err)) throw err
134
+ if (!isEnoentError(err)) throw err
175
135
  }
176
136
  }
177
137
  // 从未 flush:保留 .imported,下次 session_start 崩溃恢复重导入(不 unlink;不告警——
@@ -213,7 +173,7 @@ function handleImportedResidue(
213
173
  * 导入旧 scheduler store 到当前 session(append-only event sourcing 迁移,IF-IMPORT-LEGACY)。
214
174
  *
215
175
  * 策略:原子 rename scheduler.json → scheduler.json.imported 独占迁移;成功者读取 .imported
216
- * 逐任务 pi.appendEntry('pi-scheduler:task', upsert) 后删除 .imported;rename 抛 ENOENT
176
+ * 逐任务 pi.appendEntry(TASK_ENTRY_TYPE, upsert) 后删除 .imported;rename 抛 ENOENT
217
177
  * 说明并发场景下别人已 rename 走,走 handleImportedResidue 幂等恢复。
218
178
  *
219
179
  * 时序(CL3 方案A):必须在 backend.loadTasks() 之前执行——append 的 upsert entry 进入 pi
@@ -247,7 +207,7 @@ export function importLegacyStore(
247
207
  try {
248
208
  fs.renameSync(storePath, importedPath)
249
209
  } catch (err) {
250
- if (isENOENT(err)) {
210
+ if (isEnoentError(err)) {
251
211
  // 并发 S10 / 崩溃恢复:scheduler.json 已不在(被别人 rename 走或上次崩溃)→ 残留恢复
252
212
  return handleImportedResidue(importedPath, pi, currentSessionFile)
253
213
  }
@@ -259,7 +219,7 @@ export function importLegacyStore(
259
219
  return importFromFile(importedPath, pi, currentSessionFile)
260
220
  } catch (err) {
261
221
  // C1 整体降级:read/parse/appendEntry 任一异常不崩 session_start
262
- logger.warn('import failed', { error: err instanceof Error ? err.message : String(err) })
222
+ logger.warn('import failed', { error: toErrorMessage(err) })
263
223
  return undefined
264
224
  }
265
225
  }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
2
- import { createDelivery, type DeliveryHandle, type DeliveryMessage } from '@xyz-agent/session-delivery'
2
+ import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
3
3
 
4
4
  import { PiSchedulerBackend } from './backend.js'
5
5
  import { registerScheduleCommand } from './commands.js'
@@ -8,8 +8,8 @@ import { SchedulerRuntime } from './runtime.js'
8
8
  import { SchedulerService } from './service.js'
9
9
  import {
10
10
  controlGuidelines,
11
- createScheduleControlHandler,
12
- createScheduleHandler,
11
+ handleSchedule,
12
+ handleScheduleControl,
13
13
  ScheduleControlParams,
14
14
  type ScheduleControlParamsT,
15
15
  scheduleGuidelines,
@@ -44,7 +44,6 @@ let sessionGeneration = 0
44
44
  */
45
45
  export default function schedulerExtension(pi: ExtensionAPI): void {
46
46
  let service: SchedulerService | null = null
47
- let deliveryHandle: DeliveryHandle | undefined
48
47
  // IMPORT-FLUSH-GUARD(MF-1):importLegacyStore 对未 flush 的新 session 返回延迟删除 .imported
49
48
  // 的 cleanup——turn_end / session_shutdown 时执行:确认 flush(sessionFile 已出现)则删,
50
49
  // 未 flush 保留供崩溃恢复重导入(否则未 flush 即退出 → 全部旧任务丢失且源文件已销毁)。
@@ -68,58 +67,18 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
68
67
  // 窗口与 session 替换交错时旧 session_shutdown 可能永远等不到(timer 泄漏源头)。stopScheduler 幂等,
69
68
  // shutdown 已停过再停一次无副作用。
70
69
  service?.runtime.stopScheduler()
71
- // 销毁旧 delivery handle(清队列 + 清 timer + 退订 settled)
72
- deliveryHandle?.dispose()
73
70
  // 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
74
71
  const backend = new PiSchedulerBackend(ctx, pi)
75
72
  // 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
76
73
  // append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
77
74
  // ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
78
75
  importCleanup = importLegacyStore(ctx.cwd, pi, ctx.sessionManager.getSessionFile())
79
- // 创建 delivery handle(U4:scheduler 切换内核)
80
- // busyPolicy: 'park'(busy 入队不重试,等 tick 外部 flush)
81
- // intent: 'after-run'(保持 followUp 语义)
82
- // onSettled:延迟绑定——runtime 创建后填充(闭包变量)
83
- // #11:整条 msg 透传(runtime 按 msg.dedupeKey=task.id 精确反查,不再 content 匹配)
84
- const settledHandlerRef: { current: ((msg: DeliveryMessage, outcome: 'delivered' | 'rejected') => void) | undefined } = { current: undefined }
85
- deliveryHandle = createDelivery({
86
- supportedPayloads: ['custom'],
87
- isIdle: () => ctx.isIdle(),
88
- hasPendingMessages: () => ctx.hasPendingMessages(),
89
- subscribeSettled: (cb) => {
90
- // 无退订语义(pi 0.84.1 `pi.on(...)` 全重载返回 void、无 off——实装锚点:
91
- // node_modules @earendil-works/pi-coding-agent dist/core/extensions/types.d.ts
92
- // `on()` 系列,0.84.1 实测);disposed 标志包装兑现退订(notifier.ts 同款)
93
- let disposed = false
94
- pi.on('agent_settled', () => { if (!disposed) cb() })
95
- return () => { disposed = true }
96
- },
97
- send: (msg, intent) => {
98
- const piOpts = intent === 'interrupt-at-turn-boundary'
99
- ? { triggerTurn: true, deliverAs: 'steer' as const }
100
- : { triggerTurn: true, deliverAs: 'followUp' as const }
101
- // supportedPayloads 已收窄到 'custom',payload.kind 恒为 'custom'
102
- const content = msg.payload.content
103
- return pi.sendMessage(
104
- { content, customType: 'pi-scheduler:dispatched', display: true },
105
- piOpts,
106
- )
107
- },
108
- }, {
109
- intent: 'after-run',
110
- busyPolicy: 'park',
111
- onSettled: (msg, outcome) => {
112
- // 委托给 runtime 的 settledHandler(延迟绑定)
113
- settledHandlerRef.current?.(msg, outcome)
114
- },
115
- })
116
- backend.setDeliveryHandle(deliveryHandle)
117
76
 
118
77
  // G1:注入代际比对(本 runtime 建立时的代数 vs 实时代数),供 tick 前置检查与
119
78
  // F2 catch 分诊判定 stale——不依赖 pi 错误文案。
120
- const runtime = new SchedulerRuntime(backend, ctx, () => sessionGeneration !== myGeneration)
121
- // 延迟绑定:runtime 的 handleSettled 绑定到 delivery onSettled 回调
122
- settledHandlerRef.current = (msg, outcome) => runtime.handleSettled(msg, outcome)
79
+ // 投递模型(scheduler-steer-direct-dispatch):到期任务 steer 直投(backend.sendMessage),
80
+ // 受理即记账,不经投递内核。
81
+ const runtime = new SchedulerRuntime(backend, () => sessionGeneration !== myGeneration)
123
82
  runtime.loadTasks(backend.loadTasks())
124
83
  // W2:tick 后回调刷新 widget(替代独立 widgetTimer + setInterval,节奏对齐 TICK_INTERVAL_MS)
125
84
  runtime.onAfterTick(() => refreshWidget(ctx))
@@ -145,9 +104,6 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
145
104
  if (service) {
146
105
  service.runtime.stopScheduler()
147
106
  }
148
- // 销毁 delivery handle(清队列 + 清 timer + 退订 settled)
149
- deliveryHandle?.dispose()
150
- deliveryHandle = undefined
151
107
  // IMPORT-FLUSH-GUARD(MF-1):兜底清理——正常路径已由首个 turn_end 完成;此处覆盖
152
108
  // 从未产生 turn 的 session(打开未发消息即关闭)。cleanup 确认 flush(sessionFile 已出现)
153
109
  // 则删 .imported,未 flush 保留供崩溃恢复重导入
@@ -178,9 +134,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
178
134
  _ctx: ExtensionContext,
179
135
  ) {
180
136
  try {
181
- return await createScheduleHandler(getService())(params)
137
+ return await handleSchedule(getService(), params)
182
138
  } catch (err) {
183
- throw new Error(`Error: ${err instanceof Error ? err.message : String(err)}`)
139
+ throw new Error(`Error: ${toErrorMessage(err)}`)
184
140
  }
185
141
  },
186
142
  })
@@ -200,9 +156,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
200
156
  _ctx: ExtensionContext,
201
157
  ) {
202
158
  try {
203
- return await createScheduleControlHandler(getService())(params)
159
+ return await handleScheduleControl(getService(), params)
204
160
  } catch (err) {
205
- throw new Error(`Error: ${err instanceof Error ? err.message : String(err)}`)
161
+ throw new Error(`Error: ${toErrorMessage(err)}`)
206
162
  }
207
163
  },
208
164
  })
package/src/parsing.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { ParseScheduleResult, ScheduleSpec } from './types.js'
1
+ // croner 是 dependencies + 静态 import(ext-simplify-08 D2/M18):npm 语义保证全安装
2
+ // 形态在盘(独立安装 / builtin inline 同构),不存在「解析器缺失」分支——undefined
3
+ // 的唯一语义 = 表达式无效。
4
+ import { Cron } from 'croner'
5
+
6
+ import type { ScheduleSpec } from './types.js'
2
7
 
3
8
  // ── Duration 解析 ──
4
9
 
@@ -65,19 +70,6 @@ const CRON_FIELD_COUNT_WITH_SECONDS = 6
65
70
  /** computeNextCronRuns / computeNextRuns 默认返回的未来执行时间数。 */
66
71
  const DEFAULT_NEXT_RUNS_COUNT = 5
67
72
 
68
- let cronerModule: typeof import('croner') | null | undefined
69
-
70
- async function getCroner(): Promise<typeof import('croner') | null> {
71
- if (cronerModule !== undefined) return cronerModule
72
- try {
73
- cronerModule = await import('croner')
74
- return cronerModule
75
- } catch {
76
- cronerModule = null
77
- return null
78
- }
79
- }
80
-
81
73
  /**
82
74
  * 规范化 cron 表达式:5 字段自动补秒字段。
83
75
  * 返回 undefined 表示无效。
@@ -103,20 +95,17 @@ export function normalizeCronExpression(input: string): string | undefined {
103
95
 
104
96
  /**
105
97
  * 计算 cron 表达式的下次执行时间。
106
- * 返回 undefined 表示表达式无效或 croner 不可用。
98
+ * 返回 undefined 的唯一语义 = 表达式无效(croner 静态 import 恒在盘,见文件头注释)。
107
99
  */
108
- export async function computeNextCronRunAt(
100
+ export function computeNextCronRunAt(
109
101
  expression: string,
110
102
  from?: number,
111
- ): Promise<number | undefined> {
112
- const croner = await getCroner()
113
- if (!croner) return undefined
114
-
103
+ ): number | undefined {
115
104
  try {
116
105
  const normalized = normalizeCronExpression(expression)
117
106
  if (!normalized) return undefined
118
107
 
119
- const job = new croner.Cron(normalized, { startAt: from ? new Date(from) : undefined })
108
+ const job = new Cron(normalized, { startAt: from ? new Date(from) : undefined })
120
109
  const next = job.nextRun()
121
110
  return next ? next.getTime() : undefined
122
111
  } catch {
@@ -126,21 +115,18 @@ export async function computeNextCronRunAt(
126
115
 
127
116
  /**
128
117
  * 计算 cron 表达式的多个未来执行时间。
129
- * count 默认 5。
118
+ * count 默认 5;表达式无效 → 空数组。
130
119
  */
131
- export async function computeNextCronRuns(
120
+ export function computeNextCronRuns(
132
121
  expression: string,
133
122
  from?: number,
134
123
  count = DEFAULT_NEXT_RUNS_COUNT,
135
- ): Promise<number[]> {
136
- const croner = await getCroner()
137
- if (!croner) return []
138
-
124
+ ): number[] {
139
125
  try {
140
126
  const normalized = normalizeCronExpression(expression)
141
127
  if (!normalized) return []
142
128
 
143
- const job = new croner.Cron(normalized, { startAt: from ? new Date(from) : undefined })
129
+ const job = new Cron(normalized, { startAt: from ? new Date(from) : undefined })
144
130
  const runs: number[] = []
145
131
  let current = from ? new Date(from) : new Date()
146
132
 
@@ -160,14 +146,12 @@ export async function computeNextCronRuns(
160
146
  // ── 统一解析 ──
161
147
 
162
148
  /**
163
- * 统一解析 schedule 输入。
149
+ * 统一解析 schedule 输入为 ScheduleSpec(L7:不包装单字段结果对象)。
164
150
  * 不含空格 → duration 解析 → interval mode
165
151
  * 含空格 → cron 解析 → cron mode
166
- * 都失败 → undefined
152
+ * 都失败 → undefined(语义 = 表达式无效,service 层归一为 INVALID_SCHEDULE 单一错误通道)
167
153
  */
168
- export async function parseSchedule(
169
- input: string,
170
- ): Promise<ParseScheduleResult | undefined> {
154
+ export function parseSchedule(input: string): ScheduleSpec | undefined {
171
155
  const trimmed = input.trim()
172
156
  if (!trimmed) return undefined
173
157
 
@@ -175,9 +159,7 @@ export async function parseSchedule(
175
159
  if (!trimmed.includes(' ')) {
176
160
  const ms = parseDuration(trimmed)
177
161
  if (ms !== undefined) {
178
- return {
179
- spec: { mode: 'interval', intervalMs: ms },
180
- }
162
+ return { mode: 'interval', intervalMs: ms }
181
163
  }
182
164
  return undefined
183
165
  }
@@ -186,11 +168,9 @@ export async function parseSchedule(
186
168
  const normalized = normalizeCronExpression(trimmed)
187
169
  if (normalized) {
188
170
  // 验证 cron 表达式有效
189
- const nextRun = await computeNextCronRunAt(trimmed)
171
+ const nextRun = computeNextCronRunAt(trimmed)
190
172
  if (nextRun !== undefined) {
191
- return {
192
- spec: { mode: 'cron', cronExpression: normalized },
193
- }
173
+ return { mode: 'cron', cronExpression: normalized }
194
174
  }
195
175
  }
196
176
 
@@ -200,14 +180,14 @@ export async function parseSchedule(
200
180
  /**
201
181
  * 统一计算 schedule 的下次执行时间(IF-5:runtime 内唯一 nextRunAt 计算入口)。
202
182
  * interval → from + intervalMs(from 缺省 Date.now());
203
- * cron → 下次命中时间戳(> from);cron 无效或 croner 不可用 → undefined。
183
+ * cron → 下次命中时间戳(> from);cron 无效 → undefined。
204
184
  * undefined 语义按调用方决策:addTask → 创建即抛错;
205
185
  * toggleTask/dispatchTask 重算 → ERR-2 fallback(停用 + failed)。
206
186
  */
207
- export async function computeNextRunAt(
187
+ export function computeNextRunAt(
208
188
  spec: ScheduleSpec,
209
189
  from?: number,
210
- ): Promise<number | undefined> {
190
+ ): number | undefined {
211
191
  if (spec.mode === 'interval') {
212
192
  const start = from ?? Date.now()
213
193
  return start + spec.intervalMs
@@ -222,11 +202,11 @@ export async function computeNextRunAt(
222
202
  * 统一计算多个未来执行时间。
223
203
  * interval 模式直接乘法,cron 模式调用 croner。
224
204
  */
225
- export async function computeNextRuns(
205
+ export function computeNextRuns(
226
206
  spec: ScheduleSpec,
227
207
  from?: number,
228
208
  count = DEFAULT_NEXT_RUNS_COUNT,
229
- ): Promise<number[]> {
209
+ ): number[] {
230
210
  if (spec.mode === 'interval') {
231
211
  const start = from ?? Date.now()
232
212
  return Array.from({ length: count }, (_, i) => start + spec.intervalMs * (i + 1))