@zhushanwen/pi-scheduler 0.9.2 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -18,7 +18,7 @@
18
18
  "pi-package"
19
19
  ],
20
20
  "devDependencies": {
21
- "@earendil-works/pi-tui": "^0.84.4",
21
+ "@earendil-works/pi-tui": "^1.0.0",
22
22
  "@vitest/coverage-v8": "^4.1.9",
23
23
  "fast-check": "^4.9.0",
24
24
  "vitest": "^4.1.8"
@@ -30,10 +30,10 @@
30
30
  "vitest.config.ts"
31
31
  ],
32
32
  "peerDependencies": {
33
- "@earendil-works/pi-ai": "^0.84.4",
34
- "@earendil-works/pi-coding-agent": "^0.84.4",
35
- "@earendil-works/pi-tui": "^0.84.4",
36
- "typebox": "*"
33
+ "@earendil-works/pi-ai": "^1.0.0",
34
+ "@earendil-works/pi-coding-agent": "^1.0.0",
35
+ "@earendil-works/pi-tui": "^1.0.0",
36
+ "typebox": "1.3.27"
37
37
  },
38
38
  "peerDependenciesMeta": {
39
39
  "@earendil-works/pi-ai": {
@@ -51,10 +51,11 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "croner": "^9.0.0",
54
- "@zhushanwen/extension-protocol": "0.16.0",
55
- "@zhushanwen/pi-ext-guards": "0.4.2",
56
- "@zhushanwen/pi-extension-logger": "0.6.1",
57
- "@zhushanwen/pi-llm-shared": "0.10.1"
54
+ "@zhushanwen/extension-protocol": "0.18.0",
55
+ "@zhushanwen/pi-ext-guards": "0.4.3",
56
+ "@zhushanwen/pi-extension-logger": "0.6.2",
57
+ "@zhushanwen/pi-llm-shared": "0.11.1",
58
+ "@zhushanwen/pi-session-path": "0.1.0"
58
59
  },
59
60
  "scripts": {
60
61
  "test": "vitest run",
@@ -1,20 +1,24 @@
1
1
  /**
2
- * U4_MODEL_SWITCH:dispatch 模型切换(设计 D3 修订版)验收
2
+ * U4_MODEL_SWITCH:dispatch 模型切换(设计 D3 修订版 + D1 归属简化 + ADR-0122
3
+ * 事实查询翻转)验收
3
4
  *
4
- * 覆盖设计 §3.3 D3 的五块机制(探针 P-MODEL 修订后语义):
5
+ * 覆盖五块机制(D1 终态:agent_settled + isIdle 复核拆 turnIndex 归属状态机——原
6
+ * turnIndex 归属:4 状态字段 + 6 事件挂点 + customType 前缀匹配):
5
7
  * - 切换: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 收敛到事件即时)
8
+ * - 恢复:判定锚点 = isIdle() 同步事实查询(判定层级最高);agent_settled 确定性事件是
9
+ * 提前触发的加速器——事件到达即查询并兑现;非 idle(settled 与用户新 run 交错)不切
10
+ * (记录保留),tick 的 isIdle() 查询在 idle 重入时兑现;不挂 agent_end(end 后可能
11
+ * 有自动续跑 turn,恢复只认 settled)
9
12
  * - 互斥:切换在途时其他需切模型任务 skip + pending 留待下 tick 重试
10
- * - 串行化(MF-2):turn_end 恢复在途 / 切换 setModel 在途未建记录窗口内,后继需切模型
13
+ * - 串行化(MF-2):settled 恢复在途 / 切换 setModel 在途未建记录窗口内,后继需切模型
11
14
  * 任务的 setModel 排队等前序模型 op 完成(任意两个 setModel 不并发、双记录不叠写)
12
- * - 对账兜底:严格先于同 tick dispatch 循环;在途标记 2 tick 过期强制开放;未决记录守卫
13
- * (无记录时模型漂移 = 用户自主行为不动作)
15
+ * - tick 事实查询兜正确性(原 2-tick 计数对账已按 ADR-0122 删除):严格先于同 tick
16
+ * dispatch 循环;记录存在 + idle 即恢复(agent_settled 丢失时由查询收敛——拉保证
17
+ * 正确、事件只优化延迟);未决记录守卫(无记录时模型漂移 = 用户自主行为不动作)
14
18
  * - 降级与接管副作用:setModel false 不阻塞 dispatch;sendMessage 抛错 catch 先恢复;
15
19
  * 模型语义异常只走日志(不进持久化词表)
16
20
  *
17
- * 事件注入:直接调用 runtime 的 handle* 事件入口(index.ts 的 pi.on 只做转发)。
21
+ * 事件注入:直接调用 runtime 的 handleRunSettled(index.ts 的 pi.on 只做转发)。
18
22
  * tick:fake timers 驱动真实 interval(对齐 runtime.test.ts F2 形态)。
19
23
  */
20
24
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -30,10 +34,8 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
30
34
  }))
