@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.3.4",
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
- // 抑制 importer console.log/warn 输出(断言用 spy)
56
- vi.spyOn(console, 'log').mockImplementation(() => {})
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) 进度日志(console.warn,项目 convention 禁 console.log)
105
- expect(console.warn).toHaveBeenCalledWith(
106
- expect.stringContaining(`imported 2 legacy tasks from ${importedPath}`),
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(console.warn).toHaveBeenCalledWith(
181
- expect.stringContaining('[scheduler] import failed'),
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(console.warn).toHaveBeenCalledWith(expect.stringContaining('deferring'))
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
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
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 = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
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 = warnSpy.mock.calls.length
173
+ const warnCountAfterSelfStop = loggerMock.warn.mock.calls.length
164
174
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 warn
165
- expect(warnSpy.mock.calls.length).toBe(warnCountAfterSelfStop)
175
+ expect(loggerMock.warn.mock.calls.length).toBe(warnCountAfterSelfStop)
166
176
  } finally {
167
- warnSpy.mockRestore()
168
177
  vi.useRealTimers()
169
178
  }
170
179
  })