@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.
@@ -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 '../backend.js'
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, mockCtx)
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
- it('dispatches task when idle', async () => {
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 双注入(force 任务绕过 isIdle gate 直接受影响)。守卫:同任务在途(Set<taskId>)
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 }, { force: true })
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 }, { force: true })
284
- const task2 = await runtime.addTask('second', { mode: 'interval', intervalMs: 60000 }, { force: true })
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
- // force=true 保证不被 idle/busy 干扰,直接命中 rate-limit
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 }, { force: true }))
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 }, { force: true })
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 + 到期(force=true 绕过 idle/busy 检查,隔离 enabled 维度)
462
- const enabledTask = await runtime.addTask('enabled', { mode: 'interval', intervalMs: 60000 }, { force: true })
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
- // U4 变更:gate 已交 delivery 内核,非 force 任务在 busy 时通过 delivery 入队(无 handle 时直投)。
479
- // pending 在 dispatchViaDelivery/dispatchDirect 成功后清除(不再依赖 gate 跳过保留 pending)。
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, controllableCtx)
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:任务到期 + 无 delivery handle → dispatchDirect(直投,不检查 idle)
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, mockCtx)
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, mockCtx, () => staleFlag)
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 '../backend.js'
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, mockCtx), () => backend.now())
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, mockCtx), () => injectBackend.now())
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 INVALID_SCHEDULE for invalid schedule', async () => {
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 TASK_LIMIT_REACHED when 50 tasks exist', async () => {
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 TASK_NOT_FOUND for unknown id', async () => {
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 INVALID_PARAMS when id missing', async () => {
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 INVALID_PARAMS when enabled missing', async () => {
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 TASK_NOT_FOUND for unknown id', () => {
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 TASK_NOT_FOUND for unknown id', async () => {
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 DISPATCH_SKIPPED for disabled task (not not-found)', async () => {
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
- errorCode: 'DISPATCH_SKIPPED',
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
- // U4 变更:gate 已交 delivery 内核,无 delivery handle 时走 dispatchDirect(直投不检查 idle)。
199
- // busy 不再导致 DISPATCH_SKIPPED(直投成功)。
200
- it('无 delivery handle 时 busy 不影响 dispatch(直投)', async () => {
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 busyService.create('test', '5m')
191
+ const created = await service2.create('test', '5m')
206
192
  const id = created.data!.task.id
207
- const result = await busyService.run(id)
193
+ const result = await service2.run(id)
208
194
 
209
- // 直投成功(无 delivery handle → dispatchDirect,不检查 idle)
195
+ // steer 直投成功(runtime 层无 idle/busy 判定)
210
196
  expect(result.success).toBe(true)
211
- expect(busyBackend.sentMessages).toHaveLength(1)
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 '../backend.js'
3
+ import { MockSchedulerBackend } from './mock-backend.js'
4
4
  import { SchedulerRuntime } from '../runtime.js'
5
5
  import { SchedulerService } from '../service.js'
6
- import { createScheduleControlHandler, createScheduleHandler } from '../tool.js'
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, mockCtx), () => backend.now())
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 handler({ prompt: 'check build', schedule: '5m' })
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 丢弃——错误轮曾被标成功)。原 errorCode details 随 throw 不再产出。
25
+ // 被 agent-loop 丢弃——错误轮曾被标成功)。
30
26
  it('invalid schedule throws with message (W4: pi 采信 throw)', async () => {
31
- await expect(handler({ prompt: 'test', schedule: 'invalid' })).rejects.toThrow(
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, mockCtx), () => backend.now())
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 handler({ action: 'list' })
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 handler({ action: 'list' })
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 handler({ action: 'toggle', id: created.data!.task.id, enabled: false })
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(handler({ action: 'toggle', enabled: true })).rejects.toThrow(
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 原文即 TASK_NOT_FOUND 文案)
72
- it('TC5: toggle unknown id throws with TASK_NOT_FOUND message (W4)', async () => {
73
- await expect(handler({ action: 'toggle', id: 'deadbeef', enabled: false })).rejects.toThrow(
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 handler({ action: 'delete', id: created.data!.task.id })
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 handler({ action: 'run', id: created.data!.task.id })
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
  })