@zhushanwen/pi-scheduler 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +66 -30
  2. package/package.json +15 -4
  3. package/skills/scheduler-ext-config/SKILL.md +37 -36
  4. package/src/__tests__/U4-MODEL-SWITCH.test.ts +638 -0
  5. package/src/__tests__/ack-contract.test.ts +253 -0
  6. package/src/__tests__/ack-notify.test.ts +152 -0
  7. package/src/__tests__/ack-provider.test.ts +288 -0
  8. package/src/__tests__/ack-turn.test.ts +456 -0
  9. package/src/__tests__/commands-form.test.ts +415 -0
  10. package/src/__tests__/commands.test.ts +207 -152
  11. package/src/__tests__/create-form-component.test.ts +474 -0
  12. package/src/__tests__/format.test.ts +37 -16
  13. package/src/__tests__/index-generation.test.ts +17 -1
  14. package/src/__tests__/index-session-start.test.ts +50 -18
  15. package/src/__tests__/interaction.test.ts +140 -0
  16. package/src/__tests__/mock-backend.ts +18 -2
  17. package/src/__tests__/parsing.test.ts +38 -0
  18. package/src/__tests__/sdk-contract.test.ts +10 -8
  19. package/src/__tests__/service.test.ts +251 -4
  20. package/src/__tests__/tool-create-flow.test.ts +135 -0
  21. package/src/__tests__/tool.test.ts +7 -5
  22. package/src/__tests__/widget-push.test.ts +231 -0
  23. package/src/__tests__/widget.test.ts +237 -17
  24. package/src/ack-notify.ts +56 -0
  25. package/src/ack-provider.ts +250 -0
  26. package/src/ack-turn.ts +372 -0
  27. package/src/backend.ts +73 -4
  28. package/src/commands.ts +153 -91
  29. package/src/create-form-component.ts +1010 -0
  30. package/src/format.ts +75 -30
  31. package/src/i18n.ts +458 -0
  32. package/src/importer.ts +2 -2
  33. package/src/index.ts +188 -16
  34. package/src/interaction.ts +304 -0
  35. package/src/runtime.ts +394 -4
  36. package/src/service.ts +96 -25
  37. package/src/tool.ts +65 -11
  38. package/src/types.ts +113 -0
  39. package/src/widget.ts +133 -18
  40. package/vitest.config.ts +2 -4
