@zhushanwen/pi-scheduler 0.3.4 → 0.4.1

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,15 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
+ // Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
4
+ const { loggerMock } = vi.hoisted(() => ({
5
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
+ }))
7
+ vi.mock('@zhushanwen/pi-extension-logger', () => ({
8
+ getLogger: () => loggerMock,
9
+ createLogger: () => loggerMock,
10
+ setPiHandle: vi.fn(),
11
+ }))
12
+
3
13
  import { replayFoldEntries, type SchedulerEntryLike } from '../replay.js'
4
14
  import type { SchedulerEntryOp, TaskSnapshot } from '../types.js'
5
15
 
@@ -141,8 +151,8 @@ describe('replayFoldEntries', () => {
141
151
  })
142
152
 
143
153
  // ── TC-W-GETENTRIES-FALLBACK:getEntries 异常兜底(gap4)──
144
- it('TC-W-GETENTRIES-FALLBACK: 迭代器抛错时 console.warn + 返回空 Map,不崩溃', () => {
145
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
154
+ it('TC-W-GETENTRIES-FALLBACK: 迭代器抛错时 logger.warn + 返回空 Map,不崩溃', () => {
155
+ loggerMock.warn.mockClear()
146
156
  // 构造迭代时抛错的 iterable(模拟 session JSONL 损坏)
147
157
  const throwingIterable: Iterable<SchedulerEntryLike> = {
148
158
  [Symbol.iterator]() {
@@ -158,11 +168,10 @@ describe('replayFoldEntries', () => {
158
168
 
159
169
  const result = replayFoldEntries(throwingIterable, '/s.json')
160
170
  expect(result.size).toBe(0)
161
- expect(warnSpy).toHaveBeenCalled()
162
- const msg = warnSpy.mock.calls[0]![0] as string
171
+ expect(loggerMock.warn).toHaveBeenCalled()
172
+ const msg = loggerMock.warn.mock.calls[0]![0] as string
163
173
  expect(msg).toContain('replayFoldEntries failed')
164
- expect(msg).toContain('JSONL corrupted')
165
- warnSpy.mockRestore()
174
+ expect(loggerMock.warn.mock.calls[0]![1]).toEqual(expect.objectContaining({ error: expect.stringContaining('JSONL corrupted') }))
166
175
  })
167
176
 
168
177
  // ── gap2 补强:advance 后 lastStatus/lastRunAt/runCount/history 全恢复 ──
@@ -258,7 +267,7 @@ describe('replayFoldEntries', () => {
258
267
  // ── MF-2:守卫按变体校验必填字段——损坏 entry 只跳过该条,不清空全部任务 ──
259
268
  it('MF-2: op 合法但缺必填字段的损坏 entry 被跳过,其余任务保留', () => {
260
269
  const session = '/s.json'
261
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
270
+ loggerMock.warn.mockClear()
262
271
  const entries: SchedulerEntryLike[] = [
263
272
  { type: 'custom', customType: 'pi-scheduler:task', data: { op: 'upsert' } }, // 缺 taskId+task
264
273
  { type: 'custom', customType: 'pi-scheduler:task', data: { op: 'upsert', taskId: 'A' } }, // 缺 task
@@ -272,12 +281,11 @@ describe('replayFoldEntries', () => {
272
281
  const result = replayFoldEntries(entries, session)
273
282
  expect(result.size).toBe(1)
274
283
  expect(result.get('A')).toBeDefined()
275
- warnSpy.mockRestore()
276
284
  })
277
285
 
278
286
  it('MF-2: upsert task 嵌套数据损坏(history 非数组)→ 逐条跳过该 entry,其余任务保留', () => {
279
287
  const session = '/s.json'
280
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
288
+ loggerMock.warn.mockClear()
281
289
  const entries: SchedulerEntryLike[] = [
282
290
  {
283
291
  type: 'custom',
@@ -297,10 +305,9 @@ describe('replayFoldEntries', () => {
297
305
  expect(result.get('GOOD')).toBeDefined()
298
306
  expect(result.has('BAD')).toBe(false)
299
307
  // 逐条跳过 warn(非外层整体 catch 的 replayFoldEntries failed warn)
300
- expect(warnSpy).toHaveBeenCalled()
301
- const msg = warnSpy.mock.calls[0]![0] as string
308
+ expect(loggerMock.warn).toHaveBeenCalled()
309
+ const msg = loggerMock.warn.mock.calls[0]![0] as string
302
310
  expect(msg).toContain('skipping corrupted scheduler entry')
303
- warnSpy.mockRestore()
304
311
  })
305
312
 
306
313
  afterEach(() => {
@@ -1,4 +1,15 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import type { DeliveryMessage } from '@xyz-agent/session-delivery'
3
+
4
+ // Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
5
+ const { loggerMock } = vi.hoisted(() => ({
6
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
7
+ }))
8
+ vi.mock('@zhushanwen/pi-extension-logger', () => ({
9
+ getLogger: () => loggerMock,
10
+ createLogger: () => loggerMock,
11
+ setPiHandle: vi.fn(),
12
+ }))
2
13
 
3
14
  import { MockSchedulerBackend } from '../backend.js'
4
15
  import { SchedulerRuntime } from '../runtime.js'
@@ -7,6 +18,20 @@ import { SchedulerRuntime } from '../runtime.js'
7
18
 
8
19
  const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
9
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
+
10
35
  describe('SchedulerRuntime', () => {
11
36
  let backend: MockSchedulerBackend
12
37
  let runtime: SchedulerRuntime
@@ -114,13 +139,14 @@ describe('SchedulerRuntime', () => {
114
139
  expect(backend.sentMessages).toHaveLength(0)
115
140
  })
116
141
 
117
- it('skips when not idle and force is false', async () => {
142
+ it('non-force 任务在 busy 时走 delivery 入队(不直调 sendMessage)', async () => {
118
143
  const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
119
144
  const busyBackend = new MockSchedulerBackend()
120
145
  const busyRuntime = new SchedulerRuntime(busyBackend, busyCtx)
121
146
  const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
147
+ // 无 delivery handle 时走 dispatchDirect(直投),busy 不影响(直投不检查 idle)
122
148
  await busyRuntime.dispatchTask(task)
123
- expect(busyBackend.sentMessages).toHaveLength(0)
149
+ expect(busyBackend.sentMessages).toHaveLength(1)
124
150
  })
125
151
 
126
152
  it('dispatches when force is true even if busy', async () => {
@@ -134,13 +160,56 @@ describe('SchedulerRuntime', () => {
134
160
 
135
161
  // OR 组合补全:源码 `!isIdle() || hasPendingMessages()` 任一为真即跳过。
136
162
  // idle=true 但有 pending message → dispatch 应被跳过。
137
- it('skips when idle but has pending messages', async () => {
163
+ // U4 变更:gate 已交 delivery 内核,无 delivery handle 时走 dispatchDirect(直投不检查 idle)
164
+ it('无 delivery handle 时直投不受 idle/pending 影响', async () => {
138
165
  const pendingCtx = { isIdle: () => true, hasPendingMessages: () => true }
139
166
  const pendingBackend = new MockSchedulerBackend()
140
167
  const pendingRuntime = new SchedulerRuntime(pendingBackend, pendingCtx)
141
168
  const task = await pendingRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
142
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)
143
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)
144
213
  })
145
214
 
146
215
  it('sendMessage 失败 → 记 failed 状态但不 rethrow', async () => {
@@ -178,7 +247,7 @@ describe('SchedulerRuntime', () => {
178
247
  })
179
248
 
180
249
  it('sendMessage 挂起期间下一 tick 同任务被跳过:不双注入、warn in-flight、完成后 runCount=1', async () => {
181
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
250
+ loggerMock.warn.mockClear()
182
251
  let resolveSend: (() => void) | undefined
183
252
  const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
184
253
  backend.sendMessage = vi.fn(() => sendPromise)
@@ -195,7 +264,7 @@ describe('SchedulerRuntime', () => {
195
264
  // in-flight 守卫 → skip + warn(修复前:同一 prompt 双注入)
196
265
  await runtime.tickScheduler()
197
266
  expect(backend.sendMessage).toHaveBeenCalledTimes(1)
198
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
267
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
199
268
  expect(warnText).toContain('already in flight')
200
269
 
201
270
  // 放行挂起的 sendMessage:tick1 正常收尾(状态推进恰好一次)
@@ -204,7 +273,6 @@ describe('SchedulerRuntime', () => {
204
273
  expect(backend.sendMessage).toHaveBeenCalledTimes(1)
205
274
  expect(task.runCount).toBe(1)
206
275
  expect(task.pending).toBe(false)
207
- warnSpy.mockRestore()
208
276
  })
209
277
 
210
278
  it('挂起 dispatch 只挡同任务:其他任务在下一 tick 正常 dispatch 不受影响', async () => {
@@ -353,7 +421,7 @@ describe('SchedulerRuntime', () => {
353
421
 
354
422
  // ── TC-W-APPEND-FAIL:appendEntry 失败捕获(ER-APPEND-FAIL)──
355
423
  it('TC-W-APPEND-FAIL: appendEntry 失败不抛、保留内存态、不污染 lastError', async () => {
356
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
424
+ loggerMock.warn.mockClear()
357
425
  backend.appendError = new Error('pi internal')
358
426
 
359
427
  // addTask 内 appendEntry 抛错 → 被捕获(console.warn),不 rethrow;内存态已更新(task 仍在)
@@ -362,7 +430,7 @@ describe('SchedulerRuntime', () => {
362
430
  expect(task.enabled).toBe(true)
363
431
  // appendEntrySafe 不污染业务态(append 失败是 transient,不设 lastError)
364
432
  expect(task.lastError).toBeUndefined()
365
- expect(warnSpy).toHaveBeenCalled()
433
+ expect(loggerMock.warn).toHaveBeenCalled()
366
434
 
367
435
  // tickScheduler 同样不抛:dispatch 成功后 append advance 抛错被捕获,nextRunAt 已推进(内存态正确)
368
436
  task.nextRunAt = Date.now() - 1000
@@ -373,7 +441,6 @@ describe('SchedulerRuntime', () => {
373
441
  // nextRunAt 已推进到未来(内存态正确,append 失败只丢持久化)
374
442
  expect(updated.nextRunAt).toBe(Date.now() + 60000)
375
443
  expect(updated.lastError).toBeUndefined() // 不被 append 失败污染
376
- warnSpy.mockRestore()
377
444
  })
378
445
 
379
446
  // ── TC-W-ON-AFTER-TICK:onAfterTick 回调(W2)──
@@ -408,8 +475,8 @@ describe('SchedulerRuntime', () => {
408
475
  })
409
476
 
410
477
  // ── MF-1:toggle enable 重算 nextRunAt 到未来时清除残留 pending ──
411
- // 场景:busy tick 标记 pending=true(W4 跨 tick 重试)→ disable → enable 重算到未来。
412
- // 修复前 pending 残留 → 下个 tick step3 `pending && enabled` 在重算的未来时间点之前提前 dispatch。
478
+ // U4 变更:gate 已交 delivery 内核,非 force 任务在 busy 时通过 delivery 入队(无 handle 时直投)。
479
+ // pending 在 dispatchViaDelivery/dispatchDirect 成功后清除(不再依赖 gate 跳过保留 pending)。
413
480
  it('MF-1: enable 重算 nextRunAt 到未来时清除残留 pending,不提前 dispatch', async () => {
414
481
  // 可控 idle 状态:先 busy 模拟 dispatchTask 跳过保留 pending(W4),后切 idle 排除 busy 干扰
415
482
  let idle = false
@@ -420,29 +487,26 @@ describe('SchedulerRuntime', () => {
420
487
  vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
421
488
  const task = await rt.addTask('mf1', { mode: 'interval', intervalMs: 60000 })
422
489
 
423
- // T0+61s:任务到期 + busy → step2 标 pending=true,step3 dispatchTask busy 跳过(pending 保留)
490
+ // T0+61s:任务到期 + 无 delivery handle → dispatchDirect(直投,不检查 idle)
491
+ // 直投成功后 pending=false
424
492
  vi.setSystemTime(new Date('2026-01-01T00:01:01Z'))
425
493
  await rt.tickScheduler()
426
- expect(task.pending).toBe(true) // busy tick 残留 pending(W4 跨 tick 重试)
427
- expect(controllableBackend.sentMessages).toHaveLength(0)
494
+ expect(task.pending).toBe(false) // 直投成功后 pending 已清除
495
+ expect(controllableBackend.sentMessages).toHaveLength(1) // 直投成功
428
496
 
429
- // disable → enable:enable 重算 nextRunAt 到未来(T0+61s + 60s = T0+121s)
430
- await rt.toggleTask(task.id, false)
431
- await rt.toggleTask(task.id, true)
432
- expect(task.pending).toBe(false) // 修复后:重算到未来清除残留 pending
497
+ // 重算 nextRunAt 到未来(recurring 任务 dispatch 后 nextRunAt 已推进)
433
498
  const recalcedNext = task.nextRunAt
434
- expect(recalcedNext).toBeGreaterThan(Date.now()) // 确认重算到了未来
499
+ expect(recalcedNext).toBeGreaterThan(Date.now()) // 已推进到未来
435
500
 
436
- // T0+90s:在重算的未来 nextRunAt 之前 tick,切 idle 排除 busy 干扰 → 不应 dispatch
501
+ // T0+90s:在重算的未来 nextRunAt 之前 tick → 不应 dispatch
437
502
  vi.setSystemTime(new Date('2026-01-01T00:01:30Z'))
438
- idle = true
439
503
  await rt.tickScheduler()
440
- expect(controllableBackend.sentMessages).toHaveLength(0)
504
+ expect(controllableBackend.sentMessages).toHaveLength(1) // 仍是 1 次
441
505
 
442
506
  // 到达重算的未来 nextRunAt 后 tick:才 dispatch
443
507
  vi.setSystemTime(new Date(recalcedNext + 1000))
444
508
  await rt.tickScheduler()
445
- expect(controllableBackend.sentMessages).toHaveLength(1)
509
+ expect(controllableBackend.sentMessages).toHaveLength(2)
446
510
  })
447
511
 
448
512
  // ── P1:toggle enable 重算的 nextRunAt 跨 session 重放后保持未来值 ──
@@ -535,6 +599,146 @@ describe('SchedulerRuntime', () => {
535
599
  })
536
600
  })
537
601
 
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
+
538
742
  // ── F2:tick 错误分诊(crash-fix)──
539
743
  // startScheduler 的 interval 回调对 fire-and-forget 的 tickScheduler() 加 catch:
540
744
  // stale 类错误(session 替换后泄漏 timer 访问 stale ctx)→ warn "tick stopped" + stopScheduler
@@ -554,7 +758,7 @@ describe('SchedulerRuntime', () => {
554
758
  })
555
759
 
556
760
  it('U1: stale 错误 → warn "tick stopped" + timer 自停,后续 tick 不再发生', async () => {
557
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
761
+ loggerMock.warn.mockClear()
558
762
  const nowSpy = vi.spyOn(backend, 'now')
559
763
  runtime.onAfterTick(() => {
560
764
  throw new Error('This extension ctx is stale after session replacement or reload.')
@@ -563,7 +767,7 @@ describe('SchedulerRuntime', () => {
563
767
  runtime.startScheduler()
564
768
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:stale 抛 → catch 分诊 → 自停
565
769
 
566
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
770
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
567
771
  expect(warnText).toContain('tick stopped')
568
772
  expect(warnText).not.toContain('tick error')
569
773
 
@@ -572,12 +776,11 @@ describe('SchedulerRuntime', () => {
572
776
 
573
777
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 60s:timer 已停,无新 tick
574
778
  expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop) // now 计数不再增长
575
- warnSpy.mockRestore()
576
779
  nowSpy.mockRestore()
577
780
  })
578
781
 
579
782
  it('U2: 非 stale 错误 → warn "tick error" 且调度继续(advance 两次 now 计数 +2)', async () => {
580
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
783
+ loggerMock.warn.mockClear()
581
784
  const nowSpy = vi.spyOn(backend, 'now')
582
785
  runtime.onAfterTick(() => {
583
786
  throw new Error('boom')
@@ -586,7 +789,7 @@ describe('SchedulerRuntime', () => {
586
789
  runtime.startScheduler()
587
790
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:warn 但不停
588
791
 
589
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
792
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
590
793
  expect(warnText).toContain('tick error')
591
794
  expect(warnText).not.toContain('tick stopped')
592
795
 
@@ -595,7 +798,6 @@ describe('SchedulerRuntime', () => {
595
798
 
596
799
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 2 个后续 tick 照常
597
800
  expect(nowSpy.mock.calls.length).toBe(countAfterFirstTick + 2)
598
- warnSpy.mockRestore()
599
801
  nowSpy.mockRestore()
600
802
  })
601
803
 
@@ -634,7 +836,7 @@ describe('SchedulerRuntime', () => {
634
836
  })
635
837
 
636
838
  it('G1-a: in-flight tick 期间代际翻转 + 非文案错误 → warn "tick stopped" + 自停(不依赖 pi 错误文案)', async () => {
637
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
839
+ loggerMock.warn.mockClear()
638
840
  const nowSpy = vi.spyOn(backend, 'now')
639
841
  // tick 内先翻世代(模拟 session 替换交错发生在 dispatch await 窗口),再抛与
640
842
  // pi 文案完全无关的错误——旧实现按文案分诊会误判为普通错误继续调度(若 pi 改文案)
@@ -646,7 +848,7 @@ describe('SchedulerRuntime', () => {
646
848
  genRuntime.startScheduler()
647
849
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:catch 分诊走 G1 代际 → 自停
648
850
 
649
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
851
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
650
852
  expect(warnText).toContain('tick stopped')
651
853
  expect(warnText).not.toContain('tick error')
652
854
 
@@ -655,12 +857,11 @@ describe('SchedulerRuntime', () => {
655
857
 
656
858
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 tick
657
859
  expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop)
658
- warnSpy.mockRestore()
659
860
  nowSpy.mockRestore()
660
861
  })
661
862
 
662
863
  it('G1-b: 代际翻转后泄漏 timer 在下个 tick 前置检查自停——不进入 tick(backend.now 零调用)', async () => {
663
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
864
+ loggerMock.warn.mockClear()
664
865
  const nowSpy = vi.spyOn(backend, 'now')
665
866
  genRuntime.startScheduler()
666
867
  // session 替换:代际翻转(F1 未能触达的泄漏 timer 场景;无任何错误发生)
@@ -668,7 +869,7 @@ describe('SchedulerRuntime', () => {
668
869
 
669
870
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:前置检查命中 → 自停
670
871
 
671
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
872
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
672
873
  expect(warnText).toContain('tick stopped')
673
874
  expect(warnText).not.toContain('tick error')
674
875
  // 前置检查在 tickScheduler 之前拦截:tick 本体未执行(now 零调用,无 dispatch/append)
@@ -676,12 +877,11 @@ describe('SchedulerRuntime', () => {
676
877
 
677
878
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,仍零调用
678
879
  expect(nowSpy).not.toHaveBeenCalled()
679
- warnSpy.mockRestore()
680
880
  nowSpy.mockRestore()
681
881
  })
682
882
 
683
883
  it('G1-c: isCtxStale 注入但返回 false + 非 stale 错误 → warn "tick error" 且调度继续', async () => {
684
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
884
+ loggerMock.warn.mockClear()
685
885
  const nowSpy = vi.spyOn(backend, 'now')
686
886
  // staleFlag 恒 false(beforeEach 初始化):代际未翻转,注入存在不改变分诊结果
687
887
  genRuntime.onAfterTick(() => {
@@ -691,7 +891,7 @@ describe('SchedulerRuntime', () => {
691
891
  genRuntime.startScheduler()
692
892
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:非 stale → warn 继续调度
693
893
 
694
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
894
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
695
895
  expect(warnText).toContain('tick error')
696
896
  expect(warnText).not.toContain('tick stopped')
697
897
 
@@ -700,12 +900,11 @@ describe('SchedulerRuntime', () => {
700
900
 
701
901
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 2 个后续 tick 照常
702
902
  expect(nowSpy.mock.calls.length).toBe(countAfterFirstTick + 2)
703
- warnSpy.mockRestore()
704
903
  nowSpy.mockRestore()
705
904
  })
706
905
 
707
906
  it('G1-d: isCtxStale 返回 false 但错误文案含 stale 片段 → 文案兜底仍自停(覆盖 reload 盲区)', async () => {
708
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
907
+ loggerMock.warn.mockClear()
709
908
  const nowSpy = vi.spyOn(backend, 'now')
710
909
  // reload 场景模拟:factory 重跑后旧闭包代际计数不再递增(staleFlag 恒 false),
711
910
  // 只有错误文案能识别 stale——兜底支必须独立于代际检测生效
@@ -716,7 +915,7 @@ describe('SchedulerRuntime', () => {
716
915
  genRuntime.startScheduler()
717
916
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:文案兜底 → 自停
718
917
 
719
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
918
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
720
919
  expect(warnText).toContain('tick stopped')
721
920
  expect(warnText).not.toContain('tick error')
722
921
 
@@ -725,7 +924,6 @@ describe('SchedulerRuntime', () => {
725
924
 
726
925
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2)
727
926
  expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop)
728
- warnSpy.mockRestore()
729
927
  nowSpy.mockRestore()
730
928
  })
731
929
  })
@@ -195,10 +195,9 @@ describe('SchedulerService', () => {
195
195
  })
196
196
  })
197
197
 
198
- // TC8:busy 场景(任务 enabled 但 ctx.isIdle() 返回 false)→ dispatch no-op →
199
- // DISPATCH_SKIPPED。m2 只覆盖了 disabled 场景,busy 是 dispatchTask 的
200
- // `!task.force && (!ctx.isIdle() || ctx.hasPendingMessages())` 独立分支。
201
- it('returns DISPATCH_SKIPPED when ctx is busy (isIdle=false)', async () => {
198
+ // U4 变更:gate 已交 delivery 内核,无 delivery handle 时走 dispatchDirect(直投不检查 idle)。
199
+ // busy 不再导致 DISPATCH_SKIPPED(直投成功)。
200
+ it('无 delivery handle 时 busy 不影响 dispatch(直投)', async () => {
202
201
  const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
203
202
  const busyBackend = new MockSchedulerBackend()
204
203
  const busyService = new SchedulerService(new SchedulerRuntime(busyBackend, busyCtx), () => busyBackend.now())
@@ -207,11 +206,9 @@ describe('SchedulerService', () => {
207
206
  const id = created.data!.task.id
208
207
  const result = await busyService.run(id)
209
208
 
210
- expect(result.success).toBe(false)
211
- expect(result.errorCode).toBe('DISPATCH_SKIPPED')
212
- expect(result.message).toContain('busy')
213
- // 未发送任何 message(dispatch no-op)
214
- expect(busyBackend.sentMessages).toHaveLength(0)
209
+ // 直投成功(无 delivery handle → dispatchDirect,不检查 idle)
210
+ expect(result.success).toBe(true)
211
+ expect(busyBackend.sentMessages).toHaveLength(1)
215
212
  })
216
213
  })
217
214
  })