@zhushanwen/pi-scheduler 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -0
- package/package.json +4 -3
- package/src/__tests__/backend.test.ts +120 -0
- package/src/__tests__/commands.test.ts +59 -60
- package/src/__tests__/cron.test.ts +1 -2
- package/src/__tests__/format.test.ts +35 -0
- package/src/__tests__/importer.test.ts +283 -0
- package/src/__tests__/parsing.test.ts +29 -0
- package/src/__tests__/property.test.ts +131 -0
- package/src/__tests__/replay.test.ts +309 -0
- package/src/__tests__/runtime.test.ts +282 -48
- package/src/__tests__/sdk-contract.test.ts +54 -19
- package/src/__tests__/service.test.ts +214 -0
- package/src/__tests__/tool.test.ts +40 -23
- package/src/backend.ts +151 -0
- package/src/commands.ts +28 -34
- package/src/format.ts +6 -4
- package/src/importer.ts +261 -0
- package/src/index.ts +62 -34
- package/src/parsing.ts +19 -1
- package/src/replay.ts +181 -0
- package/src/runtime.ts +152 -63
- package/src/service.ts +156 -0
- package/src/tool.ts +51 -75
- package/src/types.ts +43 -5
- package/src/__tests__/store.test.ts +0 -161
- package/src/store.ts +0 -117
|
@@ -1,30 +1,20 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
//
|
|
19
|
-
|
|
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
|
-
|
|
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(
|
|
96
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
vi.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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<
|
|
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
|
|
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 表达式:
|
|
155
|
-
//
|
|
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('
|
|
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
|
|
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:
|
|
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
|
})
|