@@ -0,0 +1,638 @@
1
+ /**
2
+ * U4_MODEL_SWITCH:dispatch 模型切换(设计 D3 修订版)验收
3
+ *
4
+ * 覆盖设计 §3.3 D3 的五块机制(探针 P-MODEL 修订后语义):
5
+ * - 切换:dispatchTaskInner 在 sendMessage 前 setModel(busy 亦生效——本层不做 idle 检查)
6
+ * - 归属与恢复:message_start customType 前缀匹配 + run 窗口内 turnIndex 序态关联;
7
+ * turn_end(dispatchedTurnIndex) + isIdle 复核推迟;agent_end 封口 run 窗口;
8
+ * agent_settled 封口 + awaiting-restore 即时兑现(恢复延迟从最长 1 tick 收敛到事件即时)
9
+ * - 互斥:切换在途时其他需切模型任务 skip + pending 留待下 tick 重试
10
+ * - 串行化(MF-2):turn_end 恢复在途 / 切换 setModel 在途未建记录窗口内,后继需切模型
11
+ * 任务的 setModel 排队等前序模型 op 完成(任意两个 setModel 不并发、双记录不叠写)
12
+ * - 对账兜底:严格先于同 tick dispatch 循环;在途标记 2 tick 过期强制开放;未决记录守卫
13
+ * (无记录时模型漂移 = 用户自主行为不动作)
14
+ * - 降级与接管副作用:setModel false 不阻塞 dispatch;sendMessage 抛错 catch 先恢复;
15
+ * 模型语义异常只走日志(不进持久化词表)
16
+ *
17
+ * 事件注入:直接调用 runtime 的 handle* 事件入口(index.ts 的 pi.on 只做转发)。
18
+ * tick:fake timers 驱动真实 interval(对齐 runtime.test.ts F2 形态)。
19
+ */
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
21
+
22
+ // Mock 共享 logger,让 logger.warn 可被 spy(对齐 runtime.test.ts 形态)
23
+ const { loggerMock } = vi.hoisted(() => ({
24
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
25
+ }))
26
+ vi.mock('@zhushanwen/pi-extension-logger', () => ({
27
+ getLogger: () => loggerMock,
28
+ createLogger: () => loggerMock,
29
+ setPiHandle: vi.fn(),
30
+ }))
31
+
32
+ import { MockSchedulerBackend } from './mock-backend.js'
33
+ import { SchedulerRuntime, type SchedulerModelOps } from '../runtime.js'
34
+ import type { ScheduledTask } from '../types.js'
35
+
36
+ const TICK_INTERVAL_MS = 30_000
37
+ const ORIG = 'prov-a/model-1'
38
+ const TASK_M = 'prov-b/model-2'
39
+ const OTHER_M = 'prov-c/model-3'
40
+
41
+ /** 模型控制面 mock:setModelCalls 记录调用序(切换/恢复时序断言的核心观测点) */
42
+ class ModelOpsMock implements SchedulerModelOps {
43
+ currentRef: string | undefined = ORIG
44
+ idle = true
45
+ setModelResult = true
46
+ setModelCalls: string[] = []
47
+
48
+ getCurrentModelRef(): string | undefined {
49
+ return this.currentRef
50
+ }
51
+
52
+ async setModelByRef(ref: string): Promise<boolean> {
53
+ this.setModelCalls.push(ref)
54
+ if (this.setModelResult) this.currentRef = ref
55
+ return this.setModelResult
56
+ }
57
+
58
+ isIdle(): boolean {
59
+ return this.idle
60
+ }
61
+ }
62
+
63
+ /** 纯 microtask 冲刷:handleTurnEnd 的恢复是 fire-and-forget async,断言前等待其 await 链完成 */
64
+ async function flushAsync(): Promise<void> {
65
+ for (let i = 0; i < 5; i++) await Promise.resolve()
66
+ }
67
+
68
+ /** 放宽轮数的 microtask 冲刷:gate 释放后的「mock 续跑 → 队列推进 → dispatch 收尾」链
69
+ * 跨多个 await 边界(≥8 轮),固定 5 轮会卡在结算中途误报未完成。 */
70
+ async function flushAsyncRounds(rounds: number): Promise<void> {
71
+ for (let i = 0; i < rounds; i++) await Promise.resolve()
72
+ }
73
+
74
+ /** dispatch 注入的 custom message 形状(P-MODEL-④ 实测 payload) */
75
+ function dispatchCustomMessage(): Record<string, unknown> {
76
+ return { role: 'custom', customType: 'pi-scheduler:dispatched', content: 'job', display: true, timestamp: 0 }
77
+ }
78
+
79
+ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
80
+ let backend: MockSchedulerBackend
81
+ let ops: ModelOpsMock
82
+ let runtime: SchedulerRuntime
83
+ let task: ScheduledTask
84
+
85
+ beforeEach(() => {
86
+ vi.clearAllMocks()
87
+ vi.useFakeTimers()
88
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
89
+ backend = new MockSchedulerBackend()
90
+ ops = new ModelOpsMock()
91
+ runtime = new SchedulerRuntime(backend, undefined, ops)
92
+ })
93
+
94
+ afterEach(() => {
95
+ runtime.stopScheduler()
96
+ vi.useRealTimers()
97
+ })
98
+
99
+ async function addModelTask(prompt: string, model: string): Promise<ScheduledTask> {
100
+ // interval 10 分钟:测试窗口(≤5 tick = 150s)内任务不会自身重到期(onDispatchSuccess 推进
101
+ // nextRunAt),避免现状调度语义(到期重发)污染切换/互斥场景
102
+ return runtime.addTask(prompt, { mode: 'interval', intervalMs: 600_000 }, { model })
103
+ }
104
+
105
+ /** 事件序注入(P-MODEL-④ 实测序态):agent_start → turn_start(n) → message_start(custom) → turn_end(n) */
106
+ function injectDispatchTurn(turnIndex: number): void {
107
+ runtime.handleAgentStart()
108
+ runtime.handleTurnStart(turnIndex)
109
+ runtime.handleMessageStart(dispatchCustomMessage())
110
+ runtime.handleTurnEnd(turnIndex)
111
+ }
112
+
113
+ describe('切换与归属恢复', () => {
114
+ it('task.model ≠ 当前 → setModel(目标) + 归属 turn 结束后恢复(原)', async () => {
115
+ task = await addModelTask('job', TASK_M)
116
+ const ok = await runtime.dispatchTask(task)
117
+ expect(ok).toBe(true)
118
+ expect(ops.setModelCalls).toEqual([TASK_M])
119
+
120
+ injectDispatchTurn(0)
121
+ await flushAsync()
122
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
123
+
124
+ // 记录已清:重复 turn_end 不二次恢复
125
+ runtime.handleTurnEnd(0)
126
+ await flushAsync()
127
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
128
+ })
129
+
130
+ it('task.model = 当前 → 不切换不写记录', async () => {
131
+ task = await addModelTask('job', ORIG)
132
+ const ok = await runtime.dispatchTask(task)
133
+ expect(ok).toBe(true)
134
+ expect(ops.setModelCalls).toEqual([])
135
+
136
+ injectDispatchTurn(0)
137
+ await flushAsync()
138
+ expect(ops.setModelCalls).toEqual([])
139
+ })
140
+
141
+ it('turn_end 非 idle → 推迟(无立即恢复),tick 重入 idle 即兑现', async () => {
142
+ task = await addModelTask('job', TASK_M)
143
+ await runtime.dispatchTask(task)
144
+
145
+ // 用户长 run 在途(非 idle):turn_end 只推迟
146
+ ops.idle = false
147
+ injectDispatchTurn(0)
148
+ await flushAsync()
149
+ expect(ops.setModelCalls).toEqual([TASK_M])
150
+
151
+ // run 沉降后 tick 重入:恢复兑现
152
+ ops.idle = true
153
+ runtime.startScheduler()
154
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
155
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
156
+ })
157
+
158
+ it('agent_settled 即时兑现:awaiting-restore + idle → 不等 tick 立即恢复', async () => {
159
+ task = await addModelTask('job', TASK_M)
160
+ await runtime.dispatchTask(task)
161
+ expect(ops.setModelCalls).toEqual([TASK_M])
162
+
163
+ // 用户长 run 在途:turn_end 推迟为 awaiting-restore
164
+ ops.idle = false
165
+ injectDispatchTurn(0)
166
+ await flushAsync()
167
+ expect(ops.setModelCalls).toEqual([TASK_M])
168
+
169
+ // run 完全沉降(agent_settled):立即恢复,无需推进 30s tick
170
+ ops.idle = true
171
+ runtime.handleRunSettled()
172
+ await flushAsync()
173
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
174
+ })
175
+
176
+ it('agent_settled 与用户新 run 交错(非 idle)→ 不动作,tick 兜底仍兑现', async () => {
177
+ task = await addModelTask('job', TASK_M)
178
+ await runtime.dispatchTask(task)
179
+
180
+ ops.idle = false
181
+ injectDispatchTurn(0)
182
+ await flushAsync()
183
+ expect(ops.setModelCalls).toEqual([TASK_M])
184
+
185
+ // settled 事件处理时用户已开始新 run(isIdle false):不能切(会把新 run 模型换掉)
186
+ runtime.handleRunSettled()
187
+ await flushAsync()
188
+ expect(ops.setModelCalls).toEqual([TASK_M])
189
+
190
+ // 兜底不变:后续 tick 重入 idle 仍恢复
191
+ ops.idle = true
192
+ runtime.startScheduler()
193
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
194
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
195
+ })
196
+
197
+ it('agent_end 纯封口不恢复(只有 settled 兑现):awaiting-restore 态下 end 后仍不动作', async () => {
198
+ task = await addModelTask('job', TASK_M)
199
+ await runtime.dispatchTask(task)
200
+ expect(ops.setModelCalls).toEqual([TASK_M])
201
+
202
+ // 构造 awaiting-restore:归属 turn_end 时非 idle(用户长 run 在途)→ 推迟
203
+ ops.idle = false
204
+ runtime.handleAgentStart()
205
+ runtime.handleTurnStart(0)
206
+ runtime.handleMessageStart(dispatchCustomMessage())
207
+ runtime.handleTurnEnd(0)
208
+ ops.idle = true
209
+ // run 末尾 agent_end:纯封口——end 后可能仍有自动续跑 turn(retry/compaction/
210
+ // queued continuation),此时切回会把续跑 turn 的模型换掉
211
+ runtime.handleRunClosed()
212
+ await flushAsync()
213
+ expect(ops.setModelCalls).toEqual([TASK_M])
214
+
215
+ // 对照:同一状态下 agent_settled 立即兑现(a 段不动作 ≠ 状态机卡死)
216
+ runtime.handleRunSettled()
217
+ await flushAsync()
218
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
219
+ })
220
+
221
+ it('in-flight 态(归属 turn_end 未到达)settled 不动作:归属异常归 tick 对账', async () => {
222
+ task = await addModelTask('job', TASK_M)
223
+ await runtime.dispatchTask(task)
224
+ expect(ops.setModelCalls).toEqual([TASK_M])
225
+
226
+ // in-flight + idle:settled 不动 in-flight(turn_end 丢失的强制开放归 2-tick 对账)
227
+ ops.idle = true
228
+ runtime.handleRunSettled()
229
+ await flushAsync()
230
+ expect(ops.setModelCalls).toEqual([TASK_M])
231
+
232
+ // 归属 turn_end 到达后既有通道正常恢复
233
+ injectDispatchTurn(0)
234
+ await flushAsync()
235
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
236
+ })
237
+
238
+ it('非匹配事件不触发恢复:非 custom / 非前缀 / turnIndex 不匹配 / run 窗口封口', async () => {
239
+ task = await addModelTask('job', TASK_M)
240
+ await runtime.dispatchTask(task)
241
+
242
+ // a) 非 custom message(assistant 形状)不归属
243
+ runtime.handleAgentStart()
244
+ runtime.handleTurnStart(0)
245
+ runtime.handleMessageStart({ role: 'assistant', provider: 'p', model: 'm', content: [], timestamp: 0 })
246
+ runtime.handleTurnEnd(0)
247
+ await flushAsync()
248
+ expect(ops.setModelCalls).toEqual([TASK_M])
249
+
250
+ // b) customType 非 pi-scheduler: 前缀不归属
251
+ runtime.handleMessageStart({ role: 'custom', customType: 'other-ext:thing', content: '', display: true, timestamp: 0 })
252
+ runtime.handleTurnEnd(0)
253
+ await flushAsync()
254
+ expect(ops.setModelCalls).toEqual([TASK_M])
255
+
256
+ // c) 前缀命中但 turnIndex 不匹配(归属 0,结束 1)
257
+ runtime.handleMessageStart(dispatchCustomMessage())
258
+ runtime.handleTurnEnd(1)
259
+ await flushAsync()
260
+ expect(ops.setModelCalls).toEqual([TASK_M])
261
+
262
+ // d) run 窗口封口(agent_end/agent_settled)后陈旧索引失效
263
+ runtime.handleRunClosed()
264
+ runtime.handleTurnEnd(0)
265
+ await flushAsync()
266
+ expect(ops.setModelCalls).toEqual([TASK_M])
267
+
268
+ // sanity:封口后新 run 的正常归属仍可恢复(封口只清陈旧索引,不杀记录)
269
+ injectDispatchTurn(0)
270
+ await flushAsync()
271
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
272
+ })
273
+
274
+ it('恢复 setModel(原) false → restore-failed 终态:warn 日志 + 清标记 + 对账不再动作', async () => {
275
+ task = await addModelTask('job', TASK_M)
276
+ await runtime.dispatchTask(task)
277
+ expect(ops.setModelCalls).toEqual([TASK_M])
278
+
279
+ // 模拟恢复期 setModel 失败(无可用 key 等)
280
+ ops.setModelResult = false
281
+ injectDispatchTurn(0)
282
+ await flushAsync()
283
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
284
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
285
+ expect(warnText).toContain('restore-failed')
286
+
287
+ // 标记已清:后续对账窗口不重复动作
288
+ ops.setModelResult = true
289
+ runtime.startScheduler()
290
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 3)
291
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
292
+ })
293
+
294
+ it('恢复前用户已手动切回(当前 == 期望)→ 清记录不动作', async () => {
295
+ task = await addModelTask('job', TASK_M)
296
+ await runtime.dispatchTask(task)
297
+ ops.currentRef = ORIG // 用户手动切回
298
+
299
+ injectDispatchTurn(0)
300
+ await flushAsync()
301
+ expect(ops.setModelCalls).toEqual([TASK_M]) // 无恢复调用
302
+ })
303
+ })
304
+
305
+ describe('互斥窗口', () => {
306
+ it('切换在途时第二个需切模型任务 skip + pending 保留 + A 结算后重试成功', async () => {
307
+ const taskA = await addModelTask('job-a', TASK_M)
308
+ await runtime.dispatchTask(taskA)
309
+
310
+ // B 到期走 tick 路径:step2 标 pending → step3 dispatch 命中互斥 skip
311
+ const taskB = await addModelTask('job-b', OTHER_M)
312
+ taskB.nextRunAt = 0
313
+ runtime.startScheduler()
314
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
315
+
316
+ expect(taskB.pending).toBe(true) // skip 不清 pending(留待下 tick 重试,复用现状语义)
317
+ expect(backend.sentMessages).toHaveLength(1) // B 未发出
318
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
319
+ expect(warnText).toContain('model switch in progress')
320
+
321
+ // 不需切模型的任务不受互斥影响(无 model 字段)
322
+ const taskC = await runtime.addTask('job-c', { mode: 'interval', intervalMs: 600_000 })
323
+ const okC = await runtime.dispatchTask(taskC)
324
+ expect(okC).toBe(true)
325
+ expect(backend.sentMessages).toHaveLength(2)
326
+
327
+ // A 结算(turn_end + idle 恢复)后 B 重试成功
328
+ injectDispatchTurn(0)
329
+ await flushAsync()
330
+ const okRetry = await runtime.dispatchTask(taskB)
331
+ expect(okRetry).toBe(true)
332
+ expect(backend.sentMessages).toHaveLength(3)
333
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M])
334
+ })
335
+
336
+ it('互斥强制结算的恢复与后续切换串行:恢复(原)完成前新切换目标不得写入(无交错)', async () => {
337
+ // 场景(runTaskNow 直连路径):互斥命中触发 mutex-forced 强制结算恢复;若恢复
338
+ // fire-and-forget,恢复先同步清记录再 await setModel(原),紧随的需切模型任务 B 的
339
+ // setModel(目标) 与之并发、完成顺序不定(恢复后完成则 B 的 turn 用错模型)。修复后
340
+ // 互斥分支 await 强制结算——锁定「恢复(原)记账并完成前,新切换不得发起」的顺序契约。
341
+ //
342
+ // 状态构造说明:公开行为下 reconcile 在 ticksOpen 递增过窗口的同一 tick 内即结算
343
+ //(idle 恢复 / 非 idle 转推迟),互斥分支命中的「in-flight 过窗口残留」是防御分支——
344
+ // 此处直接注入该内部状态(extensions 测试目录豁免 unsafe-cast 规则,ask-user 等先例)。
345
+ const taskA = await addModelTask('job-a', TASK_M)
346
+ await runtime.dispatchTask(taskA)
347
+ expect(ops.setModelCalls).toEqual([TASK_M])
348
+
349
+ // A 的事件全部丢失;把在途标记推过对账窗口(> MODEL_SWITCH_RECONCILE_TICKS = 2)
350
+ const internal = runtime as unknown as { pendingModelSwitch: { ticksOpen: number } | null }
351
+ expect(internal.pendingModelSwitch).not.toBeNull()
352
+ internal.pendingModelSwitch!.ticksOpen = 3
353
+
354
+ // 恢复(ORIG) 的 setModel 在「已记账未完成」态挂起(模拟真实 RPC 在途窗口)
355
+ let releaseRestore: (() => void) | undefined
356
+ const restoreInFlight = new Promise<void>(resolve => {
357
+ releaseRestore = resolve
358
+ })
359
+ let gatedCalls = 0
360
+ vi.spyOn(ops, 'setModelByRef').mockImplementation(async (ref: string) => {
361
+ ops.setModelCalls.push(ref)
362
+ if (ops.setModelResult) ops.currentRef = ref
363
+ if (gatedCalls++ === 0) await restoreInFlight // 首个调用 = mutex-forced 恢复
364
+ return ops.setModelResult
365
+ })
366
+
367
+ // B(需切模型)直连 dispatch:命中互斥 → 强制结算恢复(挂起)→ await
368
+ const taskB = await addModelTask('job-b', OTHER_M)
369
+ let bSettled = false
370
+ let bResult: boolean | undefined
371
+ void runtime.dispatchTask(taskB).then(ok => {
372
+ bSettled = true
373
+ bResult = ok
374
+ })
375
+ await flushAsync()
376
+
377
+ // 恢复已发起(ORIG 已记账)但未完成:B 未被放行、OTHER_M 未写入(无交错核心断言)
378
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
379
+ expect(bSettled).toBe(false)
380
+ expect(backend.sentMessages).toHaveLength(1) // 仅 A 的消息,B 未 dispatch
381
+
382
+ // 放行恢复 → 互斥分支才继续 skip(B 返回 false,pending 留待下 tick 重试)
383
+ releaseRestore!()
384
+ await flushAsync()
385
+ expect(bSettled).toBe(true)
386
+ expect(bResult).toBe(false)
387
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG]) // B 被 skip,未切换
388
+ expect(backend.sentMessages).toHaveLength(1)
389
+
390
+ // 恢复完成后 B 重试:新切换目标写入在恢复之后(调用序断言)
391
+ const okRetry = await runtime.dispatchTask(taskB)
392
+ expect(okRetry).toBe(true)
393
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M])
394
+ expect(ops.currentRef).toBe(OTHER_M)
395
+ expect(backend.sentMessages).toHaveLength(2)
396
+ })
397
+
398
+ it('切换 setModel 在途未建记录时并发第二个 model 任务 → 不并发第二 setModel、不叠写记录', async () => {
399
+ // 同根 SUG 回归(互斥窗口全程覆盖):pendingModelSwitch 在 setModelByRef 受理之后才
400
+ // 创建,「setModel 起步 → 记录创建」的 await 窗口内第二个需切模型任务可经 runTaskNow
401
+ // 直连进入同一分支(互斥检查读到 null)→ 双记录叠写 + 两个 setModel 交错。修复后
402
+ // 切换段整体入模型 op 队列,后继任务排队、出队时重新校验命中互斥 skip。
403
+ const taskA = await addModelTask('job-a', TASK_M)
404
+ // A 的切换 setModel 挂起在「已发起未受理」态(记录尚未创建的窗口)
405
+ let releaseSwitch: (() => void) | undefined
406
+ const switchGate = new Promise<void>(resolve => {
407
+ releaseSwitch = resolve
408
+ })
409
+ let gatedCalls = 0
410
+ vi.spyOn(ops, 'setModelByRef').mockImplementation(async (ref: string) => {
411
+ ops.setModelCalls.push(ref)
412
+ if (ops.setModelResult) ops.currentRef = ref
413
+ if (gatedCalls++ === 0) await switchGate // 首次 = A 的切换
414
+ return ops.setModelResult
415
+ })
416
+
417
+ let aSettled = false
418
+ void runtime.dispatchTask(taskA).then(ok => {
419
+ aSettled = true
420
+ })
421
+ await flushAsync()
422
+ expect(ops.setModelCalls).toEqual([TASK_M])
423
+ // 窗口内证据:setModel 已发起、未决记录尚未创建
424
+ expect(
425
+ (runtime as unknown as { pendingModelSwitch: unknown }).pendingModelSwitch,
426
+ ).toBeNull()
427
+ expect(aSettled).toBe(false)
428
+
429
+ // B 并发直连 dispatch:此刻互斥检查读到 null(修复前在此放行第二 setModel)
430
+ const taskB = await addModelTask('job-b', OTHER_M)
431
+ let bSettled = false
432
+ let bResult: boolean | undefined
433
+ void runtime.dispatchTask(taskB).then(ok => {
434
+ bSettled = true
435
+ bResult = ok
436
+ })
437
+ await flushAsync()
438
+
439
+ // B 的 setModel 未并发发起(排队等 A 的切换段出队)
440
+ expect(ops.setModelCalls).toEqual([TASK_M])
441
+ expect(bSettled).toBe(false)
442
+
443
+ // 放行 A 切换 → A 建记录并完成 dispatch;B 出队后重新校验命中互斥 → skip 不叠写
444
+ //(mock 续跑→opA 建记录→队列推进→opB skip→双 dispatch 收尾 ≥8 轮,放宽冲刷)
445
+ releaseSwitch!()
446
+ await flushAsyncRounds(30)
447
+ expect(aSettled).toBe(true)
448
+ expect(bSettled).toBe(true)
449
+ expect(bResult).toBe(false)
450
+ expect(ops.setModelCalls).toEqual([TASK_M]) // 全程仅一次切换调用(无并发第二 setModel)
451
+ expect(backend.sentMessages).toHaveLength(1) // 仅 A dispatch
452
+
453
+ // 记录未被 B 叠写:仍是 A 的记录(expected = ORIG 而非 TASK_M)
454
+ const internal = runtime as unknown as {
455
+ pendingModelSwitch: { taskId: string; expectedModelRef: string; targetModelRef: string } | null
456
+ }
457
+ expect(internal.pendingModelSwitch).not.toBeNull()
458
+ expect(internal.pendingModelSwitch!.taskId).toBe(taskA.id)
459
+ expect(internal.pendingModelSwitch!.expectedModelRef).toBe(ORIG)
460
+ expect(internal.pendingModelSwitch!.targetModelRef).toBe(TASK_M)
461
+
462
+ // A 结算后 B 重试成功(系统收敛,目标模型生效)
463
+ injectDispatchTurn(0)
464
+ await flushAsync()
465
+ const okRetry = await runtime.dispatchTask(taskB)
466
+ expect(okRetry).toBe(true)
467
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M])
468
+ expect(ops.currentRef).toBe(OTHER_M)
469
+ })
470
+
471
+ it('同 tick 双任务顺序:A 恢复过期强制开放 + B 到期切换 → B 生效不被 A 回滚', async () => {
472
+ // A dispatch(记录在途)后事件全部丢失
473
+ const taskA = await addModelTask('job-a', TASK_M)
474
+ await runtime.dispatchTask(taskA)
475
+ const taskB = await addModelTask('job-b', OTHER_M)
476
+ taskB.nextRunAt = 0 // 到期
477
+
478
+ runtime.startScheduler()
479
+ // tick1:对账 ticksOpen=1(窗口内等事件);B pending → 互斥 skip
480
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
481
+ expect(backend.sentMessages).toHaveLength(1)
482
+ expect(taskB.pending).toBe(true)
483
+ // tick2:ticksOpen=2 仍在窗口内;B 仍 skip
484
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
485
+ expect(backend.sentMessages).toHaveLength(1)
486
+ // tick3:ticksOpen=3 > 2 → A 强制开放(idle 即恢复)→ dispatch 循环 B 切换成功。
487
+ // 顺序约束(对账 await 先于 dispatch 循环):A 的恢复 setModel 完成后 B 才 setModel,
488
+ // B 生效且不被 A 回滚(若交错则最后写入是 ORIG)
489
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
490
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M])
491
+ expect(ops.currentRef).toBe(OTHER_M)
492
+ expect(backend.sentMessages).toHaveLength(2)
493
+ expect(taskB.lastStatus).toBe('success')
494
+
495
+ // B 的恢复链路随后正常闭环
496
+ injectDispatchTurn(0)
497
+ await flushAsync()
498
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M, ORIG])
499
+ })
500
+ })
501
+
502
+ describe('模型 op 串行化(MF-2 回归)', () => {
503
+ it('turn_end 恢复在途时新需切模型任务 dispatch → 新 setModel 排队等恢复完成(先恢复后切换)', async () => {
504
+ // MF-2 回归:handleTurnEnd 的恢复是 fire-and-forget(先同步清记录再 await setModel(原))。
505
+ // 修复前恢复在途窗口内 pendingModelSwitch 已为 null → 新 dispatch 的互斥检查放行,
506
+ // setModel(目标) 与在途 setModel(原) 并发、完成顺序不定(恢复后完成则该任务 turn
507
+ // 静默跑错模型)。修复后恢复持模型 op 队列,后继 setModel 排队等其完成。
508
+ const taskA = await addModelTask('job-a', TASK_M)
509
+ await runtime.dispatchTask(taskA)
510
+ expect(ops.setModelCalls).toEqual([TASK_M])
511
+
512
+ // 恢复(ORIG) 的 setModel 挂起在「已记账未完成」态(模拟真实 RPC 在途窗口)
513
+ let releaseRestore: (() => void) | undefined
514
+ const restoreGate = new Promise<void>(resolve => {
515
+ releaseRestore = resolve
516
+ })
517
+ // gate 按 ref 定位(不用调用序号):spy 在 A 的 dispatch 之后安装,restore 是 spy
518
+ // 观测的首个调用,调用序号计数会把 gate 错挂到后继 dispatch 切换上
519
+ vi.spyOn(ops, 'setModelByRef').mockImplementation(async (ref: string) => {
520
+ ops.setModelCalls.push(ref)
521
+ if (ops.setModelResult) ops.currentRef = ref
522
+ if (ref === ORIG) await restoreGate // turn_end 恢复挂起在「已发起未完成」态
523
+ return ops.setModelResult
524
+ })
525
+
526
+ // A 的 turn 正常结束且 idle → fire-and-forget 恢复启动并挂起
527
+ injectDispatchTurn(0)
528
+ await flushAsync()
529
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
530
+ // 记录已同步清(先关后恢复不变)——修复前正是这个窗口放行了并发 setModel
531
+ expect(
532
+ (runtime as unknown as { pendingModelSwitch: unknown }).pendingModelSwitch,
533
+ ).toBeNull()
534
+
535
+ // B(需切模型)直连 dispatch:恢复在途、记录已清
536
+ const taskB = await addModelTask('job-b', OTHER_M)
537
+ let bSettled = false
538
+ let bResult: boolean | undefined
539
+ void runtime.dispatchTask(taskB).then(ok => {
540
+ bSettled = true
541
+ bResult = ok
542
+ })
543
+ await flushAsync()
544
+
545
+ // B 的 setModel 未并发发起:仍在排队等恢复(顺序约束核心断言)
546
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
547
+ expect(bSettled).toBe(false)
548
+ expect(backend.sentMessages).toHaveLength(1) // 仅 A 的消息,B 未 dispatch
549
+
550
+ // 放行恢复 → B 的 setModel 才发起(调用序 = 恢复先完成),B 正常 dispatch
551
+ //(restore 续跑→队列推进→opB 切换→记录→dispatch 收尾 ≥8 轮,放宽冲刷)
552
+ releaseRestore!()
553
+ await flushAsyncRounds(30)
554
+ expect(bSettled).toBe(true)
555
+ expect(bResult).toBe(true)
556
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M])
557
+ expect(ops.currentRef).toBe(OTHER_M)
558
+ expect(backend.sentMessages).toHaveLength(2)
559
+
560
+ // B 的恢复链路随后正常闭环(终态回 ORIG)
561
+ injectDispatchTurn(0)
562
+ await flushAsync()
563
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M, ORIG])
564
+ })
565
+ })
566
+
567
+ describe('对账兜底', () => {
568
+ it('在途标记超过 2 tick 未关闭 → 强制开放并对账恢复(事件丢失路径)', async () => {
569
+ task = await addModelTask('job', TASK_M)
570
+ await runtime.dispatchTask(task)
571
+ // 无任何事件注入(恢复回调永不执行)
572
+
573
+ runtime.startScheduler()
574
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1: ticksOpen=1
575
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick2: ticksOpen=2,窗口内
576
+ expect(ops.setModelCalls).toEqual([TASK_M])
577
+
578
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick3: >2 → 强制开放(idle)→ 恢复
579
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
580
+ })
581
+
582
+ it('无未决记录时模型漂移 = 用户自主行为,对账不动作', async () => {
583
+ ops.currentRef = 'prov-x/model-y' // 无 dispatch,用户手动改模型
584
+ runtime.startScheduler()
585
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 5)
586
+ expect(ops.setModelCalls).toEqual([])
587
+ })
588
+ })
589
+
590
+ describe('降级与接管副作用', () => {
591
+ it('setModel false → 日志 + 放弃切换,任务照常 dispatch(无恢复链路)', async () => {
592
+ ops.setModelResult = false
593
+ task = await addModelTask('job', TASK_M)
594
+ const ok = await runtime.dispatchTask(task)
595
+ expect(ok).toBe(true)
596
+ expect(backend.sentMessages).toHaveLength(1)
597
+ expect(task.lastStatus).toBe('success')
598
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
599
+ expect(warnText).toContain('model switch failed')
600
+
601
+ // 无未决记录:事件归属不建立、turn_end 无恢复
602
+ injectDispatchTurn(0)
603
+ await flushAsync()
604
+ expect(ops.setModelCalls).toEqual([TASK_M])
605
+ })
606
+
607
+ it('sendMessage 同步抛错 → catch 先恢复原模型,再走现状 failed 记账', async () => {
608
+ // 首次 reject 触发同步抛错路径;后续 resolve(CL7 现状重试语义:failed 不推进
609
+ // nextRunAt,下个到期 tick 重试 dispatch)
610
+ backend.sendMessage = vi.fn().mockRejectedValueOnce(new Error('session closed')).mockResolvedValue(undefined)
611
+ task = await addModelTask('job', TASK_M)
612
+ const ok = await runtime.dispatchTask(task)
613
+ expect(ok).toBe(false)
614
+ // 切换 + catch 中恢复(异常路径不把会话留在任务模型)
615
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
616
+ expect(task.lastStatus).toBe('failed')
617
+
618
+ // 重试到期:记录已清(否则互斥挡住、TASK_M 不会再次出现),dispatch 照常重试。
619
+ // 重试成功的证据 = 第三次 setModel(TASK_M)(切换分支重新执行)+ lastStatus 翻回 success
620
+ //(onDispatchSuccess 记账;vi.fn 覆盖实例 sendMessage 后 sentMessages 不再记录,不以此断言)
621
+ task.nextRunAt = 0
622
+ runtime.startScheduler()
623
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
624
+ expect(ops.setModelCalls).toEqual([TASK_M, ORIG, TASK_M])
625
+ expect(task.lastStatus).toBe('success')
626
+ })
627
+
628
+ it('modelOps 缺省(旧装配形态)→ task.model 非空照常 dispatch,不抛错', async () => {
629
+ const plainBackend = new MockSchedulerBackend()
630
+ const plainRuntime = new SchedulerRuntime(plainBackend)
631
+ const plainTask = await plainRuntime.addTask('job', { mode: 'interval', intervalMs: 60_000 }, { model: TASK_M })
632
+ const ok = await plainRuntime.dispatchTask(plainTask)
633
+ expect(ok).toBe(true)
634
+ expect(plainBackend.sentMessages).toHaveLength(1)
635
+ expect(plainTask.lastStatus).toBe('success')
636
+ })
637
+ })
638
+ })