@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/README.md +1 -1
- package/package.json +4 -8
- package/skills/scheduler-ext-config/SKILL.md +2 -2
- package/src/__tests__/U4-DISPATCH_INFLIGHT.test.ts +6 -40
- package/src/__tests__/backend.test.ts +3 -6
- package/src/__tests__/commands.test.ts +3 -2
- package/src/__tests__/cron.test.ts +5 -7
- package/src/__tests__/importer.test.ts +0 -1
- package/src/__tests__/index-generation.test.ts +7 -6
- package/src/__tests__/index-session-start.test.ts +2 -2
- package/src/__tests__/mock-backend.ts +58 -0
- package/src/__tests__/parsing.test.ts +2 -4
- package/src/__tests__/property.test.ts +1 -1
- package/src/__tests__/replay.test.ts +0 -1
- package/src/__tests__/runtime.test.ts +21 -251
- package/src/__tests__/service.test.ts +19 -33
- package/src/__tests__/tool.test.ts +16 -22
- package/src/__tests__/types.test.ts +92 -0
- package/src/__tests__/widget.test.ts +0 -1
- package/src/backend.ts +27 -87
- package/src/importer.ts +9 -49
- package/src/index.ts +10 -54
- package/src/parsing.ts +25 -45
- package/src/replay.ts +9 -34
- package/src/runtime.ts +42 -155
- package/src/service.ts +28 -37
- package/src/tool.ts +29 -35
- package/src/types.ts +70 -11
- package/src/__tests__/U4-AFTER_RUN_INTENT.test.ts +0 -99
- package/src/__tests__/U4-ONSETTLED.test.ts +0 -124
- package/src/__tests__/U4-PARK_GATE.test.ts +0 -206
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import type { DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
3
2
|
|
|
4
3
|
// Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
|
|
5
4
|
const { loggerMock } = vi.hoisted(() => ({
|
|
@@ -11,27 +10,11 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
|
11
10
|
setPiHandle: vi.fn(),
|
|
12
11
|
}))
|
|
13
12
|
|
|
14
|
-
import { MockSchedulerBackend } from '
|
|
13
|
+
import { MockSchedulerBackend } from './mock-backend.js'
|
|
15
14
|
import { SchedulerRuntime } from '../runtime.js'
|
|
16
15
|
|
|
17
16
|
// MockSchedulerBackend 零 FS 副作用:runtime 不再触碰 store,无需 mock store.js。
|
|
18
17
|
|
|
19
|
-
const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
|
|
20
|
-
|
|
21
|
-
/** 构造 delivery onSettled 回调入参消息(dispatchViaDelivery 挂 dedupeKey=task.id)。 */
|
|
22
|
-
function settledMsg(content: string, taskId: string): DeliveryMessage {
|
|
23
|
-
return {
|
|
24
|
-
payload: {
|
|
25
|
-
kind: 'custom',
|
|
26
|
-
customType: 'pi-scheduler:dispatched',
|
|
27
|
-
content,
|
|
28
|
-
display: true,
|
|
29
|
-
},
|
|
30
|
-
intent: 'after-run',
|
|
31
|
-
dedupeKey: taskId,
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
18
|
describe('SchedulerRuntime', () => {
|
|
36
19
|
let backend: MockSchedulerBackend
|
|
37
20
|
let runtime: SchedulerRuntime
|
|
@@ -39,7 +22,7 @@ describe('SchedulerRuntime', () => {
|
|
|
39
22
|
beforeEach(() => {
|
|
40
23
|
vi.clearAllMocks()
|
|
41
24
|
backend = new MockSchedulerBackend()
|
|
42
|
-
runtime = new SchedulerRuntime(backend
|
|
25
|
+
runtime = new SchedulerRuntime(backend)
|
|
43
26
|
})
|
|
44
27
|
|
|
45
28
|
describe('addTask', () => {
|
|
@@ -125,7 +108,10 @@ describe('SchedulerRuntime', () => {
|
|
|
125
108
|
})
|
|
126
109
|
|
|
127
110
|
describe('dispatchTask', () => {
|
|
128
|
-
|
|
111
|
+
// steer 直投模型(scheduler-steer-direct-dispatch):全任务统一 backend.sendMessage 直投,
|
|
112
|
+
// 受理即记账;runtime 层无 idle/busy 判定(pi 侧 steer 分支处理)、无 delivery 内核。
|
|
113
|
+
|
|
114
|
+
it('dispatches task via direct send', async () => {
|
|
129
115
|
const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
130
116
|
await runtime.dispatchTask(task)
|
|
131
117
|
expect(backend.sentMessages).toHaveLength(1)
|
|
@@ -139,79 +125,6 @@ describe('SchedulerRuntime', () => {
|
|
|
139
125
|
expect(backend.sentMessages).toHaveLength(0)
|
|
140
126
|
})
|
|
141
127
|
|
|
142
|
-
it('non-force 任务在 busy 时走 delivery 入队(不直调 sendMessage)', async () => {
|
|
143
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
|
|
144
|
-
const busyBackend = new MockSchedulerBackend()
|
|
145
|
-
const busyRuntime = new SchedulerRuntime(busyBackend, busyCtx)
|
|
146
|
-
const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
147
|
-
// 无 delivery handle 时走 dispatchDirect(直投),busy 不影响(直投不检查 idle)
|
|
148
|
-
await busyRuntime.dispatchTask(task)
|
|
149
|
-
expect(busyBackend.sentMessages).toHaveLength(1)
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('dispatches when force is true even if busy', async () => {
|
|
153
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
|
|
154
|
-
const busyBackend = new MockSchedulerBackend()
|
|
155
|
-
const busyRuntime = new SchedulerRuntime(busyBackend, busyCtx)
|
|
156
|
-
const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 }, { force: true })
|
|
157
|
-
await busyRuntime.dispatchTask(task)
|
|
158
|
-
expect(busyBackend.sentMessages).toHaveLength(1)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
// OR 组合补全:源码 `!isIdle() || hasPendingMessages()` 任一为真即跳过。
|
|
162
|
-
// idle=true 但有 pending message → dispatch 应被跳过。
|
|
163
|
-
// U4 变更:gate 已交 delivery 内核,无 delivery handle 时走 dispatchDirect(直投不检查 idle)
|
|
164
|
-
it('无 delivery handle 时直投不受 idle/pending 影响', async () => {
|
|
165
|
-
const pendingCtx = { isIdle: () => true, hasPendingMessages: () => true }
|
|
166
|
-
const pendingBackend = new MockSchedulerBackend()
|
|
167
|
-
const pendingRuntime = new SchedulerRuntime(pendingBackend, pendingCtx)
|
|
168
|
-
const task = await pendingRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
169
|
-
await pendingRuntime.dispatchTask(task)
|
|
170
|
-
expect(pendingBackend.sentMessages).toHaveLength(1)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
// U4 区分力补强:有 delivery handle 时非 force 任务走 delivery 入队而非直投
|
|
174
|
-
it('有 delivery handle 时非 force 任务走 delivery 入队', async () => {
|
|
175
|
-
const pendingCtx = { isIdle: () => true, hasPendingMessages: () => true }
|
|
176
|
-
const pendingBackend = new MockSchedulerBackend()
|
|
177
|
-
pendingBackend.deliveryHandle = {
|
|
178
|
-
send: vi.fn(),
|
|
179
|
-
sendChecked: vi.fn(),
|
|
180
|
-
flush: vi.fn(),
|
|
181
|
-
depth: vi.fn(() => 0),
|
|
182
|
-
dispose: vi.fn(),
|
|
183
|
-
} as any
|
|
184
|
-
const pendingRuntime = new SchedulerRuntime(pendingBackend, pendingCtx)
|
|
185
|
-
const task = await pendingRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
186
|
-
const dispatched = await pendingRuntime.dispatchTask(task)
|
|
187
|
-
|
|
188
|
-
expect(dispatched).toBe(true)
|
|
189
|
-
expect(pendingBackend.deliveryHandle.send).toHaveBeenCalledTimes(1)
|
|
190
|
-
expect(pendingBackend.sentMessages).toHaveLength(0)
|
|
191
|
-
expect(task.pending).toBe(false)
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
// U4 区分力补强:busy 时 delivery 入队成功,不触发直投
|
|
195
|
-
it('busy 时 delivery 入队成功,不触发直投', async () => {
|
|
196
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
197
|
-
const busyBackend = new MockSchedulerBackend()
|
|
198
|
-
busyBackend.deliveryHandle = {
|
|
199
|
-
send: vi.fn(),
|
|
200
|
-
sendChecked: vi.fn(),
|
|
201
|
-
flush: vi.fn(),
|
|
202
|
-
depth: vi.fn(() => 0),
|
|
203
|
-
dispose: vi.fn(),
|
|
204
|
-
} as any
|
|
205
|
-
const busyRuntime = new SchedulerRuntime(busyBackend, busyCtx)
|
|
206
|
-
const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
207
|
-
const dispatched = await busyRuntime.dispatchTask(task)
|
|
208
|
-
|
|
209
|
-
expect(dispatched).toBe(true)
|
|
210
|
-
expect(busyBackend.deliveryHandle.send).toHaveBeenCalledTimes(1)
|
|
211
|
-
expect(busyBackend.sentMessages).toHaveLength(0)
|
|
212
|
-
expect(task.pending).toBe(false)
|
|
213
|
-
})
|
|
214
|
-
|
|
215
128
|
it('sendMessage 失败 → 记 failed 状态但不 rethrow', async () => {
|
|
216
129
|
const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
217
130
|
// backend.sendMessage 抛错模拟注入失败
|
|
@@ -234,7 +147,7 @@ describe('SchedulerRuntime', () => {
|
|
|
234
147
|
// ── R3-S1:dispatchTask in-flight 守卫 ──
|
|
235
148
|
// tick 为 fire-and-forget:tick1 的 await sendMessage 挂起超过 TICK_INTERVAL_MS(如 pi
|
|
236
149
|
// 卡死)时,tick2 的 step2 再标 pending → step3 对同一 task 并发第二个 dispatch → 同一
|
|
237
|
-
// prompt
|
|
150
|
+
// prompt 双注入。守卫:同任务在途(Set<taskId>)
|
|
238
151
|
// 时 skip 本轮并 warn;不同任务不受影响。
|
|
239
152
|
describe('dispatchTask in-flight 守卫(R3-S1)', () => {
|
|
240
153
|
beforeEach(() => {
|
|
@@ -252,7 +165,7 @@ describe('SchedulerRuntime', () => {
|
|
|
252
165
|
const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
|
|
253
166
|
backend.sendMessage = vi.fn(() => sendPromise)
|
|
254
167
|
|
|
255
|
-
const task = await runtime.addTask('in-flight', { mode: 'interval', intervalMs: 60000 }
|
|
168
|
+
const task = await runtime.addTask('in-flight', { mode: 'interval', intervalMs: 60000 })
|
|
256
169
|
task.nextRunAt = Date.now() - 1000
|
|
257
170
|
|
|
258
171
|
// tick1:dispatch 进入 sendMessage 挂起(同步段已置 in-flight 标记)
|
|
@@ -280,8 +193,8 @@ describe('SchedulerRuntime', () => {
|
|
|
280
193
|
const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
|
|
281
194
|
backend.sendMessage = vi.fn(() => sendPromise)
|
|
282
195
|
|
|
283
|
-
const task1 = await runtime.addTask('first', { mode: 'interval', intervalMs: 60000 }
|
|
284
|
-
const task2 = await runtime.addTask('second', { mode: 'interval', intervalMs: 60000 }
|
|
196
|
+
const task1 = await runtime.addTask('first', { mode: 'interval', intervalMs: 60000 })
|
|
197
|
+
const task2 = await runtime.addTask('second', { mode: 'interval', intervalMs: 60000 })
|
|
285
198
|
task1.nextRunAt = Date.now() - 1000
|
|
286
199
|
task2.nextRunAt = Date.now() - 1000
|
|
287
200
|
|
|
@@ -315,10 +228,10 @@ describe('SchedulerRuntime', () => {
|
|
|
315
228
|
})
|
|
316
229
|
|
|
317
230
|
it('rate-limits dispatch to 6 per minute', async () => {
|
|
318
|
-
//
|
|
231
|
+
// 直投模型下 runtime 无 idle/busy 判定,直接命中 rate-limit
|
|
319
232
|
const tasks: Awaited<ReturnType<typeof runtime.addTask>>[] = []
|
|
320
233
|
for (let i = 0; i < 7; i++) {
|
|
321
|
-
tasks.push(await runtime.addTask(`task ${i}`, { mode: 'interval', intervalMs: 60000 }
|
|
234
|
+
tasks.push(await runtime.addTask(`task ${i}`, { mode: 'interval', intervalMs: 60000 }))
|
|
322
235
|
}
|
|
323
236
|
|
|
324
237
|
// 7 次 dispatch 全在同一分钟内(fake time 不前进)
|
|
@@ -331,7 +244,7 @@ describe('SchedulerRuntime', () => {
|
|
|
331
244
|
})
|
|
332
245
|
|
|
333
246
|
it('allows dispatch again after 1 minute window slides', async () => {
|
|
334
|
-
const task = await runtime.addTask('t', { mode: 'interval', intervalMs: 60000 }
|
|
247
|
+
const task = await runtime.addTask('t', { mode: 'interval', intervalMs: 60000 })
|
|
335
248
|
// 先消耗完 6 次配额
|
|
336
249
|
for (let i = 0; i < 6; i++) {
|
|
337
250
|
// 同一 task 反复 dispatch(interval 模式每次重算 nextRunAt,不影响 rate-limit 计数)
|
|
@@ -458,8 +371,8 @@ describe('SchedulerRuntime', () => {
|
|
|
458
371
|
await runtime.toggleTask(disabled.id, false)
|
|
459
372
|
disabled.nextRunAt = Date.now() - 1000
|
|
460
373
|
|
|
461
|
-
// enabled +
|
|
462
|
-
const enabledTask = await runtime.addTask('enabled', { mode: 'interval', intervalMs: 60000 }
|
|
374
|
+
// enabled + 到期(隔离 enabled 维度)
|
|
375
|
+
const enabledTask = await runtime.addTask('enabled', { mode: 'interval', intervalMs: 60000 })
|
|
463
376
|
enabledTask.nextRunAt = Date.now() - 1000
|
|
464
377
|
|
|
465
378
|
await runtime.tickScheduler()
|
|
@@ -475,19 +388,16 @@ describe('SchedulerRuntime', () => {
|
|
|
475
388
|
})
|
|
476
389
|
|
|
477
390
|
// ── MF-1:toggle enable 重算 nextRunAt 到未来时清除残留 pending ──
|
|
478
|
-
//
|
|
479
|
-
//
|
|
391
|
+
// pending 在 dispatchTaskInner 成功/失败后清除(成功清、失败也清——重试由 nextRunAt 未推进
|
|
392
|
+
// 下的下个 tick step2 重标驱动)。
|
|
480
393
|
it('MF-1: enable 重算 nextRunAt 到未来时清除残留 pending,不提前 dispatch', async () => {
|
|
481
|
-
// 可控 idle 状态:先 busy 模拟 dispatchTask 跳过保留 pending(W4),后切 idle 排除 busy 干扰
|
|
482
|
-
let idle = false
|
|
483
|
-
const controllableCtx = { isIdle: () => idle, hasPendingMessages: () => false }
|
|
484
394
|
const controllableBackend = new MockSchedulerBackend()
|
|
485
|
-
const rt = new SchedulerRuntime(controllableBackend
|
|
395
|
+
const rt = new SchedulerRuntime(controllableBackend)
|
|
486
396
|
|
|
487
397
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
488
398
|
const task = await rt.addTask('mf1', { mode: 'interval', intervalMs: 60000 })
|
|
489
399
|
|
|
490
|
-
// T0+61s:任务到期
|
|
400
|
+
// T0+61s:任务到期 → steer 直投
|
|
491
401
|
// 直投成功后 pending=false
|
|
492
402
|
vi.setSystemTime(new Date('2026-01-01T00:01:01Z'))
|
|
493
403
|
await rt.tickScheduler()
|
|
@@ -539,7 +449,7 @@ describe('SchedulerRuntime', () => {
|
|
|
539
449
|
data: op,
|
|
540
450
|
}))
|
|
541
451
|
// fakeSessionFile 默认 '/test/session.json',与第一个 backend 一致 → owner 过滤放行
|
|
542
|
-
const replayRuntime = new SchedulerRuntime(replayBackend
|
|
452
|
+
const replayRuntime = new SchedulerRuntime(replayBackend)
|
|
543
453
|
replayRuntime.loadTasks(replayBackend.loadTasks())
|
|
544
454
|
|
|
545
455
|
const replayed = replayRuntime.getTask(task.id)
|
|
@@ -599,146 +509,6 @@ describe('SchedulerRuntime', () => {
|
|
|
599
509
|
})
|
|
600
510
|
})
|
|
601
511
|
|
|
602
|
-
// ── U4:delivery 内核集成 ──
|
|
603
|
-
// scheduler 的非 force 任务走 delivery 内核(park 模式)。
|
|
604
|
-
// force 任务和无 delivery handle 时走 dispatchDirect(直投)。
|
|
605
|
-
describe('U4: delivery 内核集成', () => {
|
|
606
|
-
it('有 delivery handle 时非 force 任务走 delivery 入队(不直调 sendMessage)', async () => {
|
|
607
|
-
// 模拟 delivery handle(必须在 runtime 构造前设置,构造时从 backend 获取)
|
|
608
|
-
const sentViaDelivery: Array<{ content: string; intent: string }> = []
|
|
609
|
-
const mockDelivery = {
|
|
610
|
-
send: vi.fn((msg: { payload: { content: string }; intent?: string }) => {
|
|
611
|
-
sentViaDelivery.push({
|
|
612
|
-
content: msg.payload.content,
|
|
613
|
-
intent: msg.intent ?? 'after-run',
|
|
614
|
-
})
|
|
615
|
-
}),
|
|
616
|
-
sendChecked: vi.fn(),
|
|
617
|
-
flush: vi.fn(),
|
|
618
|
-
depth: vi.fn(() => 0),
|
|
619
|
-
dispose: vi.fn(),
|
|
620
|
-
}
|
|
621
|
-
backend.deliveryHandle = mockDelivery as any
|
|
622
|
-
// 重新创建 runtime(构造时获取 delivery handle)
|
|
623
|
-
const deliveryRuntime = new SchedulerRuntime(backend, mockCtx)
|
|
624
|
-
|
|
625
|
-
const task = await deliveryRuntime.addTask('delivery-test', { mode: 'interval', intervalMs: 60000 })
|
|
626
|
-
await deliveryRuntime.dispatchTask(task)
|
|
627
|
-
|
|
628
|
-
// 非 force 任务走 delivery 入队
|
|
629
|
-
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
630
|
-
expect(sentViaDelivery[0]!.content).toBe('delivery-test')
|
|
631
|
-
expect(sentViaDelivery[0]!.intent).toBe('after-run')
|
|
632
|
-
// 不直调 sendMessage
|
|
633
|
-
expect(backend.sentMessages).toHaveLength(0)
|
|
634
|
-
// pending 已清除(入队后移交内核管理)
|
|
635
|
-
expect(task.pending).toBe(false)
|
|
636
|
-
})
|
|
637
|
-
|
|
638
|
-
it('force 任务绕过 delivery 直投', async () => {
|
|
639
|
-
const mockDelivery = {
|
|
640
|
-
send: vi.fn(),
|
|
641
|
-
sendChecked: vi.fn(),
|
|
642
|
-
flush: vi.fn(),
|
|
643
|
-
depth: vi.fn(() => 0),
|
|
644
|
-
dispose: vi.fn(),
|
|
645
|
-
}
|
|
646
|
-
backend.deliveryHandle = mockDelivery as any
|
|
647
|
-
const deliveryRuntime = new SchedulerRuntime(backend, mockCtx)
|
|
648
|
-
|
|
649
|
-
const task = await deliveryRuntime.addTask('force-test', { mode: 'interval', intervalMs: 60000 }, { force: true })
|
|
650
|
-
await deliveryRuntime.dispatchTask(task)
|
|
651
|
-
|
|
652
|
-
// force 任务直投(不走 delivery)
|
|
653
|
-
expect(mockDelivery.send).not.toHaveBeenCalled()
|
|
654
|
-
expect(backend.sentMessages).toHaveLength(1)
|
|
655
|
-
expect(backend.sentMessages[0]!.msg.content).toBe('force-test')
|
|
656
|
-
})
|
|
657
|
-
|
|
658
|
-
it('tick 末尾调 delivery.flush()', async () => {
|
|
659
|
-
const mockDelivery = {
|
|
660
|
-
send: vi.fn(),
|
|
661
|
-
sendChecked: vi.fn(),
|
|
662
|
-
flush: vi.fn(),
|
|
663
|
-
depth: vi.fn(() => 0),
|
|
664
|
-
dispose: vi.fn(),
|
|
665
|
-
}
|
|
666
|
-
backend.deliveryHandle = mockDelivery as any
|
|
667
|
-
const deliveryRuntime = new SchedulerRuntime(backend, mockCtx)
|
|
668
|
-
|
|
669
|
-
await deliveryRuntime.tickScheduler()
|
|
670
|
-
|
|
671
|
-
// tick 完成后调 flush
|
|
672
|
-
expect(mockDelivery.flush).toHaveBeenCalledTimes(1)
|
|
673
|
-
})
|
|
674
|
-
|
|
675
|
-
it('handleSettled delivered(dedupeKey=task.id 反查)→ 成功记账', async () => {
|
|
676
|
-
const task = await runtime.addTask('settle-test', { mode: 'interval', intervalMs: 60000 })
|
|
677
|
-
|
|
678
|
-
// 模拟 delivery onSettled 回调(dispatchViaDelivery 挂 dedupeKey=task.id)
|
|
679
|
-
runtime.handleSettled(settledMsg('settle-test', task.id), 'delivered')
|
|
680
|
-
|
|
681
|
-
// onDispatchSuccess 是异步的,等待完成
|
|
682
|
-
await vi.waitFor(() => {
|
|
683
|
-
expect(task.runCount).toBe(1)
|
|
684
|
-
expect(task.lastStatus).toBe('success')
|
|
685
|
-
})
|
|
686
|
-
expect(task.lastError).toBeUndefined()
|
|
687
|
-
})
|
|
688
|
-
|
|
689
|
-
it('handleSettled rejected → 失败记账(once 不删持久化)', async () => {
|
|
690
|
-
const task = await runtime.addTask('settle-fail', { mode: 'interval', intervalMs: 60000 }, { kind: 'once' })
|
|
691
|
-
|
|
692
|
-
// 模拟 delivery onSettled 回调
|
|
693
|
-
runtime.handleSettled(settledMsg('settle-fail', task.id), 'rejected')
|
|
694
|
-
|
|
695
|
-
// 失败记账
|
|
696
|
-
expect(task.lastStatus).toBe('failed')
|
|
697
|
-
expect(task.history[task.history.length - 1]!.status).toBe('failed')
|
|
698
|
-
// once 任务失败不删持久化(任务仍在)
|
|
699
|
-
expect(runtime.getTask(task.id)).toBeDefined()
|
|
700
|
-
})
|
|
701
|
-
|
|
702
|
-
it('#11 同 prompt 两任务:各自 onSettled 按 dedupeKey 精确记账(不互相错配)', async () => {
|
|
703
|
-
const taskA = await runtime.addTask('same-prompt', { mode: 'interval', intervalMs: 60000 })
|
|
704
|
-
const taskB = await runtime.addTask('same-prompt', { mode: 'interval', intervalMs: 60000 })
|
|
705
|
-
expect(taskA.id).not.toBe(taskB.id)
|
|
706
|
-
|
|
707
|
-
// A 的投递终态:只记 A 的账(旧 content 反查 find 取首个命中,B 先入 Map 时错记 B)
|
|
708
|
-
runtime.handleSettled(settledMsg('same-prompt', taskA.id), 'delivered')
|
|
709
|
-
|
|
710
|
-
await vi.waitFor(() => {
|
|
711
|
-
expect(taskA.runCount).toBe(1)
|
|
712
|
-
})
|
|
713
|
-
expect(taskA.lastStatus).toBe('success')
|
|
714
|
-
expect(taskB.runCount).toBe(0)
|
|
715
|
-
expect(taskB.lastStatus).toBeUndefined()
|
|
716
|
-
|
|
717
|
-
// B 的投递终态:记 B 的账
|
|
718
|
-
runtime.handleSettled(settledMsg('same-prompt', taskB.id), 'delivered')
|
|
719
|
-
|
|
720
|
-
await vi.waitFor(() => {
|
|
721
|
-
expect(taskB.runCount).toBe(1)
|
|
722
|
-
})
|
|
723
|
-
expect(taskB.lastStatus).toBe('success')
|
|
724
|
-
})
|
|
725
|
-
|
|
726
|
-
it('#11 任务先删后 delivered → 不炸不误记其他任务', async () => {
|
|
727
|
-
const taskA = await runtime.addTask('deleted-task', { mode: 'interval', intervalMs: 60000 })
|
|
728
|
-
const taskB = await runtime.addTask('other-task', { mode: 'interval', intervalMs: 60000 })
|
|
729
|
-
|
|
730
|
-
// once 成功 / 手动删除后 delivered 迟到:dedupeKey 反查未命中 → no-op
|
|
731
|
-
runtime.deleteTask(taskA.id)
|
|
732
|
-
expect(() => {
|
|
733
|
-
runtime.handleSettled(settledMsg('deleted-task', taskA.id), 'delivered')
|
|
734
|
-
}).not.toThrow()
|
|
735
|
-
|
|
736
|
-
// 不误记其他任务的账
|
|
737
|
-
expect(taskB.runCount).toBe(0)
|
|
738
|
-
expect(taskB.lastStatus).toBeUndefined()
|
|
739
|
-
})
|
|
740
|
-
})
|
|
741
|
-
|
|
742
512
|
// ── F2:tick 错误分诊(crash-fix)──
|
|
743
513
|
// startScheduler 的 interval 回调对 fire-and-forget 的 tickScheduler() 加 catch:
|
|
744
514
|
// stale 类错误(session 替换后泄漏 timer 访问 stale ctx)→ warn "tick stopped" + stopScheduler
|
|
@@ -827,7 +597,7 @@ describe('SchedulerRuntime', () => {
|
|
|
827
597
|
vi.useFakeTimers()
|
|
828
598
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
829
599
|
staleFlag = false
|
|
830
|
-
genRuntime = new SchedulerRuntime(backend,
|
|
600
|
+
genRuntime = new SchedulerRuntime(backend, () => staleFlag)
|
|
831
601
|
})
|
|
832
602
|
|
|
833
603
|
afterEach(() => {
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import { MockSchedulerBackend } from '
|
|
3
|
+
import { MockSchedulerBackend } from './mock-backend.js'
|
|
4
4
|
import { SchedulerRuntime } from '../runtime.js'
|
|
5
5
|
import { SchedulerService } from '../service.js'
|
|
6
6
|
|
|
7
|
-
const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
|
|
8
|
-
|
|
9
7
|
describe('SchedulerService', () => {
|
|
10
8
|
let service: SchedulerService
|
|
11
9
|
let backend: MockSchedulerBackend
|
|
@@ -15,14 +13,13 @@ describe('SchedulerService', () => {
|
|
|
15
13
|
// 固定时间避免 clock-boundary flake:formatRelativeTime 内部读 Date.now(),
|
|
16
14
|
// 两次读之间的延迟可能导致 "in 1h" 变成 "in 59m"。
|
|
17
15
|
backend.nowValue = Date.now()
|
|
18
|
-
service = new SchedulerService(new SchedulerRuntime(backend
|
|
16
|
+
service = new SchedulerService(new SchedulerRuntime(backend), () => backend.now())
|
|
19
17
|
})
|
|
20
18
|
|
|
21
19
|
describe('create', () => {
|
|
22
20
|
it('creates task with duration and returns full summary', async () => {
|
|
23
21
|
const result = await service.create('check build', '5m')
|
|
24
22
|
expect(result.success).toBe(true)
|
|
25
|
-
expect(result.errorCode).toBeUndefined()
|
|
26
23
|
expect(result.message).toContain('Task "check build"')
|
|
27
24
|
expect(result.message).toContain('every 5m')
|
|
28
25
|
expect(result.message).toContain('Next 5 runs:')
|
|
@@ -51,7 +48,7 @@ describe('SchedulerService', () => {
|
|
|
51
48
|
const fixedNow = Date.now()
|
|
52
49
|
const injectBackend = new MockSchedulerBackend()
|
|
53
50
|
injectBackend.nowValue = fixedNow
|
|
54
|
-
const nowService = new SchedulerService(new SchedulerRuntime(injectBackend
|
|
51
|
+
const nowService = new SchedulerService(new SchedulerRuntime(injectBackend), () => injectBackend.now())
|
|
55
52
|
const result = await nowService.create('one shot', '1h', { kind: 'once' })
|
|
56
53
|
expect(result.data!.nextRuns[0]).toBe(fixedNow + 3_600_000)
|
|
57
54
|
expect(result.message).toContain('in 1h')
|
|
@@ -63,22 +60,20 @@ describe('SchedulerService', () => {
|
|
|
63
60
|
expect(result.data!.task.schedule).toEqual({ mode: 'cron', cronExpression: '0 0 9 * * 1-5' })
|
|
64
61
|
})
|
|
65
62
|
|
|
66
|
-
it('returns
|
|
63
|
+
it('returns invalid-schedule message for invalid schedule', async () => {
|
|
67
64
|
const result = await service.create('test', 'invalid')
|
|
68
65
|
expect(result).toEqual({
|
|
69
66
|
success: false,
|
|
70
|
-
errorCode: 'INVALID_SCHEDULE',
|
|
71
67
|
message: 'Invalid schedule: "invalid". Use duration (5m/2h/1d) or cron expression (*/10 * * * *).',
|
|
72
68
|
})
|
|
73
69
|
})
|
|
74
70
|
|
|
75
|
-
it('returns
|
|
71
|
+
it('returns task-limit message when 50 tasks exist', async () => {
|
|
76
72
|
for (let i = 0; i < 50; i++) {
|
|
77
73
|
await service.create(`task ${i}`, '5m')
|
|
78
74
|
}
|
|
79
75
|
const result = await service.create('one more', '5m')
|
|
80
76
|
expect(result.success).toBe(false)
|
|
81
|
-
expect(result.errorCode).toBe('TASK_LIMIT_REACHED')
|
|
82
77
|
expect(result.message).toContain('Task limit reached (50)')
|
|
83
78
|
})
|
|
84
79
|
})
|
|
@@ -118,29 +113,26 @@ describe('SchedulerService', () => {
|
|
|
118
113
|
expect(service.runtime.getTask(id)?.enabled).toBe(false)
|
|
119
114
|
})
|
|
120
115
|
|
|
121
|
-
it('TC4: returns
|
|
116
|
+
it('TC4: returns not-found message for unknown id', async () => {
|
|
122
117
|
const result = await service.toggle('deadbeef', true)
|
|
123
118
|
expect(result).toEqual({
|
|
124
119
|
success: false,
|
|
125
|
-
errorCode: 'TASK_NOT_FOUND',
|
|
126
120
|
message: 'Task deadbeef not found.',
|
|
127
121
|
})
|
|
128
122
|
})
|
|
129
123
|
|
|
130
|
-
it('returns
|
|
124
|
+
it('returns invalid-params message when id missing', async () => {
|
|
131
125
|
const result = await service.toggle(undefined, true)
|
|
132
126
|
expect(result).toEqual({
|
|
133
127
|
success: false,
|
|
134
|
-
errorCode: 'INVALID_PARAMS',
|
|
135
128
|
message: 'id is required for toggle.',
|
|
136
129
|
})
|
|
137
130
|
})
|
|
138
131
|
|
|
139
|
-
it('returns
|
|
132
|
+
it('returns invalid-params message when enabled missing', async () => {
|
|
140
133
|
const result = await service.toggle('abc12345', undefined)
|
|
141
134
|
expect(result).toEqual({
|
|
142
135
|
success: false,
|
|
143
|
-
errorCode: 'INVALID_PARAMS',
|
|
144
136
|
message: 'enabled is required for toggle.',
|
|
145
137
|
})
|
|
146
138
|
})
|
|
@@ -155,11 +147,10 @@ describe('SchedulerService', () => {
|
|
|
155
147
|
expect(service.runtime.getTask(id)).toBeUndefined()
|
|
156
148
|
})
|
|
157
149
|
|
|
158
|
-
it('TC4: returns
|
|
150
|
+
it('TC4: returns not-found message for unknown id', () => {
|
|
159
151
|
const result = service.delete('deadbeef')
|
|
160
152
|
expect(result).toEqual({
|
|
161
153
|
success: false,
|
|
162
|
-
errorCode: 'TASK_NOT_FOUND',
|
|
163
154
|
message: 'Task deadbeef not found.',
|
|
164
155
|
})
|
|
165
156
|
})
|
|
@@ -174,41 +165,36 @@ describe('SchedulerService', () => {
|
|
|
174
165
|
expect(service.runtime.getTask(id)?.runCount).toBe(1)
|
|
175
166
|
})
|
|
176
167
|
|
|
177
|
-
it('TC4: returns
|
|
168
|
+
it('TC4: returns not-found message for unknown id', async () => {
|
|
178
169
|
const result = await service.run('deadbeef')
|
|
179
170
|
expect(result).toEqual({
|
|
180
171
|
success: false,
|
|
181
|
-
errorCode: 'TASK_NOT_FOUND',
|
|
182
172
|
message: 'Task deadbeef not found.',
|
|
183
173
|
})
|
|
184
174
|
})
|
|
185
175
|
|
|
186
|
-
it('returns
|
|
176
|
+
it('returns dispatch-skipped message for disabled task (not not-found)', async () => {
|
|
187
177
|
const created = await service.create('test', '5m')
|
|
188
178
|
const id = created.data!.task.id
|
|
189
179
|
await service.toggle(id, false)
|
|
190
180
|
const result = await service.run(id)
|
|
191
181
|
expect(result).toEqual({
|
|
192
182
|
success: false,
|
|
193
|
-
|
|
194
|
-
message: `Task ${id} not dispatched (disabled, rate-limited, or already queued for delivery).`,
|
|
183
|
+
message: `Task ${id} not dispatched (disabled, rate-limited, or dispatch in flight).`,
|
|
195
184
|
})
|
|
196
185
|
})
|
|
197
186
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
|
|
202
|
-
const busyBackend = new MockSchedulerBackend()
|
|
203
|
-
const busyService = new SchedulerService(new SchedulerRuntime(busyBackend, busyCtx), () => busyBackend.now())
|
|
187
|
+
it('run 直投成功', async () => {
|
|
188
|
+
const backend2 = new MockSchedulerBackend()
|
|
189
|
+
const service2 = new SchedulerService(new SchedulerRuntime(backend2), () => backend2.now())
|
|
204
190
|
|
|
205
|
-
const created = await
|
|
191
|
+
const created = await service2.create('test', '5m')
|
|
206
192
|
const id = created.data!.task.id
|
|
207
|
-
const result = await
|
|
193
|
+
const result = await service2.run(id)
|
|
208
194
|
|
|
209
|
-
//
|
|
195
|
+
// steer 直投成功(runtime 层无 idle/busy 判定)
|
|
210
196
|
expect(result.success).toBe(true)
|
|
211
|
-
expect(
|
|
197
|
+
expect(backend2.sentMessages).toHaveLength(1)
|
|
212
198
|
})
|
|
213
199
|
})
|
|
214
200
|
})
|
|
@@ -1,34 +1,30 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import { MockSchedulerBackend } from '
|
|
3
|
+
import { MockSchedulerBackend } from './mock-backend.js'
|
|
4
4
|
import { SchedulerRuntime } from '../runtime.js'
|
|
5
5
|
import { SchedulerService } from '../service.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
|
|
6
|
+
import { handleSchedule, handleScheduleControl } from '../tool.js'
|
|
9
7
|
|
|
10
8
|
describe('schedule tool', () => {
|
|
11
9
|
let service: SchedulerService
|
|
12
|
-
let handler: ReturnType<typeof createScheduleHandler>
|
|
13
10
|
|
|
14
11
|
beforeEach(() => {
|
|
15
12
|
vi.clearAllMocks()
|
|
16
13
|
const backend = new MockSchedulerBackend()
|
|
17
|
-
service = new SchedulerService(new SchedulerRuntime(backend
|
|
18
|
-
handler = createScheduleHandler(service)
|
|
14
|
+
service = new SchedulerService(new SchedulerRuntime(backend), () => backend.now())
|
|
19
15
|
})
|
|
20
16
|
|
|
21
17
|
it('creates task with duration', async () => {
|
|
22
|
-
const result = await
|
|
18
|
+
const result = await handleSchedule(service, { prompt: 'check build', schedule: '5m' })
|
|
23
19
|
expect(result.content[0]!.text).toContain('Task "check build"')
|
|
24
20
|
const details = result.details as { task: { schedule: { mode: string; intervalMs: number } } }
|
|
25
21
|
expect(details.task.schedule).toEqual({ mode: 'interval', intervalMs: 300000 })
|
|
26
22
|
})
|
|
27
23
|
|
|
28
24
|
// W4:业务失败 throw(pi 只对 execute throw 置 isError:true,返回值里的 isError
|
|
29
|
-
// 被 agent-loop
|
|
25
|
+
// 被 agent-loop 丢弃——错误轮曾被标成功)。
|
|
30
26
|
it('invalid schedule throws with message (W4: pi 采信 throw)', async () => {
|
|
31
|
-
await expect(
|
|
27
|
+
await expect(handleSchedule(service, { prompt: 'test', schedule: 'invalid' })).rejects.toThrow(
|
|
32
28
|
'Invalid schedule',
|
|
33
29
|
)
|
|
34
30
|
})
|
|
@@ -36,54 +32,52 @@ describe('schedule tool', () => {
|
|
|
36
32
|
|
|
37
33
|
describe('schedule_control tool', () => {
|
|
38
34
|
let service: SchedulerService
|
|
39
|
-
let handler: ReturnType<typeof createScheduleControlHandler>
|
|
40
35
|
|
|
41
36
|
beforeEach(() => {
|
|
42
37
|
vi.clearAllMocks()
|
|
43
38
|
const backend = new MockSchedulerBackend()
|
|
44
|
-
service = new SchedulerService(new SchedulerRuntime(backend
|
|
45
|
-
handler = createScheduleControlHandler(service)
|
|
39
|
+
service = new SchedulerService(new SchedulerRuntime(backend), () => backend.now())
|
|
46
40
|
})
|
|
47
41
|
|
|
48
42
|
it('lists tasks', async () => {
|
|
49
43
|
await service.create('test', '5m')
|
|
50
|
-
const result = await
|
|
44
|
+
const result = await handleScheduleControl(service, { action: 'list' })
|
|
51
45
|
expect(result.content[0]!.text).toContain('test')
|
|
52
46
|
})
|
|
53
47
|
|
|
54
48
|
it('returns empty message when no tasks', async () => {
|
|
55
|
-
const result = await
|
|
49
|
+
const result = await handleScheduleControl(service, { action: 'list' })
|
|
56
50
|
expect(result.content[0]!.text).toBe('No scheduled tasks.')
|
|
57
51
|
})
|
|
58
52
|
|
|
59
53
|
it('toggles task', async () => {
|
|
60
54
|
const created = await service.create('test', '5m')
|
|
61
|
-
const result = await
|
|
55
|
+
const result = await handleScheduleControl(service, { action: 'toggle', id: created.data!.task.id, enabled: false })
|
|
62
56
|
expect(result.content[0]!.text).toContain('disabled')
|
|
63
57
|
})
|
|
64
58
|
|
|
65
59
|
it('missing id on toggle throws (W4)', async () => {
|
|
66
|
-
await expect(
|
|
60
|
+
await expect(handleScheduleControl(service, { action: 'toggle', enabled: true })).rejects.toThrow(
|
|
67
61
|
'id is required',
|
|
68
62
|
)
|
|
69
63
|
})
|
|
70
64
|
|
|
71
|
-
// TC5 tool 侧:toggle 不存在 id → throw(service message 原文即
|
|
72
|
-
it('TC5: toggle unknown id throws with
|
|
73
|
-
await expect(
|
|
65
|
+
// TC5 tool 侧:toggle 不存在 id → throw(service message 原文即 not found 文案)
|
|
66
|
+
it('TC5: toggle unknown id throws with not-found message (W4)', async () => {
|
|
67
|
+
await expect(handleScheduleControl(service, { action: 'toggle', id: 'deadbeef', enabled: false })).rejects.toThrow(
|
|
74
68
|
'Task deadbeef not found.',
|
|
75
69
|
)
|
|
76
70
|
})
|
|
77
71
|
|
|
78
72
|
it('deletes task', async () => {
|
|
79
73
|
const created = await service.create('test', '5m')
|
|
80
|
-
const result = await
|
|
74
|
+
const result = await handleScheduleControl(service, { action: 'delete', id: created.data!.task.id })
|
|
81
75
|
expect(result.content[0]!.text).toContain('deleted')
|
|
82
76
|
})
|
|
83
77
|
|
|
84
78
|
it('runs task now', async () => {
|
|
85
79
|
const created = await service.create('test', '5m')
|
|
86
|
-
const result = await
|
|
80
|
+
const result = await handleScheduleControl(service, { action: 'run', id: created.data!.task.id })
|
|
87
81
|
expect(result.content[0]!.text).toContain('executed')
|
|
88
82
|
})
|
|
89
83
|
})
|