31
35
 
32
36
  import { MockSchedulerBackend } from './mock-backend.js'
33
- import { SchedulerRuntime, type SchedulerModelOps } from '../runtime.js'
37
+ import { TICK_INTERVAL_MS, SchedulerRuntime, type SchedulerModelOps } from '../runtime.js'
34
38
  import type { ScheduledTask } from '../types.js'
35
-
36
- const TICK_INTERVAL_MS = 30_000
37
39
  const ORIG = 'prov-a/model-1'
38
40
  const TASK_M = 'prov-b/model-2'
39
41
  const OTHER_M = 'prov-c/model-3'
@@ -60,7 +62,7 @@ class ModelOpsMock implements SchedulerModelOps {
60
62
  }
61
63
  }
62
64
 
63
- /** 纯 microtask 冲刷:handleTurnEnd 的恢复是 fire-and-forget async,断言前等待其 await 链完成 */
65
+ /** 纯 microtask 冲刷:handleRunSettled 的恢复是 fire-and-forget async,断言前等待其 await 链完成 */
64
66
  async function flushAsync(): Promise<void> {
65
67
  for (let i = 0; i < 5; i++) await Promise.resolve()
66
68
  }
@@ -71,11 +73,6 @@ async function flushAsyncRounds(rounds: number): Promise<void> {
71
73
  for (let i = 0; i < rounds; i++) await Promise.resolve()
72
74
  }
73
75
 
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
76
  describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
80
77
  let backend: MockSchedulerBackend
81
78
  let ops: ModelOpsMock
@@ -102,27 +99,20 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
102
99
  return runtime.addTask(prompt, { mode: 'interval', intervalMs: 600_000 }, { model })
103
100
  }
