@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.
- package/package.json +5 -1
- package/src/__tests__/U4-AFTER_RUN_INTENT.test.ts +99 -0
- package/src/__tests__/U4-DISPATCH_INFLIGHT.test.ts +109 -0
- package/src/__tests__/U4-ONSETTLED.test.ts +124 -0
- package/src/__tests__/U4-PARK_GATE.test.ts +206 -0
- package/src/__tests__/importer.test.ts +23 -9
- package/src/__tests__/index-generation.test.ts +14 -5
- package/src/__tests__/replay.test.ts +19 -12
- package/src/__tests__/runtime.test.ts +239 -41
- package/src/__tests__/service.test.ts +6 -9
- package/src/backend.ts +22 -1
- package/src/importer.ts +12 -10
- package/src/index.ts +48 -0
- package/src/replay.ts +7 -7
- package/src/runtime.ts +124 -29
|
@@ -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: 迭代器抛错时
|
|
145
|
-
|
|
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(
|
|
162
|
-
const msg =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
301
|
-
const msg =
|
|
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('
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
412
|
-
//
|
|
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:任务到期 +
|
|
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(
|
|
427
|
-
expect(controllableBackend.sentMessages).toHaveLength(
|
|
494
|
+
expect(task.pending).toBe(false) // 直投成功后 pending 已清除
|
|
495
|
+
expect(controllableBackend.sentMessages).toHaveLength(1) // 直投成功
|
|
428
496
|
|
|
429
|
-
//
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
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
|
-
|
|
211
|
-
expect(result.
|
|
212
|
-
expect(
|
|
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
|
})
|