@zhushanwen/pi-scheduler 0.9.3 → 0.10.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.9.3",
3
+ "version": "0.10.1",
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,10 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "croner": "^9.0.0",
54
- "@zhushanwen/extension-protocol": "0.17.0",
55
- "@zhushanwen/pi-ext-guards": "0.4.2",
56
- "@zhushanwen/pi-extension-logger": "0.6.1",
57
- "@zhushanwen/pi-llm-shared": "0.11.0",
54
+ "@zhushanwen/extension-protocol": "0.19.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
58
  "@zhushanwen/pi-session-path": "0.1.0"
59
59
  },
60
60
  "scripts": {
@@ -1,18 +1,20 @@
1
1
  /**
2
- * U4_MODEL_SWITCH:dispatch 模型切换(设计 D3 修订版 + D1 归属简化)验收
2
+ * U4_MODEL_SWITCH:dispatch 模型切换(设计 D3 修订版 + D1 归属简化 + ADR-0122
3
+ * 事实查询翻转)验收
3
4
  *
4
- * 覆盖设计 §3.3 D-B4-4 D1 的五块机制(D1 终态:agent_settled + isIdle 复核拆 turnIndex
5
- * 归属状态机——原 turnIndex 归属:4 状态字段 + 6 事件挂点 + customType 前缀匹配):
5
+ * 覆盖五块机制(D1 终态:agent_settled + isIdle 复核拆 turnIndex 归属状态机——原
6
+ * turnIndex 归属:4 状态字段 + 6 事件挂点 + customType 前缀匹配):
6
7
  * - 切换:dispatchTaskInner 在 sendMessage 前 setModel(busy 亦生效——本层不做 idle 检查)
7
- * - 恢复:agent_settled(run 完全落定)+ isIdle 复核是唯一事件通道——idle 即恢复;
8
- * 非 idle(settled 与用户新 run 交错)转 awaiting-restore 推迟,tick 重入 idle 兑现;
9
- * 不挂 agent_end(end 后可能有自动续跑 turn,恢复只认 settled)
8
+ * - 恢复:判定锚点 = isIdle() 同步事实查询(判定层级最高);agent_settled 确定性事件是
9
+ * 提前触发的加速器——事件到达即查询并兑现;非 idle(settled 与用户新 run 交错)不切
10
+ * (记录保留),tick 的 isIdle() 查询在 idle 重入时兑现;不挂 agent_end(end 后可能
11
+ * 有自动续跑 turn,恢复只认 settled)
10
12
  * - 互斥:切换在途时其他需切模型任务 skip + pending 留待下 tick 重试
11
13
  * - 串行化(MF-2):settled 恢复在途 / 切换 setModel 在途未建记录窗口内,后继需切模型
12
14
  * 任务的 setModel 排队等前序模型 op 完成(任意两个 setModel 不并发、双记录不叠写)
13
- * - 对账兜底(时间平抑红线登记见 runtime.ts MODEL_SWITCH_RECONCILE_TICKS):严格先于同
14
- * tick dispatch 循环;在途标记 2 tick 过期强制开放(agent_settled 丢失的兜底路径);
15
- * 未决记录守卫(无记录时模型漂移 = 用户自主行为不动作)
15
+ * - tick 事实查询兜正确性(原 2-tick 计数对账已按 ADR-0122 删除):严格先于同 tick
16
+ * dispatch 循环;记录存在 + idle 即恢复(agent_settled 丢失时由查询收敛——拉保证
17
+ * 正确、事件只优化延迟);未决记录守卫(无记录时模型漂移 = 用户自主行为不动作)
16
18
  * - 降级与接管副作用:setModel false 不阻塞 dispatch;sendMessage 抛错 catch 先恢复;
17
19
  * 模型语义异常只走日志(不进持久化词表)
18
20
  *
@@ -126,7 +128,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
126
128
  expect(ops.setModelCalls).toEqual([])
127
129
  })
128
130
 
129
- it('settled 非 idle(与用户新 run 交错)→ 转 awaiting-restore 推迟,tick 重入 idle 兑现', async () => {
131
+ it('settled 非 idle(与用户新 run 交错)→ 不切(记录保留),tick 重入 idle 兑现', async () => {
130
132
  task = await addModelTask('job', TASK_M)
131
133
  await runtime.dispatchTask(task)
132
134
  expect(ops.setModelCalls).toEqual([TASK_M])
@@ -137,14 +139,14 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
137
139
  await flushAsync()
138
140
  expect(ops.setModelCalls).toEqual([TASK_M])
139
141
 
140
- // 兜底不变:后续 tick 重入 idle 仍恢复(awaiting-restore 无窗口过期)
142
+ // 兜底不变:后续 tick 的 isIdle() 查询在 idle 重入时恢复(记录无时间窗过期)
141
143
  ops.idle = true
142
144
  runtime.startScheduler()
143
145
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
144
146
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
145
147
  })
146
148
 
147
- it('恢复 setModel(原) false → restore-failed 终态:warn 日志 + 清标记 + 对账不再动作', async () => {
149
+ it('恢复 setModel(原) false → restore-failed 终态:warn 日志 + 清标记 + 后续查询不再动作', async () => {
148
150
  task = await addModelTask('job', TASK_M)
149
151
  await runtime.dispatchTask(task)
150
152
  expect(ops.setModelCalls).toEqual([TASK_M])
@@ -157,7 +159,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
157
159
  const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
158
160
  expect(warnText).toContain('restore-failed')
159
161
 
160
- // 标记已清:后续对账窗口不重复动作
162
+ // 标记已清:后续 tick 的 isIdle() 查询不重复动作
161
163
  ops.setModelResult = true
162
164
  runtime.startScheduler()
163
165
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 3)
@@ -179,8 +181,10 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
179
181
  it('切换在途时第二个需切模型任务 skip + pending 保留 + A 结算后重试成功', async () => {
180
182
  const taskA = await addModelTask('job-a', TASK_M)
181
183
  await runtime.dispatchTask(taskA)
184
+ ops.idle = false // A 的 run 在途(isIdle 事实查询非 idle):互斥窗口成立的前提
182
185
 
183
186
  // B 到期走 tick 路径:step2 标 pending → step3 dispatch 命中互斥 skip
187
+ //(tick 开头的事实查询因非 idle 不动作,记录保留 → 互斥命中)
184
188
  const taskB = await addModelTask('job-b', OTHER_M)
185
189
  taskB.nextRunAt = 0
186
190
  runtime.startScheduler()
@@ -197,7 +201,8 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
197
201
  expect(okC).toBe(true)
198
202
  expect(backend.sentMessages).toHaveLength(2)
199
203
 
200
- // A 结算(settled + idle 恢复)后 B 重试成功
204
+ // A 结算(run 落定后会话空闲 → settled + idle 恢复)后 B 重试成功
205
+ ops.idle = true
201
206
  runtime.handleRunSettled()
202
207
  await flushAsync()
203
208
  const okRetry = await runtime.dispatchTask(taskB)
@@ -206,23 +211,22 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
206
211
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M])
207
212
  })
208
213
 
209
- it('互斥强制结算的恢复与后续切换串行:恢复(原)完成前新切换目标不得写入(无交错)', async () => {
210
- // 场景(runTaskNow 直连路径):互斥命中触发 mutex-forced 强制结算恢复;若恢复
214
+ it('互斥结算的恢复与后续切换串行:恢复(原)完成前新切换目标不得写入(无交错)', async () => {
215
+ // 场景(runTaskNow 直连路径):互斥命中触发事实查询结算恢复;若恢复
211
216
  // fire-and-forget,恢复先同步清记录再 await setModel(原),紧随的需切模型任务 B 的
212
217
  // setModel(目标) 与之并发、完成顺序不定(恢复后完成则 B 的 turn 用错模型)。修复后
213
- // 互斥分支 await 强制结算——锁定「恢复(原)记账并完成前,新切换不得发起」的顺序契约。
218
+ // 互斥分支 await 结算——锁定「恢复(原)记账并完成前,新切换不得发起」的顺序契约。
214
219
  //
215
- // 状态构造说明:公开行为下 reconcile 在 ticksOpen 递增过窗口的同一 tick 内即结算
216
- //(idle 恢复 / 非 idle 转推迟),互斥分支命中的「in-flight 过窗口残留」是防御分支——
217
- // 此处直接注入该内部状态(extensions 测试目录豁免 unsafe-cast 规则,ask-user 等先例)。
220
+ // 状态构造说明:公开行为下「记录存在 + idle」即结算(ADR-0122 事实查询,无窗口
221
+ // 计数),互斥分支命中的未决记录直接可用——此处只断言记录存在
222
+ //(extensions 测试目录豁免 unsafe-cast 规则,ask-user 等先例)。
218
223
  const taskA = await addModelTask('job-a', TASK_M)
219
224
  await runtime.dispatchTask(taskA)
220
225
  expect(ops.setModelCalls).toEqual([TASK_M])
221
226
 
222
- // A 的事件全部丢失;把在途标记推过对账窗口(> MODEL_SWITCH_RECONCILE_TICKS = 2)
223
- const internal = runtime as unknown as { pendingModelSwitch: { ticksOpen: number } | null }
227
+ // A 的事件丢失(无 settled 注入);未决记录直接可结算
228
+ const internal = runtime as unknown as { pendingModelSwitch: unknown | null }
224
229
  expect(internal.pendingModelSwitch).not.toBeNull()
225
- internal.pendingModelSwitch!.ticksOpen = 3
226
230
 
227
231
  // 恢复(ORIG) 的 setModel 在「已记账未完成」态挂起(模拟真实 RPC 在途窗口)
228
232
  let releaseRestore: (() => void) | undefined
@@ -233,7 +237,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
233
237
  vi.spyOn(ops, 'setModelByRef').mockImplementation(async (ref: string) => {
234
238
  ops.setModelCalls.push(ref)
235
239
  if (ops.setModelResult) ops.currentRef = ref
236
- if (gatedCalls++ === 0) await restoreInFlight // 首个调用 = mutex-forced 恢复
240
+ if (gatedCalls++ === 0) await restoreInFlight // 首个调用 = mutex-settle 事实查询恢复
237
241
  return ops.setModelResult
238
242
  })
239
243
 
@@ -341,7 +345,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
341
345
  expect(ops.currentRef).toBe(OTHER_M)
342
346
  })
343
347
 
344
- it('同 tick 双任务顺序:A 恢复过期强制开放 + B 到期切换 → B 生效不被 A 回滚', async () => {
348
+ it('同 tick 双任务顺序:A 结算恢复 + B 到期切换 → B 生效不被 A 回滚', async () => {
345
349
  // A dispatch(记录在途)后事件全部丢失
346
350
  const taskA = await addModelTask('job-a', TASK_M)
347
351
  await runtime.dispatchTask(taskA)
@@ -349,16 +353,9 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
349
353
  taskB.nextRunAt = 0 // 到期
350
354
 
351
355
  runtime.startScheduler()
352
- // tick1:对账 ticksOpen=1(窗口内等事件);B pending → 互斥 skip
353
- await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
354
- expect(backend.sentMessages).toHaveLength(1)
355
- expect(taskB.pending).toBe(true)
356
- // tick2:ticksOpen=2 仍在窗口内;B 仍 skip
357
- await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
358
- expect(backend.sentMessages).toHaveLength(1)
359
- // tick3:ticksOpen=3 > 2 → A 强制开放(idle 即恢复)→ dispatch 循环 B 切换成功。
360
- // 顺序约束(对账 await 先于 dispatch 循环):A 的恢复 setModel 完成后 B 才 setModel,
361
- // B 生效且不被 A 回滚(若交错则最后写入是 ORIG)
356
+ // 首 tick:结算先于 dispatch 循环(顺序约束)——记录存在 + idle 即恢复 A(isIdle
357
+ // 事实查询,无窗口计数),B 的 setModel 在恢复完成后执行,B 生效且不被 A 回滚
358
+ //(若交错则最后写入是 ORIG)
362
359
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
363
360
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M])
364
361
  expect(ops.currentRef).toBe(OTHER_M)
@@ -437,22 +434,18 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
437
434
  })
438
435
  })
439
436
 
440
- describe('对账兜底', () => {
441
- it('agent_settled 丢失:在途标记超过 2 tick 未关闭 → 强制开放并对账恢复(事件丢失路径)', async () => {
437
+ describe('tick 事实查询兜正确性(事件丢失路径)', () => {
438
+ it('agent_settled 丢失:记录存在 + idle → 下个 tick 的 isIdle() 查询恢复', async () => {
442
439
  task = await addModelTask('job', TASK_M)
443
440
  await runtime.dispatchTask(task)
444
441
  // 无任何事件注入(settled 恢复通道不触发)
445
442
 
446
443
  runtime.startScheduler()
447
- await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1: ticksOpen=1
448
- await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick2: ticksOpen=2,窗口内
449
- expect(ops.setModelCalls).toEqual([TASK_M])
450
-
451
- await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick3: >2 → 强制开放(idle)→ 恢复
444
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // 首 tick:isIdle() 事实查询 → 恢复
452
445
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
453
446
  })
454
447
 
455
- it('无未决记录时模型漂移 = 用户自主行为,对账不动作', async () => {
448
+ it('无未决记录时模型漂移 = 用户自主行为,查询不动作', async () => {
456
449
  ops.currentRef = 'prov-x/model-y' // 无 dispatch,用户手动改模型
457
450
  runtime.startScheduler()
458
451
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 5)
@@ -47,13 +47,15 @@ vi.mock('../widget.js', async (importOriginal) => {
47
47
  })
48
48
 
49
49
  // vi.mock factory 会被提升,跨模块共享状态必须经 vi.hoisted。
50
- const { isCtxStaleCaptures, runtimeInstances } = vi.hoisted(() => ({
50
+ const { isCtxStaleCaptures, runtimeInstances, stopSchedulerCalls } = vi.hoisted(() => ({
51
51
  isCtxStaleCaptures: [] as Array<(() => boolean) | undefined>,
52
52
  runtimeInstances: [] as Array<{ stopScheduler(): void }>,
53
+ stopSchedulerCalls: [] as Array<{ stopScheduler(): void }>,
53
54
  }))
54
55
 
55
56
  // InstrumentedRuntime 继承真实实现(loadTasks/onAfterTick/startScheduler 均真实执行),
56
- // 仅捕获构造参(isCtxStale)并登记实例(afterEach 统一停 timer,避免真实 setInterval 残留)。
57
+ // 仅捕获构造参(isCtxStale)并登记实例(afterEach 统一停 timer,避免真实 setInterval 残留);
58
+ // stopScheduler 调用按序记录(F1 验收断言面:新装配前旧实例必须被停)。
57
59
  vi.mock('../runtime.js', async (importOriginal) => {
58
60
  const actual = await importOriginal<typeof import('../runtime.js')>()
59
61
  const RealSchedulerRuntime = actual.SchedulerRuntime
@@ -66,6 +68,10 @@ vi.mock('../runtime.js', async (importOriginal) => {
66
68
  isCtxStaleCaptures.push(isCtxStale)
67
69
  runtimeInstances.push(this)
68
70
  }
71
+ override stopScheduler(): void {
72
+ stopSchedulerCalls.push(this)
73
+ super.stopScheduler()
74
+ }
69
75
  }
70
76
  return { ...actual, SchedulerRuntime: InstrumentedRuntime }
71
77
  })
@@ -73,8 +79,6 @@ vi.mock('../runtime.js', async (importOriginal) => {
73
79
  import schedulerExtension from '../index.js'
74
80
  import { TICK_INTERVAL_MS } from '../runtime.js'
75
81
  import { TASK_ENTRY_TYPE } from '../types.js'
76
- /** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(常量不导出,测试锚定同值)。 */
77
- const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
78
82
 
79
83
  /**
80
84
  * 最小 fake pi:覆盖 factory 消费的 API 面
@@ -206,12 +210,9 @@ describe('G1: index.ts 代际接线(S9)', () => {
206
210
  // 下一 tick 的 refreshWidget 访问 stale ctx.ui 抛错 → unhandledRejection → pi 主进程 exit 1。
207
211
  // F1 在 session_start 开头幂等 stopScheduler,从源头消灭。
208
212
  //
209
- // 行为断言口径(验收 U4):观测面 = refreshWidget 的推送调用序列(本文件 mock 环境下
210
- // refreshWidget 走 setSchedulerWidgetMock,与真实 ctx.ui.setWidget 同一调用序列)。widget
211
- // 推送修正(D1 指纹跳推 + D2 保活底线帧)后每次 tick 不再必然推帧——任务集静态期间零推送、
212
- // 空任务集恒零推送,故注入非空任务集、以「跨 10min 保活窗口必推一帧」作为「该 runtime 的
213
- // timer 是否还在 tick」的行为观测面(保活帧间隔与 index.ts WIDGET_KEEPALIVE_INTERVAL_MS
214
- // 同值 10min)。F1 缺失时旧 timer 跨窗口必多推一帧,即被捕获。
213
+ // 行为断言口径(验收 U4):观测面 = InstrumentedRuntime 的 stopScheduler 调用序列
214
+ // (widget 推送是 D1 指纹跳推语义——任务集静态期间零推送,不能靠推送计数区分新旧 timer;
215
+ // stopScheduler 白盒断言直接锁定「新装配前旧实例必须被停」的 F1 语义本身)。
215
216
  describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
216
217
  const BASE = Date.parse('2026-01-01T00:00:00Z')
217
218
 
@@ -243,7 +244,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
243
244
  }
244
245
  }
245
246
 
246
- /** 同 createFakeCtx,但注入非空任务集(保活帧只在非空任务集时推送,见上方口径注释)。 */
247
+ /** 同 createFakeCtx,但注入非空任务集(首帧渲染需要非空任务集可断言)。 */
247
248
  function createFakeCtxWithTasks(sessionFile: string): ExtensionContext {
248
249
  return {
249
250
  cwd: '/test-index-session-start',
@@ -260,6 +261,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
260
261
  beforeEach(() => {
261
262
  isCtxStaleCaptures.length = 0
262
263
  runtimeInstances.length = 0
264
+ stopSchedulerCalls.length = 0
263
265
  setSchedulerWidgetMock.mockClear()
264
266
  vi.useFakeTimers()
265
267
  vi.setSystemTime(new Date(BASE))
@@ -272,7 +274,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
272
274
  vi.useRealTimers()
273
275
  })
274
276
 
275
- it('U4: 双 session_start 后旧 timer 已停——跨保活窗口只有新 runtime 推帧', async () => {
277
+ it('U4: 双 session_start 后旧 timer 已停(stopScheduler 恰命中首个实例)', async () => {
276
278
  const { pi, events } = createMockPi()
277
279
  schedulerExtension(pi)
278
280
  const sessionStart = events.get('session_start')
@@ -281,19 +283,19 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
281
283
  // 第一次 session_start:runtime1 + timer1 启动,初始渲染 1 次
282
284
  sessionStart!({ type: 'session_start', reason: 'startup' }, createFakeCtxWithTasks('/test/session-1.json'))
283
285
  expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(1)
286
+ expect(runtimeInstances).toHaveLength(1)
284
287
 
285
- // 前置因果锚点:静置越过保活窗口,timer1 推一帧保活帧(排除「timer 从未启动」的假绿;
286
- // 也锁定跳推行为——窗口内其余 tick 零推送)
287
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
288
- expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2)
288
+ // 前置因果锚点:静置数个 tick——timer1 在 tick(无异常),任务集静态零推送(D1 跳推)
289
+ await vi.advanceTimersByTimeAsync(2 * TICK_INTERVAL_MS)
290
+ expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(1)
289
291
 
290
292
  // 第二次 session_start(session 替换):F1 在装配新 runtime 前停掉 timer1
291
293
  sessionStart!({ type: 'session_start', reason: 'new_session' }, createFakeCtxWithTasks('/test/session-2.json'))
292
- expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(3) // runtime2 初始渲染(实例态重置,首帧必推)
294
+ expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2) // runtime2 初始渲染(实例态重置,首帧必推)
295
+ expect(runtimeInstances).toHaveLength(2)
293
296
 
294
- // 行为断言(验收口径):再静置一个保活窗口,只有 runtime2 的 timer 触发推送——恰 +1;
295
- // F1 缺失时 timer1/timer2 都活着,此处为 +2
296
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
297
- expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(4)
297
+ // 行为断言(验收口径):stopScheduler 恰被调一次且命中 runtime1;
298
+ // F1 缺失时旧 timer 不被停(数组为空或不含 runtime1)
299
+ expect(stopSchedulerCalls).toEqual([runtimeInstances[0]])
298
300
  })
299
301
  })
@@ -1,18 +1,18 @@
1
1
  // src/__tests__/widget-push.test.ts
2
2
  //
3
- // scheduler widget 推送修正(D1 指纹跳推 + D2 保活底线帧)集成单测:
3
+ // scheduler widget 推送(D1 指纹跳推)集成单测:
4
4
  // 走 index.ts 真实装配链(session_start → runtime.onAfterTick → refreshWidget),
5
5
  // 观测面 = ctx.ui.setWidget 调用序列(每个实际推送恰一次调用——跳推判定在
6
6
  // setSchedulerWidget 之前,setWidgetDual 的 TUI 分支与清屏分支都落 setWidget)。
7
7
  //
8
- // 行为契约(scheduler widget 推送修正设计 D1-b/D2):
9
- // - 任务集指纹不变 → 跳推(时间流逝不是状态变化,静态期零推送——G1)
10
- // - 保活底线双条件:任务集非空(含 disabled——任务存在即调度意图)且距上次实际推送
11
- // ≥10min → 强制推一帧;空任务集不发保活帧(清屏后零帧,会话可被 idle reaper 正常回收——G3)
8
+ // 行为契约(scheduler widget 推送修正设计 D1-b):
9
+ // - 任务集指纹不变 → 跳推(时间流逝不是状态变化,静态期零推送——G1;跨原保活窗口
10
+ // 时长仍零推送,锁定原 D2 保活底线帧已随 idle reaper 退役不回归)
11
+ // - 空任务集同样静态期零推送(清屏帧只推一次)
12
12
  // - fail-open:指纹计算异常即推送(宁可多推不可漏显)+ warn 留痕
13
13
  // - locale 入指纹:切语言一次推送自愈(集成臂;kind 字段在 widget.test.ts 纯函数臂锁定)
14
14
  //
15
- // timer 红线:fake timers(vitest useFakeTimers),保活窗口用大步进 advance(fake 无墙钟成本)。
15
+ // timer 红线:fake timers(vitest useFakeTimers),静置用大步进 advance(fake 无墙钟成本)。
16
16
 
17
17
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
18
18
  import { tmpdir } from 'node:os'
@@ -37,8 +37,8 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
37
37
  import schedulerExtension from '../index.js'
38
38
  import { TICK_INTERVAL_MS } from '../runtime.js'
39
39
  import { TASK_ENTRY_TYPE } from '../types.js'
40
- /** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(10min;常量不导出,测试锚定同值)。 */
41
- const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
40
+ /** 原保活窗口时长(10min;原 D2 已退役)——静态用例静置越过后断言仍零推送。 */
41
+ const RETIRED_KEEPALIVE_WINDOW_MS = 10 * 60 * 1000
42
42
  const BASE = Date.parse('2026-01-01T00:00:00Z')
43
43
 
44
44
  /** 构造一个远期到期的启用任务 upsert entry(owner = sessionFile,避免 fork owner 过滤)。 */
@@ -98,7 +98,7 @@ function createFakeCtx(sessionFile: string, entries: unknown[] = []): {
98
98
  return { ctx, setWidget }
99
99
  }
100
100
 
101
- describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () => {
101
+ describe('widget 推送判定(D1 指纹跳推)', () => {
102
102
  beforeEach(() => {
103
103
  vi.useFakeTimers()
104
104
  vi.setSystemTime(BASE)
@@ -109,7 +109,7 @@ describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () =>
109
109
  vi.useRealTimers()
110
110
  })
111
111
 
112
- it('D1: 任务集静态期间零推送(指纹不变跳推),任务集内但有 disabled 同样静态', async () => {
112
+ it('D1: 任务集静态期间零推送——静置越过原保活窗口仍零推送(D2 退役不回归)', async () => {
113
113
  const { pi, events } = createMockPi()
114
114
  schedulerExtension(pi)
115
115
  const sessionStart = events.get('session_start')!
@@ -117,34 +117,12 @@ describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () =>
117
117
  sessionStart({ type: 'session_start', reason: 'startup' }, ctx)
118
118
  expect(setWidget).toHaveBeenCalledTimes(1) // 首帧必推(实例态重置)
119
119
 
120
- // 静置 9.5min(17 个 tick):每次 tick 指纹相同、保活未到期 → 零推送
121
- await vi.advanceTimersByTimeAsync(17 * TICK_INTERVAL_MS)
120
+ // 静置越过原保活窗口(10min + 2 个 tick):指纹相同 → 零推送(无保活底线帧)
121
+ await vi.advanceTimersByTimeAsync(RETIRED_KEEPALIVE_WINDOW_MS + 2 * TICK_INTERVAL_MS)
122
122
  expect(setWidget).toHaveBeenCalledTimes(1)
123
123
  })
124
124
 
125
- it('D2: 非空任务集跨保活窗口 → 强制推一帧,内容与首帧相同(静态面恒定)', async () => {
126
- const { pi, events } = createMockPi()
127
- schedulerExtension(pi)
128
- const sessionStart = events.get('session_start')!
129
- const { ctx, setWidget } = createFakeCtx('/test/wp-keepalive.json', [
130
- taskEntry('/test/wp-keepalive.json'),
131
- ])
132
- sessionStart({ type: 'session_start', reason: 'startup' }, ctx)
133
- expect(setWidget).toHaveBeenCalledTimes(1)
134
- const firstFrame = setWidget.mock.calls[0]![1]
135
-
136
- // 静置越过保活窗口:下一 tick(保活到期后首个 30s 节拍)强制推一帧
137
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
138
- expect(setWidget).toHaveBeenCalledTimes(2)
139
- // 保活帧纯粹为心跳:文本与首帧相同(D1 静态化后显示面不随时间变化)
140
- expect(setWidget.mock.calls[1]![1]).toEqual(firstFrame)
141
-
142
- // 第二个保活窗口同样恰好一帧(有界心跳,非恢复 30s 节拍)
143
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
144
- expect(setWidget).toHaveBeenCalledTimes(3)
145
- })
146
-
147
- it('D2: 空任务集不发保活帧(清屏后零帧,会话保持可回收)', async () => {
125
+ it('D1: 空任务集静态期零推送(清屏帧只推一次)', async () => {
148
126
  const { pi, events } = createMockPi()
149
127
  schedulerExtension(pi)
150
128
  const sessionStart = events.get('session_start')!
@@ -153,28 +131,11 @@ describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () =>
153
131
  expect(setWidget).toHaveBeenCalledTimes(1) // 首帧 = 清屏帧(setWidget(key, undefined))
154
132
  expect(setWidget.mock.calls[0]![1]).toBeUndefined()
155
133
 
156
- // 静置 30min(远超保活窗口):无任务 → 无保活帧
134
+ // 静置 30min:空任务集指纹稳定 → 零推送
157
135
  await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
158
136
  expect(setWidget).toHaveBeenCalledTimes(1)
159
137
  })
160
138
 
161
- it('D2: 全 disabled 的任务集仍保活(任务存在即调度意图)', async () => {
162
- const { pi, events } = createMockPi()
163
- schedulerExtension(pi)
164
- const sessionStart = events.get('session_start')!
165
- const file = '/test/wp-disabled.json'
166
- const { ctx, setWidget } = createFakeCtx(file, [taskEntry(file, { enabled: false })])
167
- sessionStart({ type: 'session_start', reason: 'startup' }, ctx)
168
- expect(setWidget).toHaveBeenCalledTimes(1)
169
-
170
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
171
- expect(setWidget).toHaveBeenCalledTimes(2) // disabled 不豁免保活(re-enable 后须可执行)
172
- // 用户可见面:保活帧携带完整静态行(计数段在场——TUI 最近任务行只取 enabled 任务,
173
- // 全 disabled 时行 = 计数段,这是既有 enabled 过滤行为)
174
- const frame = String((setWidget.mock.calls[1]![1] as string[])[0] ?? '')
175
- expect(frame).toBe('[scheduler] 0 scheduled')
176
- })
177
-
178
139
  it('fail-open: 指纹计算异常 → 该 tick 照常推送 + warn 留痕', async () => {
179
140
  const { pi, events } = createMockPi()
180
141
  schedulerExtension(pi)
package/src/ack-turn.ts CHANGED
@@ -11,9 +11,9 @@
11
11
  // ① 建任务成功 ⇒ maybeStartAck:只读检查未落盘 + 空闲 + 可用,注入 custom 触发器
12
12
  // (triggerTurn);写盘兜底判定收敛在 session 边界;
13
13
  // ② 触发器的 message_start 到达(本轮首次模型请求之前——pi-agent-core
14
- // agent-loop.js:51-53 初始 prompt 逐条 emit message_start/message_end 先于
15
- // :56 runLoop → :122 streamAssistantResponse 首次模型请求,按 0.84.4 实装核实;
16
- // 直达链锚 PS-08)⇒ 同步 registerProvider 覆写当前 provider 的 streamSimple;
14
+ // agent-loop.js 初始 prompt 逐条 emit message_start/message_end 先于
15
+ // runLoop → streamAssistantResponse 首次模型请求;直达链锚 PS-08,
16
+ // verifiedWith 以 pi-semantics.json 为准)⇒ 同步 registerProvider 覆写当前 provider 的 streamSimple;
17
17
  // ③ 我们的 streamSimple 被调用 ⇒ 返回 stream 之前同步注销覆写(one-shot 自撤),
18
18
  // 本轮内被 steer/followUp drain 出的后续请求与后续轮次全部走真实 provider;
19
19
  // ④ turn_end / session 边界 ⇒ 安全网注销(幂等)。
@@ -344,9 +344,9 @@ export function createAckTurnController(deps: AckTurnDeps): AckTurnController {
344
344
  /**
345
345
  * 落盘判据①:会话内已有 assistant 消息 ⇒ pi 已 flush,任务必然已落盘。
346
346
  *
347
- * 反向推论承重锚(语义登记 docs/pi-semantics.json PS-14):pi _appendEntry 先 push 进
348
- * fileEntries 再同步 _persist(dist/core/session-manager.js:757-762),assistant 在列时
349
- * hasAssistant 命中即走同步 wx 全量补写分支(:741-752,0.84.4 无异步 flush)——getEntries
347
+ * 反向推论承重锚(语义登记 docs/pi-semantics.json PS-14,verifiedWith 以该登记为准):
348
+ * pi _appendEntry 先 push 进 fileEntries 再同步 _persist,assistant 在列时
349
+ * hasAssistant 命中即走同步 wx 全量补写分支(0.84.4 无异步 flush)——getEntries
350
350
  * 读到 assistant 的时刻磁盘必已含全部内存 entry,无异步窗口。
351
351
  *
352
352
  * entry 形状来自 replay 的 SchedulerEntryLike(无 role 字段声明),故用 `in` 收窄到
package/src/index.ts CHANGED
@@ -7,7 +7,6 @@ import { createAckTurnController, type AckTurnController } from './ack-turn.js'
7
7
  import { PiSchedulerBackend } from './backend.js'
8
8
  import { registerScheduleCommand } from './commands.js'
9
9
  import { formatSchedule } from './format.js'
10
- import { MS_PER_MINUTE } from './parsing.js'
11
10
  import { readUiLocale, t } from './i18n.js'
12
11
  import { importLegacyStore } from './importer.js'
13
12
  import { abortPendingScheduleForms } from './interaction.js'
@@ -40,26 +39,14 @@ import type { ScheduledTask } from './types.js'
40
39
  // 绑定,永不再递增 → 其 isCtxStale 恒 false。该盲区由两道既有防线覆盖:pi 在替换前 await
41
40
  // fire session_shutdown(F1 stopScheduler 主防线,teardownCurrent)+ runtime 侧
42
41
  // STALE_CTX_MARKER 文案兜底(F2 catch 分诊)。
42
+ // 绑定,永不再递增 → 其 isCtxStale 恒 false。该盲区由两道既有防线覆盖:pi 在替换前 await
43
+ // fire session_shutdown(F1 stopScheduler 主防线,teardownCurrent)+ runtime 侧
44
+ // STALE_CTX_MARKER 文案兜底(F2 catch 分诊)。
43
45
  let sessionGeneration = 0
44
46
 
45
47
  /** 包内既有 logger(ack 编排的日志面与其它模块同源)。 */
46
48
  const logger = getLogger('scheduler')
47
49
 
48
- /**
49
- * D2 保活底线帧间隔(10min):任务集静态期间每 ≥10min 强制推一帧(内容与上一帧相同,
50
- * 纯粹为维持 rpc-client 入站全帧 touch `_lastActivityAt` 的心跳),防 idle reaper
51
- * (DEFAULT_PI_RECLAIM_IDLE_MS,生产 2h,5min 一拍、严格大于判定)回收挂有定时任务的
52
- * 会话——定时任务跨空闲期不停摆(G3)。
53
- *
54
- * **方案不变量:保活间隔 ≪ idle 回收阈值,须维持 ≥3 倍余量。** 生产 10min vs 2h = 12 倍;
55
- * 保活帧实际到达受 tick(TICK_INTERVAL_MS=30s)驱动波动(+0~30s),余量须覆盖 tick 波动
56
- * 与 reaper 拍相位。未来调整本值、idle 阈值或 tick 间隔任一侧,须重验 ≥3 倍余量并重跑
57
- * C-场景(真机加速验收的加速值也须 ≥3 倍保活间隔,防零余量竞态随机误回收)。
58
- */
59
- /** 保活间隔的分钟数(语义单位,避免裸魔数;乘 MS_PER_MINUTE 得毫秒间隔)。 */
60
- const WIDGET_KEEPALIVE_MINUTES = 10
61
- const WIDGET_KEEPALIVE_INTERVAL_MS = WIDGET_KEEPALIVE_MINUTES * MS_PER_MINUTE
62
-
63
50
  /**
64
51
  * pi-scheduler extension factory。
65
52
  * 注册 schedule + schedule_control 两个 tool、/schedule command、session 事件。
@@ -89,7 +76,6 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
89
76
  // 空窗;模块级 vs 闭包级的语义差异在本包有实证(见上方 sessionGeneration 注释)。
90
77
  // 生命周期:session_start 装配新会话实例时重置(refreshWidgetState 调用点)。
91
78
  let lastWidgetFingerprint: string | null = null
92
- let lastWidgetPushedAt = 0
93
79
 
94
80
  const getService = (): SchedulerService => {
95
81
  if (!service) throw new Error('Scheduler not initialized: session not started')
@@ -184,17 +170,17 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
184
170
  })
185
171
 
186
172
  // 注册 widget(SDK setWidget 第一重载:直接传 string[])。初始渲染一次,
187
- // 后续随每次 tickScheduler 末尾的 onAfterTick 回调刷新(推送频率由指纹跳推 +
188
- // 保活底线帧判定,见 refreshWidget)。
173
+ // 后续随每次 tickScheduler 末尾的 onAfterTick 回调刷新(推送频率由指纹跳推判定,
174
+ // 见 refreshWidget)。
189
175
  // 新会话实例起点重置推送状态:首帧(含空任务清屏帧)必推,不继承前代指纹/时间戳。
190
176
  lastWidgetFingerprint = null
191
- lastWidgetPushedAt = 0
192
177
  refreshWidget(ctx)
193
178
  })
194
179
 
195
180
  // U6c:树回退(撤回 __taiji_nav__ → navigateTree / 用户树跳转)后重折叠任务集。
196
181
  // 注册在 factory 顶层而非 PiSchedulerBackend 构造函数:pi 的 on 是追加语义(loader.js
197
- // on 实现 list.push,无去重无 off,0.84.4 实装核对),且 RPC 模式下每次 session 替换
182
+ // on 实现 list.push,无去重——0.84.4 连 off 都没有,1.0.0 起返回 unsubscribe 但本处
183
+ // 不消费,handler 生命周期随 factory 代际收敛),且 RPC 模式下每次 session 替换
198
184
  // session_start 在同一代内触发两次(agent-session-runtime finishSessionReplacement
199
185
  // 内部 rebindSession 一路 + RPC handler 再 rebindSession 一路,均经 bindExtensions
200
186
  // emit session_start)——构造函数注册会同代线性累积 handler(残留旧代 backend 在旧
@@ -263,8 +249,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
263
249
  // 注册 schedule tool(触发反转:直建,不再弹确认表单——人侧表单入口在 /schedule 命令)。
264
250
  // execute 内联闭包:从 SDK 全签名 (toolCallId, params, signal, onUpdate, ctx) 提取
265
251
  // 转调 handleSchedule 直建流(预校验 → abort 检查 → service.create)。
266
- // 错误路径 throw(W4):pi 只对 execute throw 置 isError:true(返回值里的
267
- // isError 被 agent-loop 丢弃);getService() 未初始化异常穿透到这里,包装
252
+ // 错误路径 throw(W4):pi 对 execute throw 置 isError:true(agent-loop.js:581-588;
253
+ // 1.0.0 起返回值 isError:true 也被尊重,本工具恒走 throw 路径两版语义等价);
254
+ // getService() 未初始化异常穿透到这里,包装
268
255
  // 'Error: Scheduler not initialized' 格式(R3 格式保持)。
269
256
  pi.registerTool({
270
257
  name: 'schedule',
@@ -325,14 +312,11 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
325
312
  * `ctx as GuiContext`:pi 的 `ExtensionContext` 与协议包最小结构(mode/hasUI/ui.setWidget)
326
313
  * 静态不完全兼容,先例见 todo/src/index.ts makeRefreshDisplay。
327
314
  *
328
- * 推送频率判定(scheduler widget 推送修正设计 D1-b / D2):
315
+ * 推送频率判定(scheduler widget 推送修正设计 D1-b;原 D2 保活底线帧随 idle reaper
316
+ * 退役一并删除——其存在理由唯一指向 reaper 回收防御):
329
317
  * - D1 指纹跳推:任务集稳定指纹(computeTasksFingerprint,字段集含 kind/locale)与上次
330
- * 实际推送相同且保活未到期 → 跳过推送。时间流逝不是状态变化,任务集不变期间零推送
318
+ * 实际推送相同 → 跳过推送。时间流逝不是状态变化,任务集不变期间零推送
331
319
  * (rpc/tui 两模式同效——判定在 setWidgetDual 之前)。
332
- * - D2 保活底线帧:有任务(任务集非空,含全部 disabled——任务存在即调度意图,re-enable
333
- * 后须可执行)且距上次实际推送超过 WIDGET_KEEPALIVE_INTERVAL_MS → 强制推一帧(内容
334
- * 与上帧相同,纯粹为维持心跳防 idle reaper 回收)。空任务集不发保活帧:清屏后任务集
335
- * 保持空 → 无帧 → 会话按 idle 规则正常回收(保活与任务存在性绑定)。
336
320
  * - fail-open:指纹计算异常即推送(宁可多推不可漏显,设计 §3.1 失败路径)。
337
321
  */
338
322
  function refreshWidget(ctx: ExtensionContext): void {
@@ -347,22 +331,16 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
347
331
  fingerprint = computeTasksFingerprint(tasks, locale)
348
332
  } catch (err) {
349
333
  // fail-open(辅助显示面的降级 ≠ 吞错):指纹异常说明序列化路径有 bug,跳推判定不可信
350
- // → 直接推送保显示正确;缓存失效(null)使下一帧也必推,保活计时照常刷新。
334
+ // → 直接推送保显示正确;缓存失效(null)使下一帧也必推。
351
335
  logger.warn('widget fingerprint computation failed, pushing anyway', { error: toErrorMessage(err) })
352
336
  lastWidgetFingerprint = null
353
- lastWidgetPushedAt = Date.now()
354
337
  setSchedulerWidget(ctx as GuiContext, tasks)
355
338
  return
356
339
  }
357
340
 
358
- const nowMs = Date.now()
359
- // D2 双条件:任务存在(含 disabled)+ 距上次实际推送超时。跳推不刷新 lastWidgetPushedAt
360
- // ——保活计时只从「实际推送」起算。
361
- const keepaliveDue = tasks.length > 0 && nowMs - lastWidgetPushedAt >= WIDGET_KEEPALIVE_INTERVAL_MS
362
- if (fingerprint === lastWidgetFingerprint && !keepaliveDue) return
341
+ if (fingerprint === lastWidgetFingerprint) return
363
342
 
364
343
  setSchedulerWidget(ctx as GuiContext, tasks)
365
344
  lastWidgetFingerprint = fingerprint
366
- lastWidgetPushedAt = nowMs
367
345
  }
368
346
  }
package/src/runtime.ts CHANGED
@@ -23,22 +23,14 @@ const RATE_LIMIT_PER_MINUTE = 6
23
23
  export const TICK_INTERVAL_MS = 30_000
24
24
  const DEFAULT_EXPIRY_DAYS = 7
25
25
  const DEFAULT_EXPIRY_MS = DEFAULT_EXPIRY_DAYS * MS_PER_DAY // 7 days
26
- // U4 dispatch 模型切换(设计 D3 修订版 + D1 归属简化):
26
+ // U4 dispatch 模型切换(设计 D3 修订版 + D1 归属简化 + ADR-0122 事实查询翻转):
27
27
  // - dispatch 注入消息的 customType 标记前缀(sendMessage 的消息标记,供运维从 session 流
28
28
  // 识别 scheduler dispatch 注入;与 ack 触发器前缀 pi-scheduler-ack: 避让)
29
+ // - 恢复判定锚点 = isIdle() 同步事实查询(判定层级最高):agent_settled 确定性事件是
30
+ // 提前触发的加速器(事件到达即查询并兑现);每 tick 开头对未决记录做一次同步事实
31
+ // 查询兜正确性(事件丢失时由查询收敛——拉保证正确、事件只优化延迟)。原 2-tick
32
+ // 计数对账(时间窗猜测事件丢失)已按 ADR-0122 删除。
29
33
  const DISPATCH_CUSTOM_TYPE_PREFIX = 'pi-scheduler:'
30
- // - 在途标记强制开放的 tick 计数:超过本计数的 tick 数未关闭即视为恢复事件丢失
31
- // [时间平抑红线登记](30s tick 周期对账 = 用时间换一致的兜底):
32
- // 补偿根因:agent_settled 恢复事件丢失(pi 事件通道无送达保证)时,未决记录无事件通道
33
- // 复位,会话停留在任务模型上不被切回。
34
- // 量级/形态:TICK_INTERVAL_MS=30s 周期 tick 对账;in-flight 标记超本计数(2 tick ≈ 60s,
35
- // 强制开放的最坏恢复延迟 ≈ 3 tick ≈ 90s)未关闭即视为事件丢失、强制开放转移。
36
- // 恢复路径:reconcileModelSwitch 强制开放 → idle 即 restoreExpectedModel 切回期望模型 /
37
- // 非 idle 转 awaiting-restore(tick 重入 idle 兑现);恢复失败落 warn 日志(含
38
- // "manual switch back required" 恢复动作)。
39
- // 重审触发(退役条件):恢复事件闭环已由 agent_settled 单点达成(D1 归属简化);待批次 5
40
- // W2 通道可观测(事件丢失可报警)落地后,本对账强制开放分支删除(设计 §3.3 D-B4-4)。
41
- const MODEL_SWITCH_RECONCILE_TICKS = 2
42
34
 
43
35
  /**
44
36
  * 模型控制面(U4 dispatch 模型切换,依赖反转):runtime 只见 'provider/id' 字符串 ref
@@ -60,24 +52,19 @@ export interface SchedulerModelOps {
60
52
  }
61
53
 
62
54
  /**
63
- * 未决模型切换记录(模型状态机,纯内存态)。模型语义异常不进持久化词表
55
+ * 未决模型切换记录(纯内存态)。模型语义异常不进持久化词表
64
56
  * (replay 守卫对 advance.status 硬校验 'success',旁路字段会炸重放推进)。
65
57
  *
66
- * phase 生命周期(D1 归属简化后二态;恢复事件 = agent_settled 单点):
67
- * - 'in-flight':dispatch 受理后等待 run 落定(agent_settled)兑现恢复;ticksOpen 计数超过
68
- * MODEL_SWITCH_RECONCILE_TICKS → 恢复事件丢失,tick 对账强制开放
69
- * - 'awaiting-restore':settled 到达时非 idle(与用户新 run 交错)/ 对账强制开放时非 idle
70
- * 的推迟态;比对决策已在窗口内锁定,执行不受窗口过期限制——tick 重入 idle 即兑现
71
- * (不设墙钟上界)
72
- * - 恢复成功 / restore-failed(setModel(原) false)→ 记录清除(终态只留日志)
58
+ * 生命周期(ADR-0122 事实查询翻转后):dispatch 受理 → 记录创建(in-flight 等价语义:
59
+ * 等 run 落定);恢复兑现锚点 = isIdle() 同步事实查询(agent_settled 事件提前触发 +
60
+ * tick 每轮查询兜正确性)→ 恢复成功 / restore-failed(setModel(原) false)→ 记录清除
61
+ * (终态只留日志)。记录存在 + idle 即恢复,无 phase 区分、无时间窗计数。
73
62
  */
74
63
  interface PendingModelSwitch {
75
64
  taskId: string
76
65
  /** 会话期望模型(dispatch 切换前抓取的原模型 'provider/id') */
77
66
  expectedModelRef: string
78
67
  targetModelRef: string
79
- phase: 'in-flight' | 'awaiting-restore'
80
- ticksOpen: number
81
68
  }
82
69
 
83
70
  // HISTORY_LIMIT 单点在 types.ts(ext-simplify-08 L5)——与 replay.ts 的 advance 折叠共用
@@ -305,15 +292,15 @@ export class SchedulerRuntime {
305
292
  }
306
293
 
307
294
  async tickScheduler(): Promise<void> {
308
- // U4 对账兜底(设计 D3):严格先于本 tick 的 dispatch 循环。顺序约束:否则出现
309
- // 「B dispatch 先切 Y → 对账持 A 旧记录把 Y 静默切回」的交错(Y 从未生效、全序列
310
- // 无报错,G4 被绕过)。恢复执行时 await 完成,保证恢复 setModel 完成后 dispatch 循环
311
- // 才开 setModel;更广的「任意两个 setModel 不并发」由模型 op 串行队列(modelOpChain)
295
+ // U4 未决模型切换记录的事实查询结算(严格先于本 tick 的 dispatch 循环)。顺序约束:
296
+ // 否则出现「B dispatch 先切 Y → 结算持 A 旧记录把 Y 静默切回」的交错(Y 从未生效、
297
+ // 全序列无报错,G4 被绕过)。恢复执行时 await 完成,保证恢复 setModel 完成后 dispatch
298
+ // 循环才开 setModel;更广的「任意两个 setModel 不并发」由模型 op 串行队列(modelOpChain)
312
299
  // 结构保证——含 agent_settled 恢复 fire-and-forget 挂点,dispatch 模型分支排在其后。
313
- // 无未决记录的绝大多数 tick 同步返回、不引入 microtask 断点——dispatch 的同步段
314
- // 契约(in-flight 守卫在 tickScheduler() 未 await 时同步可见)保持不变。
315
- const reconciling = this.reconcileModelSwitch()
316
- if (reconciling) await reconciling
300
+ // 无未决记录(或非 idle)的绝大多数 tick 同步返回、不引入 microtask 断点——dispatch
301
+ // 的同步段契约(in-flight 守卫在 tickScheduler() 未 await 时同步可见)保持不变。
302
+ const settling = this.settlePendingIfIdle('tick-reconcile')
303
+ if (settling) await settling
317
304
 
318
305
  const now = this.backend.now()
319
306
 
@@ -392,8 +379,8 @@ export class SchedulerRuntime {
392
379
  * 按序执行「互斥校验 → setModel(task.model) → 记未决切换记录 → sendMessage」,切换段
393
380
  * 整体入模型 op 串行队列(modelOpChain——与恢复 setModel 互斥,MF-2)。busy 亦照常 setModel
394
381
  * (P-MODEL-② 证伪「busy 切换不生效」——steer 消息消费时在同一 run 内开新 turn,turn
395
- * 开始从 ctx.model 取当前模型,setModel 对 steer turn 生效。pi 实装锚点(0.84.4):
396
- * dist/core/agent-session.js:1258 setModel 同步写 agent.state.model + :304 每个 turn
382
+ * 开始从 ctx.model 取当前模型,setModel 对 steer turn 生效。pi 实装锚点(1.0.0 复核):
383
+ * dist/core/agent-session.js setModel 同步写 agent.state.model + 每个 turn
397
384
  * 准备时从 agent.state.model 取模型快照——setModel 先于下一 turn 准备即生效)。
398
385
  * 失败降级(分级,不阻塞核心
399
386
  * 调度):modelOps 缺省 / 会话无当前模型 / setModel false → 日志 + 放弃切换,照常 dispatch。
@@ -414,14 +401,14 @@ export class SchedulerRuntime {
414
401
  if (this.pendingModelSwitch) {
415
402
  // 互斥窗口(设计 D3,理由 = 切换秩序):未决记录未关闭时,其他需切模型任务命中即
416
403
  // skip 本轮 + pending 留待下 tick 重试(复用现状 skip 语义,不建真队列;不需切模型
417
- // 的任务不受互斥影响——检查只在本分支)。「dispatch 写新记录前先强制结算旧记录」
418
- // 的落地:本分支结构性永不写新记录(杜绝新旧叠写),同时把已过窗口的 in-flight
419
- // 残留先强制开放转移,保证新任务在旧记录结算后 ≤1 tick 内接管、不被过期残留无限阻塞。
420
- // 强制结算的恢复必须 await(顺序约束,与 tickScheduler 的对账 await 同构):恢复
404
+ // 的任务不受互斥影响——检查只在本分支)。「dispatch 写新记录前先结算旧记录」
405
+ // 的落地:本分支结构性永不写新记录(杜绝新旧叠写),skip 前做一次 isIdle() 事实
406
+ // 查询结算(idle 即恢复收口),保证新任务不被旧记录无限阻塞。
407
+ // 结算的恢复必须 await(顺序约束,与 tickScheduler 的结算 await 同构):恢复
421
408
  // setModel(原) 完成后才放行 skip——恢复与紧随的需切模型任务 setModel(目标) 不得
422
409
  // 并发(串行由模型 op 队列结构保证,本 await 是调用方顺序语义:skip 放行前恢复
423
410
  // 已收口);本分支随后即 skip,await 无额外延迟代价。
424
- await this.forceSettleExpiredInFlight()
411
+ await this.settlePendingIfIdle('mutex-settle')
425
412
  skipped = true
426
413
  } else {
427
414
  // 模型 op 串行段(MF-2 收口,互斥窗口全程覆盖「setModel 起步 → 记录创建」):
@@ -459,8 +446,6 @@ export class SchedulerRuntime {
459
446
  taskId: task.id,
460
447
  expectedModelRef: currentNow,
461
448
  targetModelRef,
462
- phase: 'in-flight',
463
- ticksOpen: 0,
464
449
  }
465
450
  } else {
466
451
  // setModel false(无可用 API key 等)→ 按「恢复失败」同族处理:日志 + 放弃本次
@@ -554,23 +539,23 @@ export class SchedulerRuntime {
554
539
  return this.dispatchTimestamps.length < RATE_LIMIT_PER_MINUTE
555
540
  }
556
541
 
557
- // ── 模型切换:agent_settled 恢复与 tick 对账兜底(U4,D3 修订版 + D1 归属简化)──
542
+ // ── 模型切换:agent_settled 恢复与 tick 事实查询(U4,D3 修订版 + D1 归属简化
543
+ // + ADR-0122 事实查询翻转)──
558
544
 
559
545
  /**
560
- * 未决模型切换记录的对账探测(tickScheduler 开头调用,严格先于 dispatch 循环)。
561
- * 同步推进状态机,仅在需要执行恢复时返回 Promise(调用方 await——顺序约束)。
562
- * 对账守卫前置:仅当存在未决记录才比对恢复——无记录时「模型 ≠ 期望」是用户自主行为,
563
- * 不动作(否则用户在任务间隙手动切模型会被静默回滚)。
564
- * - in-flight:ticksOpen 计数超 MODEL_SWITCH_RECONCILE_TICKS → agent_settled 恢复事件
565
- * 丢失,强制开放:idle 即恢复(返回恢复 Promise);非 idle 转 awaiting-restore
566
- * (P-MODEL-② 证实 setModel 不影响 in-flight turn,但保守口径维持非 idle 推迟。
567
- * pi 实装锚点(0.84.4):dist/core/agent-session.js:304 turn 模型在准备时一次性
568
- * 快照,setModel 只写 state 不回写已在途 turn 的已快照模型)。
569
- * 窗口内继续等事件。
570
- * - awaiting-restore:比对决策已在窗口内锁定,执行不受窗口过期限制——idle 重入即兑现
571
- * (tick 为天然重入点,不设墙钟上界:任务级正常路径无墙钟超时)。
546
+ * 未决模型切换记录的事实查询结算:记录存在 + 会话 idle(isIdle() 同步查询,
547
+ * 判定层级最高的事实来源)→ 兑现恢复;非 idle → 不动作(不能把在途 run 的模型
548
+ * 换掉),记录保留等下一查询点。无记录时同步返回 undefined(调用方跳过 await,
549
+ * 保住 tick 无记录路径的同步契约——dispatch 的 in-flight 守卫在 tickScheduler()
550
+ * 未 await 时同步可见)。
551
+ *
552
+ * 调用点两处:① tickScheduler 开头(严格先于 dispatch 循环——顺序约束:否则
553
+ * 「B dispatch 先切 Y → 对账持 A 旧记录把 Y 静默切回」;agent_settled 事件丢失时
554
+ * 本查询点是正确性兜底,事件只作提前触发加速器);② dispatchTaskInner 互斥分支
555
+ * (skip 前尽量把旧记录收口,恢复 setModel 与后续新任务的 setModel(目标) 不得并发
556
+ * ——串行由模型 op 队列结构保证,本 await 是调用方顺序语义)。
572
557
  */
573
- private reconcileModelSwitch(): Promise<void> | undefined {
558
+ private settlePendingIfIdle(reason: string): Promise<void> | undefined {
574
559
  const ps = this.pendingModelSwitch
575
560
  if (!ps) return undefined
576
561
  if (this.isCtxStale?.()) {
@@ -580,44 +565,8 @@ export class SchedulerRuntime {
580
565
  }
581
566
  const ops = this.modelOps
582
567
  if (!ops) return undefined
583
- if (ps.phase === 'in-flight') {
584
- ps.ticksOpen += 1
585
- if (ps.ticksOpen <= MODEL_SWITCH_RECONCILE_TICKS) return undefined
586
- logger.warn('model switch in-flight mark expired, forcing reconciliation', {
587
- taskId: ps.taskId,
588
- expectedModelRef: ps.expectedModelRef,
589
- targetModelRef: ps.targetModelRef,
590
- })
591
- if (!ops.isIdle()) {
592
- ps.phase = 'awaiting-restore'
593
- return undefined
594
- }
595
- return this.restoreExpectedModel('reconcile-forced')
596
- }
597
568
  if (!ops.isIdle()) return undefined
598
- return this.restoreExpectedModel('reconcile-idle')
599
- }
600
-
601
- /**
602
- * 互斥命中时对已过窗口的 in-flight 残留做强制开放转移(idle 恢复 / 非 idle 转
603
- * awaiting-restore);未过窗口或已在 awaiting-restore 的记录不动(其结算由事件与
604
- * tick 对账通道负责)。
605
- *
606
- * 返回 Promise 而非 fire-and-forget:idle 恢复路径透传 restoreExpectedModel,调用方
607
- * (dispatchTaskInner 互斥分支)必须 await——恢复 setModel(原) 与后续新任务的
608
- * setModel(目标) 不得并发(runTaskNow 直连路径下完成顺序不定,恢复后完成则该任务
609
- * turn 用错模型;串行由模型 op 队列结构保证,本 await 是调用方顺序语义:skip 放行前
610
- * 恢复已收口);本分支随后即 skip,await 无额外延迟代价。
611
- */
612
- private forceSettleExpiredInFlight(): Promise<void> {
613
- const ps = this.pendingModelSwitch
614
- if (!ps || !this.modelOps) return Promise.resolve()
615
- if (ps.phase !== 'in-flight' || ps.ticksOpen <= MODEL_SWITCH_RECONCILE_TICKS) return Promise.resolve()
616
- if (this.modelOps.isIdle()) {
617
- return this.restoreExpectedModel('mutex-forced')
618
- }
619
- ps.phase = 'awaiting-restore'
620
- return Promise.resolve()
569
+ return this.restoreExpectedModel(reason)
621
570
  }
622
571
 
623
572
  /**
@@ -671,27 +620,22 @@ export class SchedulerRuntime {
671
620
  // 不执行恢复)。
672
621
 
673
622
  /**
674
- * agent_settled:模型恢复的唯一事件通道(D1 归属简化,审计裁决「agent_settled + isIdle
675
- * 复核拆 turnIndex 状态机」——原 turnIndex 归属:4 状态字段 + 6 事件挂点 + customType
676
- * 前缀匹配,绕路自造对 2-tick 对账的依赖)。settled = run 完全落定(无 retry/compaction/
677
- * queued continuation;pi 实装 agent-session.js _emitAgentSettled 先置 _isAgentRunActive
678
- * = false 再 emit),dispatched turn 必已结束,无需 turnIndex 归属识别——isIdle 复核:
679
- * - idle → 兑现恢复(in-flight / awaiting-restore 均结算;恢复延迟从「最长 1 tick(30s)」
680
- * 收敛到事件即时)
681
- * - 非 idle(settled 与用户新 run 交错)→ 转 awaiting-restore 推迟(不能切:会把新 run
682
- * 的模型换掉,P-MODEL-② 同族形态),tick 重入 idle 兑现
623
+ * agent_settled:模型恢复的事件触发点(D1 归属简化——原 turnIndex 归属:4 状态字段 +
624
+ * 6 事件挂点 + customType 前缀匹配,绕路自造对 2-tick 对账的依赖)。settled = run
625
+ * 完全落定(无 retry/compaction/queued continuation;pi 实装 agent-session.js
626
+ * _emitAgentSettled 先置 _isAgentRunActive = false 再 emit),dispatched turn 必已
627
+ * 结束,无需 turnIndex 归属识别——事件到达即做 isIdle() 事实查询:
628
+ * - idle → 兑现恢复(事件是提前触发的加速器,恢复延迟 = 事件即时)
629
+ * - 非 idle(settled 与用户新 run 交错)→ 不能切:会把新 run 的模型换掉
630
+ * (P-MODEL-② 同族形态)。记录保留,由 tick 的 isIdle() 查询在 idle 重入时兑现。
683
631
  * 不挂 agent_end 恢复:end 后仍可能有自动续跑 turn,此时切回会把续跑 turn 的模型换掉
684
- * (P-MODEL-② 同族形态)——恢复只认 settled。agent_settled 丢失(事件通道无送达保证)
685
- * → 30s tick 对账强制开放兜底(见 MODEL_SWITCH_RECONCILE_TICKS 红线登记)。
632
+ * (P-MODEL-② 同族形态)——恢复只认 settled。
686
633
  */
687
634
  handleRunSettled(): void {
688
635
  if (this.isCtxStale?.()) return
689
- const ps = this.pendingModelSwitch
690
- if (!ps || !this.modelOps) return
636
+ if (!this.pendingModelSwitch || !this.modelOps) return
691
637
  if (this.modelOps.isIdle()) {
692
638
  void this.restoreExpectedModel('agent-settled')
693
- } else {
694
- ps.phase = 'awaiting-restore'
695
639
  }
696
640
  }
697
641