104
101
 
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 () => {
102
+ describe('切换与 settled 恢复', () => {
103
+ it('task.model ≠ 当前 → setModel(目标);agent_settled + idle 即恢复(原),重复 settled 不二次恢复', async () => {
115
104
  task = await addModelTask('job', TASK_M)
116
105
  const ok = await runtime.dispatchTask(task)
117
106
  expect(ok).toBe(true)
118
107
  expect(ops.setModelCalls).toEqual([TASK_M])
119
108
 
120
- injectDispatchTurn(0)
109
+ // run 完全落定(agent_settled)+ idle:唯一事件通道立即兑现,无需推进 tick
110
+ runtime.handleRunSettled()
121
111
  await flushAsync()
122
112
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
123
113
 
124
- // 记录已清:重复 turn_end 不二次恢复
125
- runtime.handleTurnEnd(0)
114
+ // 记录已清:重复 settled 不二次恢复
115
+ runtime.handleRunSettled()
126
116
  await flushAsync()
127
117
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
128
118
  })
@@ -133,158 +123,43 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
133
123
  expect(ok).toBe(true)
134
124
  expect(ops.setModelCalls).toEqual([])
135
125
 
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
126
  runtime.handleRunSettled()
172
127
  await flushAsync()
173
- expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
128
+ expect(ops.setModelCalls).toEqual([])
174
129
  })
175
130
 
176
- it('agent_settled 与用户新 run 交错(非 idle)→ 不动作,tick 兜底仍兑现', async () => {
131
+ it('settled 非 idle(与用户新 run 交错)→ 不切(记录保留),tick 重入 idle 兑现', async () => {
177
132
  task = await addModelTask('job', TASK_M)
178
133
  await runtime.dispatchTask(task)
179
-
180
- ops.idle = false
181
- injectDispatchTurn(0)
182
- await flushAsync()
183
134
  expect(ops.setModelCalls).toEqual([TASK_M])
184
135
 
185
136
  // settled 事件处理时用户已开始新 run(isIdle false):不能切(会把新 run 模型换掉)
137
+ ops.idle = false
186
138
  runtime.handleRunSettled()
187
139
  await flushAsync()
188
140
  expect(ops.setModelCalls).toEqual([TASK_M])
189
141
 
190
- // 兜底不变:后续 tick 重入 idle 仍恢复
142
+ // 兜底不变:后续 tick 的 isIdle() 查询在 idle 重入时恢复(记录无时间窗过期)
191
143
  ops.idle = true
192
144
  runtime.startScheduler()
193
145
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
194
146
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
195
147
  })
196
148
 
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 () => {
149
+ it('恢复 setModel(原) false → restore-failed 终态:warn 日志 + 清标记 + 后续查询不再动作', async () => {
275
150
  task = await addModelTask('job', TASK_M)
276
151
  await runtime.dispatchTask(task)
277
152
  expect(ops.setModelCalls).toEqual([TASK_M])
278
153
 
279
154
  // 模拟恢复期 setModel 失败(无可用 key 等)
280
155
  ops.setModelResult = false
281
- injectDispatchTurn(0)
156
+ runtime.handleRunSettled()
282
157
  await flushAsync()
283
158
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
284
159
  const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
285
160
  expect(warnText).toContain('restore-failed')
286
161
 
287
- // 标记已清:后续对账窗口不重复动作
162
+ // 标记已清:后续 tick 的 isIdle() 查询不重复动作
288
163
  ops.setModelResult = true
289
164
  runtime.startScheduler()
290
165
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 3)
@@ -296,7 +171,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
296
171
  await runtime.dispatchTask(task)
297
172
  ops.currentRef = ORIG // 用户手动切回
298
173
 
299
- injectDispatchTurn(0)
174
+ runtime.handleRunSettled()
300
175
  await flushAsync()
301
176
  expect(ops.setModelCalls).toEqual([TASK_M]) // 无恢复调用
302
177
  })
@@ -306,8 +181,10 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
306
181
  it('切换在途时第二个需切模型任务 skip + pending 保留 + A 结算后重试成功', async () => {
307
182
  const taskA = await addModelTask('job-a', TASK_M)
308
183
  await runtime.dispatchTask(taskA)
184
+ ops.idle = false // A 的 run 在途(isIdle 事实查询非 idle):互斥窗口成立的前提
309
185
 
310
186
  // B 到期走 tick 路径:step2 标 pending → step3 dispatch 命中互斥 skip
187
+ //(tick 开头的事实查询因非 idle 不动作,记录保留 → 互斥命中)
311
188
  const taskB = await addModelTask('job-b', OTHER_M)
312
189
  taskB.nextRunAt = 0
313
190
  runtime.startScheduler()
@@ -324,8 +201,9 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
324
201
  expect(okC).toBe(true)
325
202
  expect(backend.sentMessages).toHaveLength(2)
326
203
 
327
- // A 结算(turn_end + idle 恢复)后 B 重试成功
328
- injectDispatchTurn(0)
204
+ // A 结算(run 落定后会话空闲 → settled + idle 恢复)后 B 重试成功
205
+ ops.idle = true
206
+ runtime.handleRunSettled()
329
207
  await flushAsync()
330
208
  const okRetry = await runtime.dispatchTask(taskB)
331
209
  expect(okRetry).toBe(true)
@@ -333,23 +211,22 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
333
211
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M])
334
212
  })
335
213
 
336
- it('互斥强制结算的恢复与后续切换串行:恢复(原)完成前新切换目标不得写入(无交错)', async () => {
337
- // 场景(runTaskNow 直连路径):互斥命中触发 mutex-forced 强制结算恢复;若恢复
214
+ it('互斥结算的恢复与后续切换串行:恢复(原)完成前新切换目标不得写入(无交错)', async () => {
215
+ // 场景(runTaskNow 直连路径):互斥命中触发事实查询结算恢复;若恢复
338
216
  // fire-and-forget,恢复先同步清记录再 await setModel(原),紧随的需切模型任务 B 的
339
217
  // setModel(目标) 与之并发、完成顺序不定(恢复后完成则 B 的 turn 用错模型)。修复后
340
- // 互斥分支 await 强制结算——锁定「恢复(原)记账并完成前,新切换不得发起」的顺序契约。
218
+ // 互斥分支 await 结算——锁定「恢复(原)记账并完成前,新切换不得发起」的顺序契约。
341
219
  //
342
- // 状态构造说明:公开行为下 reconcile 在 ticksOpen 递增过窗口的同一 tick 内即结算
343
- //(idle 恢复 / 非 idle 转推迟),互斥分支命中的「in-flight 过窗口残留」是防御分支——
344
- // 此处直接注入该内部状态(extensions 测试目录豁免 unsafe-cast 规则,ask-user 等先例)。
220
+ // 状态构造说明:公开行为下「记录存在 + idle」即结算(ADR-0122 事实查询,无窗口
221
+ // 计数),互斥分支命中的未决记录直接可用——此处只断言记录存在
222
+ //(extensions 测试目录豁免 unsafe-cast 规则,ask-user 等先例)。
345
223
  const taskA = await addModelTask('job-a', TASK_M)
346
224
  await runtime.dispatchTask(taskA)
347
225
  expect(ops.setModelCalls).toEqual([TASK_M])
348
226
 
349
- // A 的事件全部丢失;把在途标记推过对账窗口(> MODEL_SWITCH_RECONCILE_TICKS = 2)
350
- const internal = runtime as unknown as { pendingModelSwitch: { ticksOpen: number } | null }
227
+ // A 的事件丢失(无 settled 注入);未决记录直接可结算
228
+ const internal = runtime as unknown as { pendingModelSwitch: unknown | null }
351
229
  expect(internal.pendingModelSwitch).not.toBeNull()
352
- internal.pendingModelSwitch!.ticksOpen = 3
353
230
 
354
231
  // 恢复(ORIG) 的 setModel 在「已记账未完成」态挂起(模拟真实 RPC 在途窗口)
355
232
  let releaseRestore: (() => void) | undefined
@@ -360,7 +237,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
360
237
  vi.spyOn(ops, 'setModelByRef').mockImplementation(async (ref: string) => {
361
238
  ops.setModelCalls.push(ref)
362
239
  if (ops.setModelResult) ops.currentRef = ref
363
- if (gatedCalls++ === 0) await restoreInFlight // 首个调用 = mutex-forced 恢复
240
+ if (gatedCalls++ === 0) await restoreInFlight // 首个调用 = mutex-settle 事实查询恢复
364
241
  return ops.setModelResult
365
242
  })
366
243
 
@@ -460,7 +337,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
460
337
  expect(internal.pendingModelSwitch!.targetModelRef).toBe(TASK_M)
461
338
 
462
339
  // A 结算后 B 重试成功(系统收敛,目标模型生效)
463
- injectDispatchTurn(0)
340
+ runtime.handleRunSettled()
464
341
  await flushAsync()
465
342
  const okRetry = await runtime.dispatchTask(taskB)
466
343
  expect(okRetry).toBe(true)
@@ -468,7 +345,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
468
345
  expect(ops.currentRef).toBe(OTHER_M)
469
346
  })
470
347
 
471
- it('同 tick 双任务顺序:A 恢复过期强制开放 + B 到期切换 → B 生效不被 A 回滚', async () => {
348
+ it('同 tick 双任务顺序:A 结算恢复 + B 到期切换 → B 生效不被 A 回滚', async () => {
472
349
  // A dispatch(记录在途)后事件全部丢失
473
350
  const taskA = await addModelTask('job-a', TASK_M)
474
351
  await runtime.dispatchTask(taskA)
@@ -476,16 +353,9 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
476
353
  taskB.nextRunAt = 0 // 到期
477
354
 
478
355
  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)
356
+ // 首 tick:结算先于 dispatch 循环(顺序约束)——记录存在 + idle 即恢复 A(isIdle
357
+ // 事实查询,无窗口计数),B 的 setModel 在恢复完成后执行,B 生效且不被 A 回滚
358
+ //(若交错则最后写入是 ORIG)
489
359
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
490
360
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M])
491
361
  expect(ops.currentRef).toBe(OTHER_M)
@@ -493,15 +363,15 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
493
363
  expect(taskB.lastStatus).toBe('success')
494
364
 
495
365
  // B 的恢复链路随后正常闭环
496
- injectDispatchTurn(0)
366
+ runtime.handleRunSettled()
497
367
  await flushAsync()
498
368
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M, ORIG])
499
369
  })
