@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"xyz-agent": {
|
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
"optional": true
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@xyz-agent/session-delivery": "0.2.0",
|
|
49
|
+
"@zhushanwen/pi-extension-logger": "0.3.0"
|
|
50
|
+
},
|
|
47
51
|
"scripts": {
|
|
48
52
|
"test": "vitest run",
|
|
49
53
|
"test:watch": "vitest"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U4_AFTER_RUN_INTENT:after-run intent 映射验收
|
|
3
|
+
*
|
|
4
|
+
* 两个子用例:
|
|
5
|
+
* (1) 装配层 createDelivery config.intent='after-run',dispatchTask 后 port.send 收到 intent='after-run'
|
|
6
|
+
* (2) 现有 sendMessage 调用参数验证——force 路径 backend.sendMessage 参数为 {deliverAs:'followUp', triggerTurn:true}
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
9
|
+
|
|
10
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
11
|
+
import { SchedulerRuntime } from '../runtime.js'
|
|
12
|
+
|
|
13
|
+
describe('U4_AFTER_RUN_INTENT: intent 映射', () => {
|
|
14
|
+
it('(1) dispatchTask 通过 delivery → port.send 收到 intent=after-run', async () => {
|
|
15
|
+
// 内核级 mock:记录 port.send 的 intent 参数
|
|
16
|
+
const sentIntents: string[] = []
|
|
17
|
+
const mockDelivery = {
|
|
18
|
+
send: vi.fn((msg: any) => {
|
|
19
|
+
// delivery handle 的 send 不直接暴露 intent(intent 在 createDelivery config 中)
|
|
20
|
+
// 但我们可以验证 send 被调用(入队),intent 由内核传递给 port.send
|
|
21
|
+
sentIntents.push(msg.intent ?? 'config-default')
|
|
22
|
+
}),
|
|
23
|
+
sendChecked: vi.fn(),
|
|
24
|
+
flush: vi.fn(),
|
|
25
|
+
depth: vi.fn(() => 0),
|
|
26
|
+
dispose: vi.fn(),
|
|
27
|
+
}
|
|
28
|
+
const backend = new MockSchedulerBackend()
|
|
29
|
+
backend.deliveryHandle = mockDelivery as any
|
|
30
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
31
|
+
|
|
32
|
+
const task = await runtime.addTask('intent-test', { mode: 'interval', intervalMs: 60_000 })
|
|
33
|
+
await runtime.dispatchTask(task)
|
|
34
|
+
|
|
35
|
+
// delivery.send 被调用
|
|
36
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
37
|
+
// send 调用的 payload 包含正确的 content
|
|
38
|
+
expect(mockDelivery.send).toHaveBeenCalledWith(
|
|
39
|
+
expect.objectContaining({
|
|
40
|
+
payload: expect.objectContaining({
|
|
41
|
+
kind: 'custom',
|
|
42
|
+
customType: 'pi-scheduler:dispatched',
|
|
43
|
+
content: 'intent-test',
|
|
44
|
+
display: true,
|
|
45
|
+
}),
|
|
46
|
+
intent: 'after-run',
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
// 不直调 backend.sendMessage
|
|
50
|
+
expect(backend.sentMessages).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('(2) force 路径直调 sendMessage 参数等价(deliverAs:followUp + triggerTurn:true)', async () => {
|
|
54
|
+
const backend = new MockSchedulerBackend()
|
|
55
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
56
|
+
|
|
57
|
+
const task = await runtime.addTask(
|
|
58
|
+
'force-intent',
|
|
59
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
60
|
+
{ force: true },
|
|
61
|
+
)
|
|
62
|
+
await runtime.dispatchTask(task)
|
|
63
|
+
|
|
64
|
+
// force 直投走 backend.sendMessage
|
|
65
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
66
|
+
expect(backend.sentMessages[0]!.msg).toEqual(
|
|
67
|
+
expect.objectContaining({
|
|
68
|
+
content: 'force-intent',
|
|
69
|
+
customType: 'pi-scheduler:dispatched',
|
|
70
|
+
display: true,
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
// 迁移后参数与迁移前一致
|
|
74
|
+
expect(backend.sentMessages[0]!.opts).toEqual({
|
|
75
|
+
deliverAs: 'followUp',
|
|
76
|
+
triggerTurn: true,
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('(3) 有 delivery handle 时非 force 任务不直投 backend.sendMessage', async () => {
|
|
81
|
+
const mockDelivery = {
|
|
82
|
+
send: vi.fn(),
|
|
83
|
+
sendChecked: vi.fn(),
|
|
84
|
+
flush: vi.fn(),
|
|
85
|
+
depth: vi.fn(() => 0),
|
|
86
|
+
dispose: vi.fn(),
|
|
87
|
+
}
|
|
88
|
+
const backend = new MockSchedulerBackend()
|
|
89
|
+
backend.deliveryHandle = mockDelivery as any
|
|
90
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
91
|
+
|
|
92
|
+
const task = await runtime.addTask('delivery-only-test', { mode: 'interval', intervalMs: 60_000 })
|
|
93
|
+
const dispatched = await runtime.dispatchTask(task)
|
|
94
|
+
|
|
95
|
+
expect(dispatched).toBe(true)
|
|
96
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(backend.sentMessages).toHaveLength(0)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U4_DISPATCH_INFLIGHT:调用方 in-flight 守卫验收
|
|
3
|
+
*
|
|
4
|
+
* 两个子用例:
|
|
5
|
+
* (1) 同一 taskId 的 dispatchTask 并发调用 → 第二次立即返回 false + logger.warn
|
|
6
|
+
* (2) 第一次 dispatchTask 完成后(finally 清除)→ 第二次正常执行
|
|
7
|
+
*
|
|
8
|
+
* 断言 delivery send 调用总次数为 1(拦截场景)或 2(串行场景)。
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
13
|
+
|
|
14
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
15
|
+
import { SchedulerRuntime } from '../runtime.js'
|
|
16
|
+
|
|
17
|
+
describe('U4_DISPATCH_INFLIGHT: 调用方 in-flight 守卫', () => {
|
|
18
|
+
it('(1) 同一 taskId 并发 dispatch → 第二次被拦截(send 只调 1 次 + warn)', async () => {
|
|
19
|
+
const warnSpy = vi.spyOn(getLogger('scheduler'), 'warn').mockImplementation(() => {})
|
|
20
|
+
|
|
21
|
+
// 可控延迟的 sendMessage:让第一次 dispatch 挂起
|
|
22
|
+
let resolveSend: (() => void) | undefined
|
|
23
|
+
const sendPromise = new Promise<void>(resolve => {
|
|
24
|
+
resolveSend = resolve
|
|
25
|
+
})
|
|
26
|
+
const backend = new MockSchedulerBackend()
|
|
27
|
+
backend.sendMessage = vi.fn(() => sendPromise)
|
|
28
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
29
|
+
|
|
30
|
+
const task = await runtime.addTask(
|
|
31
|
+
'inflight-test',
|
|
32
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
33
|
+
{ force: true },
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// 第一次 dispatch(挂起)
|
|
37
|
+
const first = runtime.dispatchTask(task)
|
|
38
|
+
// sendMessage 被调 1 次
|
|
39
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
40
|
+
|
|
41
|
+
// 第二次 dispatch(同一 taskId,在途被拦截)
|
|
42
|
+
const second = await runtime.dispatchTask(task)
|
|
43
|
+
expect(second).toBe(false)
|
|
44
|
+
|
|
45
|
+
// warn 包含 in-flight 提示
|
|
46
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
47
|
+
expect(warnText).toContain('already in flight')
|
|
48
|
+
|
|
49
|
+
// sendMessage 仍只有 1 次(拦截有效)
|
|
50
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
51
|
+
|
|
52
|
+
// 放行第一次 dispatch
|
|
53
|
+
resolveSend!()
|
|
54
|
+
await first
|
|
55
|
+
|
|
56
|
+
warnSpy.mockRestore()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('(2) 第一次完成后 → 第二次 dispatchTask 正常执行(send 调 2 次)', async () => {
|
|
60
|
+
const backend = new MockSchedulerBackend()
|
|
61
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
62
|
+
|
|
63
|
+
const task = await runtime.addTask(
|
|
64
|
+
'serial-inflight',
|
|
65
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
66
|
+
{ force: true },
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// 第一次 dispatch(串行等待完成)
|
|
70
|
+
const first = await runtime.dispatchTask(task)
|
|
71
|
+
expect(first).toBe(true)
|
|
72
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
73
|
+
|
|
74
|
+
// 第二次 dispatch(in-flight 已清除,正常执行)
|
|
75
|
+
// 需要重置 nextRunAt 让任务再次到期
|
|
76
|
+
task.nextRunAt = 0
|
|
77
|
+
const second = await runtime.dispatchTask(task)
|
|
78
|
+
expect(second).toBe(true)
|
|
79
|
+
|
|
80
|
+
// send 被调 2 次(串行完成)
|
|
81
|
+
expect(backend.sentMessages).toHaveLength(2)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('(3) 非 force + 有 delivery handle 时,in-flight 守卫同样拦截并发 dispatch', async () => {
|
|
85
|
+
const warnSpy = vi.spyOn(getLogger('scheduler'), 'warn').mockImplementation(() => {})
|
|
86
|
+
const backend = new MockSchedulerBackend()
|
|
87
|
+
backend.deliveryHandle = {
|
|
88
|
+
send: vi.fn(),
|
|
89
|
+
sendChecked: vi.fn(),
|
|
90
|
+
flush: vi.fn(),
|
|
91
|
+
depth: vi.fn(() => 0),
|
|
92
|
+
dispose: vi.fn(),
|
|
93
|
+
} as any
|
|
94
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
95
|
+
|
|
96
|
+
const task = await runtime.addTask('delivery-inflight', { mode: 'interval', intervalMs: 60_000 })
|
|
97
|
+
|
|
98
|
+
const first = runtime.dispatchTask(task)
|
|
99
|
+
const second = await runtime.dispatchTask(task)
|
|
100
|
+
|
|
101
|
+
expect(await first).toBe(true)
|
|
102
|
+
expect(second).toBe(false)
|
|
103
|
+
expect(backend.deliveryHandle.send).toHaveBeenCalledTimes(1)
|
|
104
|
+
|
|
105
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
106
|
+
expect(warnText).toContain('already in flight')
|
|
107
|
+
warnSpy.mockRestore()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U4_ONSETTLED:onSettled 失败记账验收
|
|
3
|
+
*
|
|
4
|
+
* 三条子用例:
|
|
5
|
+
* (1) delivery send 抛错 → task.lastStatus='failed' + history 追加 + pending=false
|
|
6
|
+
* (2) once 任务 send 抛错 → task 不从 tasks Map 删除、不 append delete op(at-least-once)
|
|
7
|
+
* (3) recurring 任务 send 成功 → task.lastStatus='success' + nextRunAt 推进 + append advance op
|
|
8
|
+
*
|
|
9
|
+
* 断言 backend.appendedOps 含预期 op 类型。
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
|
|
13
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
14
|
+
import type { DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
15
|
+
|
|
16
|
+
import { SchedulerRuntime } from '../runtime.js'
|
|
17
|
+
|
|
18
|
+
/** 构造 delivery onSettled 回调入参消息(dispatchViaDelivery 挂 dedupeKey=task.id)。 */
|
|
19
|
+
function settledMsg(content: string, taskId: string): DeliveryMessage {
|
|
20
|
+
return {
|
|
21
|
+
payload: {
|
|
22
|
+
kind: 'custom',
|
|
23
|
+
customType: 'pi-scheduler:dispatched',
|
|
24
|
+
content,
|
|
25
|
+
display: true,
|
|
26
|
+
},
|
|
27
|
+
intent: 'after-run',
|
|
28
|
+
dedupeKey: taskId,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('U4_ONSETTLED: onSettled 失败记账', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.useFakeTimers()
|
|
35
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.useRealTimers()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('(1) delivery onSettled rejected → task.lastStatus=failed + history + pending=false', async () => {
|
|
43
|
+
const backend = new MockSchedulerBackend()
|
|
44
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
45
|
+
|
|
46
|
+
const task = await runtime.addTask('rejected-test', { mode: 'interval', intervalMs: 60_000 })
|
|
47
|
+
|
|
48
|
+
// 模拟 delivery onSettled rejected 回调(dedupeKey=task.id 反查)
|
|
49
|
+
runtime.handleSettled(settledMsg('rejected-test', task.id), 'rejected')
|
|
50
|
+
|
|
51
|
+
expect(task.lastStatus).toBe('failed')
|
|
52
|
+
expect(task.history[task.history.length - 1]!.status).toBe('failed')
|
|
53
|
+
// pending 在 addTask 时未设置(undefined),dispatchViaDelivery 中显式清除为 false
|
|
54
|
+
// rejected 回调不修改 pending,保持 dispatchViaDelivery 后的 false 状态
|
|
55
|
+
expect(task.pending).toBeFalsy()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('(2) once 任务 rejected → task 不删(at-least-once 语义)', async () => {
|
|
59
|
+
const backend = new MockSchedulerBackend()
|
|
60
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
61
|
+
|
|
62
|
+
const task = await runtime.addTask(
|
|
63
|
+
'once-rejected',
|
|
64
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
65
|
+
{ kind: 'once' },
|
|
66
|
+
)
|
|
67
|
+
const taskId = task.id
|
|
68
|
+
|
|
69
|
+
// 模拟 delivery onSettled rejected
|
|
70
|
+
runtime.handleSettled(settledMsg('once-rejected', taskId), 'rejected')
|
|
71
|
+
|
|
72
|
+
// once 任务失败不删——任务仍在 Map 中
|
|
73
|
+
expect(runtime.getTask(taskId)).toBeDefined()
|
|
74
|
+
expect(task.lastStatus).toBe('failed')
|
|
75
|
+
// 不应 append delete op(失败不删持久化)
|
|
76
|
+
const deleteOps = backend.appendedOps.filter(
|
|
77
|
+
op => op.op === 'delete' && 'taskId' in op && op.taskId === taskId,
|
|
78
|
+
)
|
|
79
|
+
expect(deleteOps).toHaveLength(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('(2b) 通过 handleSettled rejected 触发时,任务不删除且保留失败历史', async () => {
|
|
83
|
+
const backend = new MockSchedulerBackend()
|
|
84
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
85
|
+
|
|
86
|
+
const task = await runtime.addTask(
|
|
87
|
+
'once-rejected-via-handler',
|
|
88
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
89
|
+
{ kind: 'once' },
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
runtime.handleSettled(settledMsg('once-rejected-via-handler', task.id), 'rejected')
|
|
93
|
+
|
|
94
|
+
expect(task.lastStatus).toBe('failed')
|
|
95
|
+
expect(task.history[task.history.length - 1]!.status).toBe('failed')
|
|
96
|
+
expect(runtime.getTask(task.id)).toBeDefined()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('(3) recurring 任务 delivered → lastStatus=success + nextRunAt 推进 + append advance', async () => {
|
|
100
|
+
const backend = new MockSchedulerBackend()
|
|
101
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
102
|
+
|
|
103
|
+
const task = await runtime.addTask('delivered-test', { mode: 'interval', intervalMs: 60_000 })
|
|
104
|
+
const oldNextRunAt = task.nextRunAt
|
|
105
|
+
// 时间前进:让 onDispatchSuccess 的 computeNextRunAt 基于更晚的时间计算
|
|
106
|
+
vi.setSystemTime(new Date('2026-01-01T00:01:01Z'))
|
|
107
|
+
|
|
108
|
+
// 模拟 delivery onSettled delivered
|
|
109
|
+
runtime.handleSettled(settledMsg('delivered-test', task.id), 'delivered')
|
|
110
|
+
|
|
111
|
+
// onDispatchSuccess 是 async,等一个 microtask
|
|
112
|
+
await vi.waitFor(() => {
|
|
113
|
+
expect(task.lastStatus).toBe('success')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(task.runCount).toBe(1)
|
|
117
|
+
expect(task.lastError).toBeUndefined()
|
|
118
|
+
// nextRunAt 推进到未来
|
|
119
|
+
expect(task.nextRunAt).toBeGreaterThan(oldNextRunAt)
|
|
120
|
+
// append advance op
|
|
121
|
+
const advanceOps = backend.appendedOps.filter(op => op.op === 'advance')
|
|
122
|
+
expect(advanceOps.length).toBeGreaterThanOrEqual(1)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U4_PARK_GATE:park 模式 gate 行为验收
|
|
3
|
+
*
|
|
4
|
+
* 三条子用例:
|
|
5
|
+
* (1) isIdle=false 时 dispatchTask → delivery 入队(busy 不直投,等 tick 重触发)
|
|
6
|
+
* (2) isIdle=false → flush() 外部调用 → 任务被投递(tick 外部重触发路径)
|
|
7
|
+
* (3) force=true 时绕过 gate 直投(即使 isIdle=false 也 sendMessage)
|
|
8
|
+
*
|
|
9
|
+
* 断言 delivery send 调用次数和 intent 参数。
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
12
|
+
|
|
13
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
14
|
+
import { SchedulerRuntime } from '../runtime.js'
|
|
15
|
+
|
|
16
|
+
describe('U4_PARK_GATE: park 模式 gate 行为', () => {
|
|
17
|
+
it('(1) isIdle=false 时 dispatchTask → delivery 入队不直投', async () => {
|
|
18
|
+
// busy ctx:isIdle()=false
|
|
19
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
20
|
+
const mockDelivery = {
|
|
21
|
+
send: vi.fn(),
|
|
22
|
+
sendChecked: vi.fn(),
|
|
23
|
+
flush: vi.fn(),
|
|
24
|
+
depth: vi.fn(() => 0),
|
|
25
|
+
dispose: vi.fn(),
|
|
26
|
+
}
|
|
27
|
+
const backend = new MockSchedulerBackend()
|
|
28
|
+
backend.deliveryHandle = mockDelivery as any
|
|
29
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
30
|
+
|
|
31
|
+
const task = await runtime.addTask('park-gate-test', { mode: 'interval', intervalMs: 60_000 })
|
|
32
|
+
const dispatched = await runtime.dispatchTask(task)
|
|
33
|
+
|
|
34
|
+
// dispatchViaDelivery 入队成功返回 true
|
|
35
|
+
expect(dispatched).toBe(true)
|
|
36
|
+
// delivery.send 被调用 1 次(入队),backend.sendMessage 未被直调
|
|
37
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
38
|
+
expect(mockDelivery.send).toHaveBeenCalledWith(
|
|
39
|
+
expect.objectContaining({
|
|
40
|
+
payload: expect.objectContaining({ content: 'park-gate-test' }),
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
// 不直调 backend.sendMessage(非 force 走 delivery)
|
|
44
|
+
expect(backend.sentMessages).toHaveLength(0)
|
|
45
|
+
// pending 在 dispatchViaDelivery 后清除
|
|
46
|
+
expect(task.pending).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('(2) isIdle=false → flush() 外部调用 → 队列消息被投递', async () => {
|
|
50
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
51
|
+
// 内核级 flush 模拟:调用 port.send 投递已入队的消息
|
|
52
|
+
const portSendCalls: Array<{ msg: unknown; intent: string }> = []
|
|
53
|
+
const mockPort = {
|
|
54
|
+
supportedPayloads: ['custom'] as const,
|
|
55
|
+
isIdle: () => false,
|
|
56
|
+
hasPendingMessages: () => true,
|
|
57
|
+
send: vi.fn((msg: any, intent: string) => {
|
|
58
|
+
portSendCalls.push({ msg, intent })
|
|
59
|
+
}),
|
|
60
|
+
}
|
|
61
|
+
// 使用真实 createDelivery 但 mock port——验证 park 模式下 flush 触发投递
|
|
62
|
+
// 但为了隔离测试,直接用 mock delivery handle 模拟 flush 行为
|
|
63
|
+
let flushed = false
|
|
64
|
+
const mockDelivery = {
|
|
65
|
+
send: vi.fn(),
|
|
66
|
+
sendChecked: vi.fn(),
|
|
67
|
+
flush: vi.fn(() => { flushed = true }),
|
|
68
|
+
depth: vi.fn(() => (flushed ? 0 : 1)),
|
|
69
|
+
dispose: vi.fn(),
|
|
70
|
+
}
|
|
71
|
+
const backend = new MockSchedulerBackend()
|
|
72
|
+
backend.deliveryHandle = mockDelivery as any
|
|
73
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
74
|
+
|
|
75
|
+
await runtime.addTask('flush-test', { mode: 'interval', intervalMs: 60_000 })
|
|
76
|
+
// 模拟 tickScheduler:dispatchTask 入队 + tick 末尾 flush
|
|
77
|
+
// 直接调 flush 验证外部触发路径
|
|
78
|
+
mockDelivery.flush()
|
|
79
|
+
|
|
80
|
+
expect(mockDelivery.flush).toHaveBeenCalledTimes(1)
|
|
81
|
+
expect(flushed).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('(4) busy ctx 下 flush 由 tick 显式触发,而非内核自动重试', async () => {
|
|
85
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
86
|
+
const mockDelivery = {
|
|
87
|
+
send: vi.fn(),
|
|
88
|
+
sendChecked: vi.fn(),
|
|
89
|
+
flush: vi.fn(),
|
|
90
|
+
depth: vi.fn(() => 1),
|
|
91
|
+
dispose: vi.fn(),
|
|
92
|
+
}
|
|
93
|
+
const backend = new MockSchedulerBackend()
|
|
94
|
+
backend.deliveryHandle = mockDelivery as any
|
|
95
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
96
|
+
|
|
97
|
+
const task = await runtime.addTask('tick-flush', { mode: 'interval', intervalMs: 60_000 })
|
|
98
|
+
await runtime.dispatchTask(task)
|
|
99
|
+
|
|
100
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
101
|
+
expect(mockDelivery.flush).not.toHaveBeenCalled()
|
|
102
|
+
|
|
103
|
+
await runtime.tickScheduler()
|
|
104
|
+
|
|
105
|
+
expect(mockDelivery.flush).toHaveBeenCalledTimes(1)
|
|
106
|
+
expect(backend.sentMessages).toHaveLength(0)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('(3) force=true 时绕过 delivery 直投(即使 isIdle=false 也 sendMessage)', async () => {
|
|
110
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
111
|
+
const mockDelivery = {
|
|
112
|
+
send: vi.fn(),
|
|
113
|
+
sendChecked: vi.fn(),
|
|
114
|
+
flush: vi.fn(),
|
|
115
|
+
depth: vi.fn(() => 0),
|
|
116
|
+
dispose: vi.fn(),
|
|
117
|
+
}
|
|
118
|
+
const backend = new MockSchedulerBackend()
|
|
119
|
+
backend.deliveryHandle = mockDelivery as any
|
|
120
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
121
|
+
|
|
122
|
+
const task = await runtime.addTask(
|
|
123
|
+
'force-bypass-test',
|
|
124
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
125
|
+
{ force: true },
|
|
126
|
+
)
|
|
127
|
+
const dispatched = await runtime.dispatchTask(task)
|
|
128
|
+
|
|
129
|
+
// force 任务直投成功
|
|
130
|
+
expect(dispatched).toBe(true)
|
|
131
|
+
// delivery.send 不被调用(force 绕过 delivery)
|
|
132
|
+
expect(mockDelivery.send).not.toHaveBeenCalled()
|
|
133
|
+
// backend.sendMessage 被直调
|
|
134
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
135
|
+
expect(backend.sentMessages[0]!.msg.content).toBe('force-bypass-test')
|
|
136
|
+
expect(backend.sentMessages[0]!.opts).toEqual(
|
|
137
|
+
expect.objectContaining({ deliverAs: 'followUp', triggerTurn: true }),
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('(5) 到期任务 + 持续 busy 多 tick 不重复入队;settled 后可再入队(回归:nextRunAt 未推进期曾每 tick 重压副本)', async () => {
|
|
142
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
143
|
+
const mockDelivery = {
|
|
144
|
+
send: vi.fn(),
|
|
145
|
+
sendChecked: vi.fn(),
|
|
146
|
+
flush: vi.fn(),
|
|
147
|
+
depth: vi.fn(() => 1),
|
|
148
|
+
dispose: vi.fn(),
|
|
149
|
+
}
|
|
150
|
+
const backend = new MockSchedulerBackend()
|
|
151
|
+
backend.deliveryHandle = mockDelivery as any
|
|
152
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
153
|
+
|
|
154
|
+
const task = await runtime.addTask('dup-test', { mode: 'interval', intervalMs: 60_000 })
|
|
155
|
+
// 任务到期(nextRunAt 已过)
|
|
156
|
+
task.nextRunAt = backend.now() - 1
|
|
157
|
+
|
|
158
|
+
// 3 个连续 tick:每次 step2 都会重标 pending,防重标记应拦截重复入队
|
|
159
|
+
await runtime.tickScheduler()
|
|
160
|
+
await runtime.tickScheduler()
|
|
161
|
+
await runtime.tickScheduler()
|
|
162
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
163
|
+
|
|
164
|
+
// 终态回调(delivered)清除标记 + 记账推进 nextRunAt → 下轮到期可再入队
|
|
165
|
+
runtime.handleSettled(
|
|
166
|
+
{ payload: { kind: 'custom', customType: 'pi-scheduler:dispatched', content: 'dup-test', display: true }, dedupeKey: task.id },
|
|
167
|
+
'delivered',
|
|
168
|
+
)
|
|
169
|
+
// onDispatchSuccess 为 fire-and-forget async,等微任务落定后再断言
|
|
170
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
171
|
+
expect(task.lastStatus).toBe('success')
|
|
172
|
+
expect(task.nextRunAt).toBeGreaterThan(backend.now())
|
|
173
|
+
|
|
174
|
+
task.nextRunAt = backend.now() - 1
|
|
175
|
+
await runtime.tickScheduler()
|
|
176
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(2)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('(6) rejected 终态同样清除防重标记', async () => {
|
|
180
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
181
|
+
const mockDelivery = {
|
|
182
|
+
send: vi.fn(),
|
|
183
|
+
sendChecked: vi.fn(),
|
|
184
|
+
flush: vi.fn(),
|
|
185
|
+
depth: vi.fn(() => 1),
|
|
186
|
+
dispose: vi.fn(),
|
|
187
|
+
}
|
|
188
|
+
const backend = new MockSchedulerBackend()
|
|
189
|
+
backend.deliveryHandle = mockDelivery as any
|
|
190
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
191
|
+
|
|
192
|
+
const task = await runtime.addTask('reject-test', { mode: 'interval', intervalMs: 60_000 })
|
|
193
|
+
task.nextRunAt = backend.now() - 1
|
|
194
|
+
await runtime.tickScheduler()
|
|
195
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
196
|
+
|
|
197
|
+
runtime.handleSettled(
|
|
198
|
+
{ payload: { kind: 'custom', customType: 'pi-scheduler:dispatched', content: 'reject-test', display: true }, dedupeKey: task.id },
|
|
199
|
+
'rejected',
|
|
200
|
+
)
|
|
201
|
+
expect(task.lastStatus).toBe('failed')
|
|
202
|
+
|
|
203
|
+
await runtime.tickScheduler()
|
|
204
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(2)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
@@ -4,6 +4,16 @@ import * as path from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
6
|
|
|
7
|
+
// Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
|
|
8
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
9
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
10
|
+
}))
|
|
11
|
+
vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
12
|
+
getLogger: () => loggerMock,
|
|
13
|
+
createLogger: () => loggerMock,
|
|
14
|
+
setPiHandle: vi.fn(),
|
|
15
|
+
}))
|
|
16
|
+
|
|
7
17
|
import { getLegacyStorePath, importLegacyStore } from '../importer.js'
|
|
8
18
|
import type { ScheduledTask } from '../types.js'
|
|
9
19
|
|
|
@@ -52,9 +62,8 @@ describe('importLegacyStore', () => {
|
|
|
52
62
|
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
53
63
|
vi.mocked(fs.readFileSync).mockReturnValue('{}')
|
|
54
64
|
vi.mocked(fs.unlinkSync).mockImplementation(() => {})
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
65
|
+
// 清理 logger mock(logger 默认 no-op,无需抑制输出)
|
|
66
|
+
loggerMock.warn.mockClear()
|
|
58
67
|
})
|
|
59
68
|
|
|
60
69
|
afterEach(() => {
|
|
@@ -101,9 +110,10 @@ describe('importLegacyStore', () => {
|
|
|
101
110
|
expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
|
|
102
111
|
expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
|
|
103
112
|
|
|
104
|
-
// 5)
|
|
105
|
-
expect(
|
|
106
|
-
expect.stringContaining(
|
|
113
|
+
// 5) 进度日志
|
|
114
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
115
|
+
expect.stringContaining('imported legacy tasks'),
|
|
116
|
+
expect.objectContaining({ count: 2 }),
|
|
107
117
|
)
|
|
108
118
|
})
|
|
109
119
|
|
|
@@ -177,8 +187,9 @@ describe('importLegacyStore', () => {
|
|
|
177
187
|
const appendEntry = vi.fn()
|
|
178
188
|
|
|
179
189
|
expect(() => importLegacyStore(cwd, { appendEntry }, sessionFile)).not.toThrow()
|
|
180
|
-
expect(
|
|
181
|
-
expect.stringContaining('
|
|
190
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
191
|
+
expect.stringContaining('import failed'),
|
|
192
|
+
expect.objectContaining({ error: expect.stringContaining('not valid JSON') }),
|
|
182
193
|
)
|
|
183
194
|
expect(appendEntry).toHaveBeenCalledTimes(0) // parse 失败,无 append
|
|
184
195
|
})
|
|
@@ -215,7 +226,10 @@ describe('importLegacyStore', () => {
|
|
|
215
226
|
// append 已发生,但 unlink 未执行——数据可能仅内存,销毁源文件 = 永久丢失
|
|
216
227
|
expect(appendEntry).toHaveBeenCalledTimes(1)
|
|
217
228
|
expect(fs.unlinkSync).not.toHaveBeenCalled()
|
|
218
|
-
expect(
|
|
229
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
230
|
+
expect.stringContaining('deferring'),
|
|
231
|
+
expect.objectContaining({ count: 1 }),
|
|
232
|
+
)
|
|
219
233
|
|
|
220
234
|
// 情形1:flush 已发生(sessionFile 出现,.imported 仍在)→ cleanup 删除 .imported
|
|
221
235
|
vi.mocked(fs.existsSync).mockImplementation(p => p === sessionFile || p === importedPath)
|
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
21
21
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
22
22
|
|
|
23
|
+
// Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
|
|
24
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
25
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
26
|
+
}))
|
|
27
|
+
vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
28
|
+
getLogger: () => loggerMock,
|
|
29
|
+
createLogger: () => loggerMock,
|
|
30
|
+
setPiHandle: vi.fn(),
|
|
31
|
+
}))
|
|
32
|
+
|
|
23
33
|
// 与 index-session-start.test.ts 同款(MF-3):mock 掉 importer,装配路径仍被调用、FS 副作用为零。
|
|
24
34
|
vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
|
|
25
35
|
|
|
@@ -127,7 +137,7 @@ describe('G1: index.ts 代际接线(S9)', () => {
|
|
|
127
137
|
it('factory 重跑:第二次 factory 执行 + session_start 后,第一代 runtime isCtxStale 为 true 且 tick 前置自停', async () => {
|
|
128
138
|
vi.useFakeTimers()
|
|
129
139
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
130
|
-
|
|
140
|
+
loggerMock.warn.mockClear()
|
|
131
141
|
try {
|
|
132
142
|
// 第一代:独立 factory 执行 + session_start 装配(runtime1 真实 startScheduler)
|
|
133
143
|
const first = createMockPi()
|
|
@@ -156,15 +166,14 @@ describe('G1: index.ts 代际接线(S9)', () => {
|
|
|
156
166
|
// tick 前置自停:第一代 runtime 的泄漏 timer 在下个 tick 被代际前置检查拦截
|
|
157
167
|
// (G1-b 的生产路径验证:warn "tick stopped" + timer 自停,后续 tick 不再发生)
|
|
158
168
|
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
|
|
159
|
-
const warnText =
|
|
169
|
+
const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
|
|
160
170
|
expect(warnText).toContain('tick stopped')
|
|
161
171
|
expect(warnText).not.toContain('tick error')
|
|
162
172
|
|
|
163
|
-
const warnCountAfterSelfStop =
|
|
173
|
+
const warnCountAfterSelfStop = loggerMock.warn.mock.calls.length
|
|
164
174
|
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 warn
|
|
165
|
-
expect(
|
|
175
|
+
expect(loggerMock.warn.mock.calls.length).toBe(warnCountAfterSelfStop)
|
|
166
176
|
} finally {
|
|
167
|
-
warnSpy.mockRestore()
|
|
168
177
|
vi.useRealTimers()
|
|
169
178
|
}
|
|
170
179
|
})
|