@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.
@@ -1,30 +1,20 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
- // M11:mock store 模块,避免 SchedulerRuntime 触发真实 FS 写入。
4
- // 原实现用 new SchedulerRuntime('/test', ...) → createStore('/test') 会写
5
- // ~/.pi/agent/scheduler/root/test/scheduler.json(store.test.ts:87 stderr 已证实)。
6
- // mock 后 load 返回空 store、persist/persistSync 为 no-op,runtime 完全不碰 FS。
7
- vi.mock('../store.js', () => ({
8
- createStore: () => ({
9
- load: () => ({ version: 1, tasks: [] }),
10
- persist: vi.fn(),
11
- persistSync: vi.fn(),
12
- storePath: '/mocked/scheduler.json',
13
- }),
14
- }))
15
-
3
+ import { MockSchedulerBackend } from '../backend.js'
16
4
  import { SchedulerRuntime } from '../runtime.js'
17
5
 
18
- // Mock pi 和 ctx
19
- const mockPi = { sendMessage: vi.fn() }
6
+ // MockSchedulerBackend 零 FS 副作用:runtime 不再触碰 store,无需 mock store.js。
7
+
20
8
  const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
21
9
 
22
10
  describe('SchedulerRuntime', () => {
11
+ let backend: MockSchedulerBackend
23
12
  let runtime: SchedulerRuntime
24
13
 
25
14
  beforeEach(() => {
26
15
  vi.clearAllMocks()
27
- runtime = new SchedulerRuntime('/test', mockPi as never, mockCtx as never)
16
+ backend = new MockSchedulerBackend()
17
+ runtime = new SchedulerRuntime(backend, mockCtx)
28
18
  })
29
19
 
30
20
  describe('addTask', () => {
@@ -42,6 +32,11 @@ describe('SchedulerRuntime', () => {
42
32
  await expect(runtime.addTask('one more', { mode: 'interval', intervalMs: 60000 }))
43
33
  .rejects.toThrow('Task limit reached')
44
34
  })
35
+
36
+ it('throws for invalid cron expression at creation', async () => {
37
+ await expect(runtime.addTask('bad cron', { mode: 'cron', cronExpression: 'invalid * *' }))
38
+ .rejects.toThrow('Invalid cron expression: invalid * *')
39
+ })
45
40
  })
46
41
 
47
42
  describe('listTasks', () => {
@@ -74,6 +69,22 @@ describe('SchedulerRuntime', () => {
74
69
  it('returns false for non-existent task', async () => {
75
70
  expect(await runtime.toggleTask('nonexistent', true)).toBe(false)
76
71
  })
72
+
73
+ it('ERR-2: enable 时 cron 失效 → 停用 + failed + lastError,不落入 ?? now() 死循环', async () => {
74
+ const task = await runtime.addTask('test', { mode: 'cron', cronExpression: '*/10 * * * *' })
75
+ await runtime.toggleTask(task.id, false)
76
+ // 手动破坏 cron 表达式(模拟表达式随环境失效),并让 nextRunAt 过期触发重算
77
+ task.schedule = { mode: 'cron', cronExpression: 'invalid * *' }
78
+ task.nextRunAt = 0
79
+
80
+ await runtime.toggleTask(task.id, true)
81
+
82
+ expect(task.enabled).toBe(false)
83
+ expect(task.lastStatus).toBe('failed')
84
+ expect(task.lastError).toBe('cron expression invalid')
85
+ // nextRunAt 保留原值(0),enabled=false 后 tick 不再触发
86
+ expect(task.nextRunAt).toBe(0)
87
+ })
77
88
  })
78
89
 
79
90
  describe('deleteTask', () => {
@@ -91,44 +102,63 @@ describe('SchedulerRuntime', () => {
91
102
  describe('dispatchTask', () => {
92
103
  it('dispatches task when idle', async () => {
93
104
  const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
94
- runtime.dispatchTask(task)
95
- expect(mockPi.sendMessage).toHaveBeenCalledWith(
96
- expect.objectContaining({ content: 'test' }),
97
- expect.any(Object),
98
- )
105
+ await runtime.dispatchTask(task)
106
+ expect(backend.sentMessages).toHaveLength(1)
107
+ expect(backend.sentMessages[0]!.msg).toEqual(expect.objectContaining({ content: 'test' }))
99
108
  })
100
109
 
101
110
  it('skips disabled task', async () => {
102
111
  const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
103
112
  await runtime.toggleTask(task.id, false)
104
- runtime.dispatchTask(task)
105
- expect(mockPi.sendMessage).not.toHaveBeenCalled()
113
+ await runtime.dispatchTask(task)
114
+ expect(backend.sentMessages).toHaveLength(0)
106
115
  })
107
116
 
108
117
  it('skips when not idle and force is false', async () => {
109
118
  const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
110
- const busyRuntime = new SchedulerRuntime('/test', mockPi as never, busyCtx as never)
119
+ const busyBackend = new MockSchedulerBackend()
120
+ const busyRuntime = new SchedulerRuntime(busyBackend, busyCtx)
111
121
  const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
112
- busyRuntime.dispatchTask(task)
113
- expect(mockPi.sendMessage).not.toHaveBeenCalled()
122
+ await busyRuntime.dispatchTask(task)
123
+ expect(busyBackend.sentMessages).toHaveLength(0)
114
124
  })
115
125
 
116
126
  it('dispatches when force is true even if busy', async () => {
117
127
  const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
118
- const busyRuntime = new SchedulerRuntime('/test', mockPi as never, busyCtx as never)
128
+ const busyBackend = new MockSchedulerBackend()
129
+ const busyRuntime = new SchedulerRuntime(busyBackend, busyCtx)
119
130
  const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 }, { force: true })
120
- busyRuntime.dispatchTask(task)
121
- expect(mockPi.sendMessage).toHaveBeenCalled()
131
+ await busyRuntime.dispatchTask(task)
132
+ expect(busyBackend.sentMessages).toHaveLength(1)
122
133
  })
123
134
 
124
135
  // OR 组合补全:源码 `!isIdle() || hasPendingMessages()` 任一为真即跳过。
125
136
  // idle=true 但有 pending message → dispatch 应被跳过。
126
137
  it('skips when idle but has pending messages', async () => {
127
138
  const pendingCtx = { isIdle: () => true, hasPendingMessages: () => true }
128
- const pendingRuntime = new SchedulerRuntime('/test', mockPi as never, pendingCtx as never)
139
+ const pendingBackend = new MockSchedulerBackend()
140
+ const pendingRuntime = new SchedulerRuntime(pendingBackend, pendingCtx)
129
141
  const task = await pendingRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
130
- pendingRuntime.dispatchTask(task)
131
- expect(mockPi.sendMessage).not.toHaveBeenCalled()
142
+ await pendingRuntime.dispatchTask(task)
143
+ expect(pendingBackend.sentMessages).toHaveLength(0)
144
+ })
145
+
146
+ it('sendMessage 失败 → 记 failed 状态但不 rethrow', async () => {
147
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
148
+ // backend.sendMessage 抛错模拟注入失败
149
+ backend.sendMessage = async () => { throw new Error('inject failed') }
150
+ const dispatched = await runtime.dispatchTask(task)
151
+ expect(dispatched).toBe(false)
152
+ expect(task.lastStatus).toBe('failed')
153
+ expect(task.pending).toBe(false)
154
+ expect(task.history[task.history.length - 1]!.status).toBe('failed')
155
+ })
156
+
157
+ it('成功 dispatch 后清除 lastError', async () => {
158
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
159
+ task.lastError = 'cron expression invalid'
160
+ await runtime.dispatchTask(task)
161
+ expect(task.lastError).toBeUndefined()
132
162
  })
133
163
  })
134
164
 
@@ -141,6 +171,10 @@ describe('SchedulerRuntime', () => {
141
171
  vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
142
172
  })
143
173
 
174
+ afterEach(() => {
175
+ vi.useRealTimers()
176
+ })
177
+
144
178
  it('rate-limits dispatch to 6 per minute', async () => {
145
179
  // force=true 保证不被 idle/busy 干扰,直接命中 rate-limit
146
180
  const tasks: Awaited<ReturnType<typeof runtime.addTask>>[] = []
@@ -154,7 +188,7 @@ describe('SchedulerRuntime', () => {
154
188
  }
155
189
 
156
190
  // 前 6 次成功,第 7 次被限流:sendMessage 只被调 6 次
157
- expect(mockPi.sendMessage).toHaveBeenCalledTimes(6)
191
+ expect(backend.sentMessages).toHaveLength(6)
158
192
  })
159
193
 
160
194
  it('allows dispatch again after 1 minute window slides', async () => {
@@ -164,27 +198,28 @@ describe('SchedulerRuntime', () => {
164
198
  // 同一 task 反复 dispatch(interval 模式每次重算 nextRunAt,不影响 rate-limit 计数)
165
199
  await runtime.dispatchTask(task)
166
200
  }
167
- expect(mockPi.sendMessage).toHaveBeenCalledTimes(6)
201
+ expect(backend.sentMessages).toHaveLength(6)
168
202
 
169
203
  // 时间前进 61 秒:旧 timestamp 滑出窗口,配额恢复
170
204
  vi.setSystemTime(new Date('2026-01-01T00:01:01Z'))
171
205
  const dispatched = await runtime.dispatchTask(task)
172
206
  expect(dispatched).toBe(true)
173
- expect(mockPi.sendMessage).toHaveBeenCalledTimes(7)
207
+ expect(backend.sentMessages).toHaveLength(7)
174
208
  })
175
209
  })
176
210
 
177
211
  // ── M10d:tickScheduler ──
178
212
  describe('tickScheduler', () => {
213
+ beforeEach(() => {
214
+ vi.useFakeTimers()
215
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
216
+ })
217
+
179
218
  afterEach(() => {
180
219
  vi.useRealTimers()
181
220
  })
182
221
 
183
222
  it('dispatches due interval tasks and advances nextRunAt', async () => {
184
- vi.useFakeTimers()
185
- const start = new Date('2026-01-01T00:00:00Z')
186
- vi.setSystemTime(start)
187
-
188
223
  const task = await runtime.addTask('tick me', { mode: 'interval', intervalMs: 60000 })
189
224
  // 手动让任务过期(nextRunAt 设为过去)
190
225
  task.nextRunAt = Date.now() - 1000
@@ -192,7 +227,7 @@ describe('SchedulerRuntime', () => {
192
227
  await runtime.tickScheduler()
193
228
 
194
229
  // 已 dispatch
195
- expect(mockPi.sendMessage).toHaveBeenCalledTimes(1)
230
+ expect(backend.sentMessages).toHaveLength(1)
196
231
  const updated = runtime.getTask(task.id)
197
232
  expect(updated).toBeDefined()
198
233
  expect(updated!.runCount).toBe(1)
@@ -201,9 +236,6 @@ describe('SchedulerRuntime', () => {
201
236
  })
202
237
 
203
238
  it('removes expired tasks (expiresAt in the past)', async () => {
204
- vi.useFakeTimers()
205
- vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
206
-
207
239
  const task = await runtime.addTask('expire me', { mode: 'interval', intervalMs: 60000 })
208
240
  // expiresAt 已过:tick 的第 1 步清理会删除
209
241
  task.expiresAt = Date.now() - 1000
@@ -212,13 +244,10 @@ describe('SchedulerRuntime', () => {
212
244
 
213
245
  expect(runtime.getTask(task.id)).toBeUndefined()
214
246
  // 过期清理先于 dispatch,不应 dispatch
215
- expect(mockPi.sendMessage).not.toHaveBeenCalled()
247
+ expect(backend.sentMessages).toHaveLength(0)
216
248
  })
217
249
 
218
250
  it('deletes once task after dispatch', async () => {
219
- vi.useFakeTimers()
220
- vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
221
-
222
251
  const task = await runtime.addTask('one-shot', { mode: 'interval', intervalMs: 60000 }, { kind: 'once' })
223
252
  task.nextRunAt = Date.now() - 1000
224
253
 
@@ -226,7 +255,212 @@ describe('SchedulerRuntime', () => {
226
255
 
227
256
  // once 任务 dispatch 后自删
228
257
  expect(runtime.getTask(task.id)).toBeUndefined()
229
- expect(mockPi.sendMessage).toHaveBeenCalledTimes(1)
258
+ expect(backend.sentMessages).toHaveLength(1)
259
+ })
260
+
261
+ // ── TC1:cron 失效任务不死循环 ──
262
+ // tick1 dispatch 成功、重算 nextRunAt 失败停用;tick2/3 因 enabled=false 不再 dispatch。
263
+ // 旧实现 `?? Date.now()` 会把 nextRunAt 设为 now → 每 tick 立即重触发 → 死循环。
264
+ it('TC1: cron 失效任务不死循环(sendMessage 只触发 1 次)', async () => {
265
+ const task = await runtime.addTask('tick me', { mode: 'cron', cronExpression: '*/10 * * * *' })
266
+ // 手动把 cron 表达式改为无效(模拟表达式随环境失效),并使任务到期
267
+ task.schedule = { mode: 'cron', cronExpression: 'invalid * *' }
268
+ task.nextRunAt = Date.now() - 1000
269
+
270
+ // 连跑 3 次 tick
271
+ await runtime.tickScheduler()
272
+ await runtime.tickScheduler()
273
+ await runtime.tickScheduler()
274
+
275
+ expect(backend.sentMessages).toHaveLength(1)
276
+ const updated = runtime.getTask(task.id)
277
+ expect(updated).toBeDefined()
278
+ expect(updated!.enabled).toBe(false)
279
+ expect(updated!.lastStatus).toBe('failed')
280
+ expect(updated!.lastError).toBe('cron expression invalid')
281
+ })
282
+
283
+ // ── TC-W-APPEND-FAIL:appendEntry 失败捕获(ER-APPEND-FAIL)──
284
+ it('TC-W-APPEND-FAIL: appendEntry 失败不抛、保留内存态、不污染 lastError', async () => {
285
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
286
+ backend.appendError = new Error('pi internal')
287
+
288
+ // addTask 内 appendEntry 抛错 → 被捕获(console.warn),不 rethrow;内存态已更新(task 仍在)
289
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
290
+ expect(runtime.getTask(task.id)).toBeDefined()
291
+ expect(task.enabled).toBe(true)
292
+ // appendEntrySafe 不污染业务态(append 失败是 transient,不设 lastError)
293
+ expect(task.lastError).toBeUndefined()
294
+ expect(warnSpy).toHaveBeenCalled()
295
+
296
+ // tickScheduler 同样不抛:dispatch 成功后 append advance 抛错被捕获,nextRunAt 已推进(内存态正确)
297
+ task.nextRunAt = Date.now() - 1000
298
+ await expect(runtime.tickScheduler()).resolves.toBeUndefined()
299
+ const updated = runtime.getTask(task.id)!
300
+ expect(updated.enabled).toBe(true)
301
+ expect(updated.runCount).toBe(1)
302
+ // nextRunAt 已推进到未来(内存态正确,append 失败只丢持久化)
303
+ expect(updated.nextRunAt).toBe(Date.now() + 60000)
304
+ expect(updated.lastError).toBeUndefined() // 不被 append 失败污染
305
+ warnSpy.mockRestore()
306
+ })
307
+
308
+ // ── TC-W-ON-AFTER-TICK:onAfterTick 回调(W2)──
309
+ it('TC-W-ON-AFTER-TICK: onAfterTick 回调在 tick 完成后被调用 1 次', async () => {
310
+ const spy = vi.fn()
311
+ runtime.onAfterTick(spy)
312
+ await runtime.tickScheduler()
313
+ expect(spy).toHaveBeenCalledTimes(1)
314
+ })
315
+
316
+ // ── TC-W-ENABLED-FILTER:tickScheduler 步骤3 显式 enabled 过滤(W4)──
317
+ it('TC-W-ENABLED-FILTER: disabled 且到期的任务不被 dispatch,enabled 到期则 dispatch', async () => {
318
+ // disabled + 到期
319
+ const disabled = await runtime.addTask('disabled', { mode: 'interval', intervalMs: 60000 })
320
+ await runtime.toggleTask(disabled.id, false)
321
+ disabled.nextRunAt = Date.now() - 1000
322
+
323
+ // enabled + 到期(force=true 绕过 idle/busy 检查,隔离 enabled 维度)
324
+ const enabledTask = await runtime.addTask('enabled', { mode: 'interval', intervalMs: 60000 }, { force: true })
325
+ enabledTask.nextRunAt = Date.now() - 1000
326
+
327
+ await runtime.tickScheduler()
328
+
329
+ // 只 dispatch enabled(disabled 被步骤2 不标 pending + 步骤3 +t.enabled 双重过滤)
330
+ expect(backend.sentMessages).toHaveLength(1)
331
+ expect(backend.sentMessages[0]!.msg.content).toBe('enabled')
332
+ // disabled 仍在、未被 dispatch、enabled=false
333
+ const stillDisabled = runtime.getTask(disabled.id)
334
+ expect(stillDisabled).toBeDefined()
335
+ expect(stillDisabled!.enabled).toBe(false)
336
+ expect(stillDisabled!.runCount).toBe(0)
337
+ })
338
+
339
+ // ── MF-1:toggle enable 重算 nextRunAt 到未来时清除残留 pending ──
340
+ // 场景:busy tick 标记 pending=true(W4 跨 tick 重试)→ disable → enable 重算到未来。
341
+ // 修复前 pending 残留 → 下个 tick step3 `pending && enabled` 在重算的未来时间点之前提前 dispatch。
342
+ it('MF-1: enable 重算 nextRunAt 到未来时清除残留 pending,不提前 dispatch', async () => {
343
+ // 可控 idle 状态:先 busy 模拟 dispatchTask 跳过保留 pending(W4),后切 idle 排除 busy 干扰
344
+ let idle = false
345
+ const controllableCtx = { isIdle: () => idle, hasPendingMessages: () => false }
346
+ const controllableBackend = new MockSchedulerBackend()
347
+ const rt = new SchedulerRuntime(controllableBackend, controllableCtx)
348
+
349
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
350
+ const task = await rt.addTask('mf1', { mode: 'interval', intervalMs: 60000 })
351
+
352
+ // T0+61s:任务到期 + busy → step2 标 pending=true,step3 dispatchTask busy 跳过(pending 保留)
353
+ vi.setSystemTime(new Date('2026-01-01T00:01:01Z'))
354
+ await rt.tickScheduler()
355
+ expect(task.pending).toBe(true) // busy tick 残留 pending(W4 跨 tick 重试)
356
+ expect(controllableBackend.sentMessages).toHaveLength(0)
357
+
358
+ // disable → enable:enable 重算 nextRunAt 到未来(T0+61s + 60s = T0+121s)
359
+ await rt.toggleTask(task.id, false)
360
+ await rt.toggleTask(task.id, true)
361
+ expect(task.pending).toBe(false) // 修复后:重算到未来清除残留 pending
362
+ const recalcedNext = task.nextRunAt
363
+ expect(recalcedNext).toBeGreaterThan(Date.now()) // 确认重算到了未来
364
+
365
+ // T0+90s:在重算的未来 nextRunAt 之前 tick,切 idle 排除 busy 干扰 → 不应 dispatch
366
+ vi.setSystemTime(new Date('2026-01-01T00:01:30Z'))
367
+ idle = true
368
+ await rt.tickScheduler()
369
+ expect(controllableBackend.sentMessages).toHaveLength(0)
370
+
371
+ // 到达重算的未来 nextRunAt 后 tick:才 dispatch
372
+ vi.setSystemTime(new Date(recalcedNext + 1000))
373
+ await rt.tickScheduler()
374
+ expect(controllableBackend.sentMessages).toHaveLength(1)
375
+ })
376
+
377
+ // ── P1:toggle enable 重算的 nextRunAt 跨 session 重放后保持未来值 ──
378
+ // 场景:addTask → nextRunAt 过期 → disable → enable 重算到未来(内存)→
379
+ // 新建第二个 SchedulerRuntime + backend.loadTasks() 重放 appendedOps(模拟 resume)→
380
+ // 重放后 nextRunAt = 重算的未来值(非 upsert 快照旧过期值)+ enabled=true + tick 不立即 dispatch。
381
+ // 修复前:toggle op 不带 nextRunAt,重放回退到 upsert 快照旧过期值 → 首个 tick 立即 dispatch(跨 session 数据丢失)。
382
+ it('P1: toggle enable 重算的 nextRunAt 跨 session 重放后保持未来值,不回退到旧过期值', async () => {
383
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
384
+ const task = await runtime.addTask('cross-session', { mode: 'interval', intervalMs: 60000 })
385
+ const oldNextRunAt = task.nextRunAt // T0+60s(upsert 快照值)
386
+
387
+ // T0+120s:nextRunAt(T0+60s) 已过期
388
+ vi.setSystemTime(new Date('2026-01-01T00:02:00Z'))
389
+ expect(task.nextRunAt).toBeLessThan(Date.now())
390
+
391
+ // disable → enable:enable 重算 nextRunAt 到未来(T0+120s + 60s = T0+180s)
392
+ await runtime.toggleTask(task.id, false)
393
+ await runtime.toggleTask(task.id, true)
394
+ const recalcedNext = task.nextRunAt
395
+ expect(recalcedNext).toBeGreaterThan(Date.now()) // 内存态已重算到未来
396
+ expect(recalcedNext).not.toBe(oldNextRunAt) // 确实重算,非旧值
397
+
398
+ // 模拟 resume:把第一个 runtime 的 appendedOps 包装成 entries,喂给第二个 backend 重放。
399
+ // appendedOps = [upsert(T0+60s), toggle(enabled=false), toggle(enabled=true, nextRunAt=T0+180s)]
400
+ const replayBackend = new MockSchedulerBackend()
401
+ replayBackend.fakeEntries = backend.appendedOps.map(op => ({
402
+ type: 'custom',
403
+ customType: 'pi-scheduler:task',
404
+ data: op,
405
+ }))
406
+ // fakeSessionFile 默认 '/test/session.json',与第一个 backend 一致 → owner 过滤放行
407
+ const replayRuntime = new SchedulerRuntime(replayBackend, mockCtx)
408
+ replayRuntime.loadTasks(replayBackend.loadTasks())
409
+
410
+ const replayed = replayRuntime.getTask(task.id)
411
+ expect(replayed).toBeDefined()
412
+ expect(replayed!.enabled).toBe(true)
413
+ // 重放后是重算的未来值(修复核心),非 upsert 快照的旧过期值
414
+ expect(replayed!.nextRunAt).toBe(recalcedNext)
415
+ expect(replayed!.nextRunAt).not.toBe(oldNextRunAt)
416
+
417
+ // 再 tick 一次(now=T0+120s < 重算 nextRunAt T0+180s):不应 dispatch。
418
+ // 修复前此断言会失败:nextRunAt 回退到 oldNextRunAt(T0+60s) < now → 首个 tick 立即触发
419
+ await replayRuntime.tickScheduler()
420
+ expect(replayBackend.sentMessages).toHaveLength(0)
421
+ })
422
+ })
423
+
424
+ // ── TC9:expiresAt 三态(addTask 的 expires 分支)──
425
+ // 源码逻辑:expires==='never' → undefined;kind==='recurring' 且 expires →
426
+ // now + parseDuration(expires)(解析失败 ?? 默认 7d);recurring 无 expires →
427
+ // 默认 7d;kind==='once' 不进分支 → undefined。
428
+ // MockSchedulerBackend nowValue 固定为 1_000_000,expiresAt 精确可控。
429
+ describe('expiresAt', () => {
430
+ beforeEach(() => {
431
+ backend.nowValue = 1_000_000
432
+ })
433
+
434
+ it("expires: 'never' → expiresAt undefined", async () => {
435
+ const task = await runtime.addTask(
436
+ 'test',
437
+ { mode: 'interval', intervalMs: 60000 },
438
+ { expires: 'never' },
439
+ )
440
+ expect(task.expiresAt).toBeUndefined()
441
+ })
442
+
443
+ it('recurring + expires 30m → now + 1_800_000', async () => {
444
+ const task = await runtime.addTask(
445
+ 'test',
446
+ { mode: 'interval', intervalMs: 60000 },
447
+ { expires: '30m' },
448
+ )
449
+ expect(task.expiresAt).toBe(2_800_000)
450
+ })
451
+
452
+ it('recurring + 无 expires → 默认 7d(now + 604_800_000)', async () => {
453
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
454
+ expect(task.expiresAt).toBe(605_800_000)
455
+ })
456
+
457
+ it("kind once + expires '30m' → expiresAt undefined(once 不设过期)", async () => {
458
+ const task = await runtime.addTask(
459
+ 'test',
460
+ { mode: 'interval', intervalMs: 60000 },
461
+ { kind: 'once', expires: '30m' },
462
+ )
463
+ expect(task.expiresAt).toBeUndefined()
230
464
  })
231
465
  })
232
466
  })
@@ -17,18 +17,15 @@
17
17
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
18
18
  import { describe, expect, it, vi } from 'vitest'
19
19
 
20
- // Mock store 模块:避免 runtime 触发真实 FS 写入(session_start 会创建 SchedulerRuntime,
21
- // 进而 createStore(cwd) 写 ~/.pi/agent/scheduler/...)。sdk-contract 只验证注册契约,
22
- // 不关心持久化,故 store 返回空状态 + no-op persist。
23
- vi.mock('../store.js', () => ({
24
- createStore: () => ({
25
- load: () => ({ version: 1, tasks: [] }),
26
- persist: vi.fn(),
27
- persistSync: vi.fn(),
28
- storePath: '/mocked/scheduler.json',
29
- }),
30
- }))
31
-
20
+ // MF-3:session_start handler 会真实执行 importLegacyStore(ctx.cwd, ...) —— fakeCtx cwd='/test'
21
+ // 会触碰真实用户 FS(~/.pi/agent/scheduler/root/test/scheduler.json 的 renameSync/existsSync/
22
+ // unlinkSync;该目录是活跃数据目录,一旦路径存在会 rename+unlink 真实用户数据且结果不确定)。
23
+ // mock 掉 importer 模块:session_start 装配路径仍被调用(vi.fn 记录调用),FS 副作用为零;
24
+ // 装配时序由 tools/verify-scheduler-e2e.cjs 的 S10/S12/S17 真实环境覆盖。
25
+ // mock 返回 vi.fn() 作为延迟删除 cleanup(MF-1:turn_end / session_shutdown 装配链路可测)。
26
+ vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
27
+
28
+ import { importLegacyStore } from '../importer.js'
32
29
  import schedulerExtension from '../index.js'
33
30
 
34
31
  /**
@@ -38,7 +35,11 @@ import schedulerExtension from '../index.js'
38
35
  /** 捕获到的 tool definition:只关心我们要断言的字段。 */
39
36
  interface CapturedTool {
40
37
  name: string
41
- execute: (...args: unknown[]) => Promise<Record<string, unknown>>
38
+ execute: (...args: unknown[]) => Promise<{
39
+ content: { type: string; text: string }[]
40
+ details: unknown
41
+ isError?: boolean
42
+ }>
42
43
  handler?: unknown
43
44
  fn?: unknown
44
45
  [key: string]: unknown
@@ -58,6 +59,7 @@ function createMockPi(): {
58
59
  registerCommand: (name: string, opts: Record<string, unknown>) => commands.push({ name, opts }),
59
60
  on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
60
61
  sendMessage: vi.fn(),
62
+ appendEntry: vi.fn(),
61
63
  } as unknown as ExtensionAPI
62
64
  return { pi, tools, commands, events }
63
65
  }
@@ -72,6 +74,12 @@ function createFakeCtx(): ExtensionContext {
72
74
  isIdle: () => true,
73
75
  hasPendingMessages: () => false,
74
76
  ui: { setWidget: vi.fn() },
77
+ // append-only 模型:session_start 时 PiSchedulerBackend(ctx, pi) 读 ctx.sessionManager.getEntries()
78
+ // 折叠恢复任务。fakeCtx 返回空 entries + 固定 sessionFile(无历史任务,等价新 session)。
79
+ sessionManager: {
80
+ getEntries: () => [],
81
+ getSessionFile: () => '/test/session.json',
82
+ },
75
83
  } as unknown as ExtensionContext
76
84
  }
77
85
 
@@ -145,29 +153,31 @@ describe('pi-scheduler SDK contract', () => {
145
153
  expect(result.isError).toBeFalsy()
146
154
  })
147
155
 
148
- it('handler 抛错被 catch 为 isError(而非 throw)—— standards.md §4.2 契约', async () => {
156
+ it('handler 返回结构化失败被 isError 标记(而非 throw)—— standards.md §4.2 契约', async () => {
149
157
  const { pi, tools, events } = createMockPi()
150
158
  schedulerExtension(pi)
151
159
  const fakeCtx = createFakeCtx()
152
160
  await events.get('session_start')!({ type: 'session_start', reason: 'startup' }, fakeCtx)
153
161
 
154
- // 非法 cron 表达式:parseSchedule 会 reject,handler 抛 'Invalid schedule',
155
- // execute 应 catch 为 isError 而非让 promise reject。
162
+ // 非法 cron 表达式:service.create 返回 INVALID_SCHEDULE 结构化失败,
163
+ // handler 返回 isError result(text 为 service message 本体,无 'Error:' 前缀)。
156
164
  const result = await tools[0]!.execute('call-err', { prompt: 'x', schedule: 'invalid-cron-expr-xxx' }, undefined, undefined, fakeCtx)
157
165
  expect(result.isError).toBe(true)
158
- expect(result.content[0].text).toContain('Error:')
166
+ expect(result.content[0].text).toContain('Invalid schedule')
167
+ expect(result.content[0].text).not.toContain('Error:')
159
168
  })
160
169
 
161
- it('schedule_control tool 同样 catch handler 错误', async () => {
170
+ it('schedule_control tool 同样返回结构化 isError', async () => {
162
171
  const { pi, tools, events } = createMockPi()
163
172
  schedulerExtension(pi)
164
173
  const fakeCtx = createFakeCtx()
165
174
  await events.get('session_start')!({ type: 'session_start', reason: 'startup' }, fakeCtx)
166
175
 
167
- // toggle 不存在的 task id:handler 抛 'Task xxx not found',应被 catch
176
+ // toggle 不存在的 task id:service 返回 TASK_NOT_FOUND,handler 返回 isError
168
177
  const controlTool = tools.find(t => t.name === 'schedule_control')!
169
178
  const result = await controlTool.execute('call-ctrl', { action: 'toggle', id: 'deadbeef', enabled: false }, undefined, undefined, fakeCtx)
170
179
  expect(result.isError).toBe(true)
180
+ expect(result.content[0].text).toBe('Task deadbeef not found.')
171
181
  })
172
182
 
173
183
  it('registerCommand 注册了名为 schedule 的命令', () => {
@@ -175,4 +185,29 @@ describe('pi-scheduler SDK contract', () => {
175
185
  schedulerExtension(pi)
176
186
  expect(commands.map(c => c.name)).toContain('schedule')
177
187
  })
188
+
189
+ // MF-1(R2)装配链路:session_start → importLegacyStore 返回延迟删除 cleanup;
190
+ // turn_end 触发 cleanup(主触发点,flush 已发生);session_shutdown 兜底再调 + 复位。
191
+ it('MF-1 装配:turn_end / session_shutdown 调用延迟删除 cleanup,importer 仅 session_start 调 1 次', async () => {
192
+ const { pi, events } = createMockPi()
193
+ schedulerExtension(pi)
194
+ const fakeCtx = createFakeCtx()
195
+
196
+ // 其他用例也触发 session_start,先清调用记录(mockClear 保留实现)
197
+ vi.mocked(importLegacyStore).mockClear()
198
+
199
+ // session_start → importLegacyStore 调用 1 次,返回 cleanup(新 session 未 flush 延迟删除路径)
200
+ await events.get('session_start')!({ type: 'session_start', reason: 'startup' }, fakeCtx)
201
+ expect(importLegacyStore).toHaveBeenCalledTimes(1)
202
+ const cleanup = vi.mocked(importLegacyStore).mock.results[0]!.value
203
+ expect(typeof cleanup).toBe('function')
204
+
205
+ // turn_end(首个 turn 完成,flush 已发生)→ cleanup 被调用——跨 session 双导入窗口闭合点
206
+ await events.get('turn_end')!()
207
+ expect(cleanup).toHaveBeenCalledTimes(1)
208
+
209
+ // session_shutdown → 兜底再调一次(覆盖从未产生 turn 的 session),随后复位
210
+ await events.get('session_shutdown')!()
211
+ expect(cleanup).toHaveBeenCalledTimes(2)
212
+ })
178
213
  })