500
370
  })
501
371
 
502
372
  describe('模型 op 串行化(MF-2 回归)', () => {
503
- it('turn_end 恢复在途时新需切模型任务 dispatch → 新 setModel 排队等恢复完成(先恢复后切换)', async () => {
504
- // MF-2 回归:handleTurnEnd 的恢复是 fire-and-forget(先同步清记录再 await setModel(原))。
373
+ it('settled 恢复在途时新需切模型任务 dispatch → 新 setModel 排队等恢复完成(先恢复后切换)', async () => {
374
+ // MF-2 回归:handleRunSettled 的恢复是 fire-and-forget(先同步清记录再 await setModel(原))。
505
375
  // 修复前恢复在途窗口内 pendingModelSwitch 已为 null → 新 dispatch 的互斥检查放行,
506
376
  // setModel(目标) 与在途 setModel(原) 并发、完成顺序不定(恢复后完成则该任务 turn
507
377
  // 静默跑错模型)。修复后恢复持模型 op 队列,后继 setModel 排队等其完成。
@@ -519,12 +389,12 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
519
389
  vi.spyOn(ops, 'setModelByRef').mockImplementation(async (ref: string) => {
520
390
  ops.setModelCalls.push(ref)
521
391
  if (ops.setModelResult) ops.currentRef = ref
522
- if (ref === ORIG) await restoreGate // turn_end 恢复挂起在「已发起未完成」态
392
+ if (ref === ORIG) await restoreGate // settled 恢复挂起在「已发起未完成」态
523
393
  return ops.setModelResult
524
394
  })
525
395
 
526
- // A 的 turn 正常结束且 idle → fire-and-forget 恢复启动并挂起
527
- injectDispatchTurn(0)
396
+ // A 的 run 完全落定且 idle → fire-and-forget 恢复启动并挂起
397
+ runtime.handleRunSettled()
528
398
  await flushAsync()
529
399
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
530
400
  // 记录已同步清(先关后恢复不变)——修复前正是这个窗口放行了并发 setModel
@@ -558,28 +428,24 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
558
428
  expect(backend.sentMessages).toHaveLength(2)
559
429
 
560
430
  // B 的恢复链路随后正常闭环(终态回 ORIG)
561
- injectDispatchTurn(0)
431
+ runtime.handleRunSettled()
562
432
  await flushAsync()
563
433
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M, ORIG])
564
434
  })
565
435
  })
566
436
 
567
- describe('对账兜底', () => {
568
- it('在途标记超过 2 tick 未关闭 → 强制开放并对账恢复(事件丢失路径)', async () => {
437
+ describe('tick 事实查询兜正确性(事件丢失路径)', () => {
438
+ it('agent_settled 丢失:记录存在 + idle → 下个 tick 的 isIdle() 查询恢复', async () => {
569
439
  task = await addModelTask('job', TASK_M)
570
440
  await runtime.dispatchTask(task)
571
- // 无任何事件注入(恢复回调永不执行)
441
+ // 无任何事件注入(settled 恢复通道不触发)
572
442
 
573
443
  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)→ 恢复
444
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // 首 tick:isIdle() 事实查询 → 恢复
579
445
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
580
446
  })
581
447
 
582
- it('无未决记录时模型漂移 = 用户自主行为,对账不动作', async () => {
448
+ it('无未决记录时模型漂移 = 用户自主行为,查询不动作', async () => {
583
449
  ops.currentRef = 'prov-x/model-y' // 无 dispatch,用户手动改模型
584
450
  runtime.startScheduler()
585
451
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 5)
@@ -598,8 +464,8 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
598
464
  const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
599
465
  expect(warnText).toContain('model switch failed')
600
466
 
601
- // 无未决记录:事件归属不建立、turn_end 无恢复
602
- injectDispatchTurn(0)
467
+ // 无未决记录:settled 不动作(切换失败 = 无恢复对象)
468
+ runtime.handleRunSettled()
603
469
  await flushAsync()
604
470
  expect(ops.setModelCalls).toEqual([TASK_M])
605
471
  })