@zhushanwen/pi-scheduler 0.9.1 → 0.9.3

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.1",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -51,10 +51,11 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "croner": "^9.0.0",
54
- "@zhushanwen/extension-protocol": "0.14.0",
55
- "@zhushanwen/pi-ext-guards": "0.4.1",
54
+ "@zhushanwen/extension-protocol": "0.17.0",
55
+ "@zhushanwen/pi-ext-guards": "0.4.2",
56
56
  "@zhushanwen/pi-extension-logger": "0.6.1",
57
- "@zhushanwen/pi-llm-shared": "0.10.0"
57
+ "@zhushanwen/pi-llm-shared": "0.11.0",
58
+ "@zhushanwen/pi-session-path": "0.1.0"
58
59
  },
59
60
  "scripts": {
60
61
  "test": "vitest run",
@@ -1,20 +1,22 @@
1
1
  /**
2
- * U4_MODEL_SWITCH:dispatch 模型切换(设计 D3 修订版)验收
2
+ * U4_MODEL_SWITCH:dispatch 模型切换(设计 D3 修订版 + D1 归属简化)验收
3
3
  *
4
- * 覆盖设计 §3.3 D3 的五块机制(探针 P-MODEL 修订后语义):
4
+ * 覆盖设计 §3.3 D-B4-4 D1 的五块机制(D1 终态:agent_settled + isIdle 复核拆 turnIndex
5
+ * 归属状态机——原 turnIndex 归属:4 状态字段 + 6 事件挂点 + customType 前缀匹配):
5
6
  * - 切换: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 收敛到事件即时)
7
+ * - 恢复:agent_settled(run 完全落定)+ isIdle 复核是唯一事件通道——idle 即恢复;
8
+ * 非 idle(settled 与用户新 run 交错)转 awaiting-restore 推迟,tick 重入 idle 兑现;
9
+ * 不挂 agent_end(end 后可能有自动续跑 turn,恢复只认 settled)
9
10
  * - 互斥:切换在途时其他需切模型任务 skip + pending 留待下 tick 重试
10
- * - 串行化(MF-2):turn_end 恢复在途 / 切换 setModel 在途未建记录窗口内,后继需切模型
11
+ * - 串行化(MF-2):settled 恢复在途 / 切换 setModel 在途未建记录窗口内,后继需切模型
11
12
  * 任务的 setModel 排队等前序模型 op 完成(任意两个 setModel 不并发、双记录不叠写)
12
- * - 对账兜底:严格先于同 tick dispatch 循环;在途标记 2 tick 过期强制开放;未决记录守卫
13
- * (无记录时模型漂移 = 用户自主行为不动作)
13
+ * - 对账兜底(时间平抑红线登记见 runtime.ts MODEL_SWITCH_RECONCILE_TICKS):严格先于同
14
+ * tick dispatch 循环;在途标记 2 tick 过期强制开放(agent_settled 丢失的兜底路径);
15
+ * 未决记录守卫(无记录时模型漂移 = 用户自主行为不动作)
14
16
  * - 降级与接管副作用:setModel false 不阻塞 dispatch;sendMessage 抛错 catch 先恢复;
15
17
  * 模型语义异常只走日志(不进持久化词表)
16
18
  *
17
- * 事件注入:直接调用 runtime 的 handle* 事件入口(index.ts 的 pi.on 只做转发)。
19
+ * 事件注入:直接调用 runtime 的 handleRunSettled(index.ts 的 pi.on 只做转发)。
18
20
  * tick:fake timers 驱动真实 interval(对齐 runtime.test.ts F2 形态)。
19
21
  */
20
22
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -30,10 +32,8 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
30
32
  }))
31
33
 
32
34
  import { MockSchedulerBackend } from './mock-backend.js'
33
- import { SchedulerRuntime, type SchedulerModelOps } from '../runtime.js'
35
+ import { TICK_INTERVAL_MS, SchedulerRuntime, type SchedulerModelOps } from '../runtime.js'
34
36
  import type { ScheduledTask } from '../types.js'
35
-
36
- const TICK_INTERVAL_MS = 30_000
37
37
  const ORIG = 'prov-a/model-1'
38
38
  const TASK_M = 'prov-b/model-2'
39
39
  const OTHER_M = 'prov-c/model-3'
@@ -60,7 +60,7 @@ class ModelOpsMock implements SchedulerModelOps {
60
60
  }
61
61
  }
62
62
 
63
- /** 纯 microtask 冲刷:handleTurnEnd 的恢复是 fire-and-forget async,断言前等待其 await 链完成 */
63
+ /** 纯 microtask 冲刷:handleRunSettled 的恢复是 fire-and-forget async,断言前等待其 await 链完成 */
64
64
  async function flushAsync(): Promise<void> {
65
65
  for (let i = 0; i < 5; i++) await Promise.resolve()
66
66
  }
@@ -71,11 +71,6 @@ async function flushAsyncRounds(rounds: number): Promise<void> {
71
71
  for (let i = 0; i < rounds; i++) await Promise.resolve()
72
72
  }
73
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
74
  describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
80
75
  let backend: MockSchedulerBackend
81
76
  let ops: ModelOpsMock
@@ -102,27 +97,20 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
102
97
  return runtime.addTask(prompt, { mode: 'interval', intervalMs: 600_000 }, { model })
103
98
  }
104
99
 
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 () => {
100
+ describe('切换与 settled 恢复', () => {
101
+ it('task.model ≠ 当前 → setModel(目标);agent_settled + idle 即恢复(原),重复 settled 不二次恢复', async () => {
115
102
  task = await addModelTask('job', TASK_M)
116
103
  const ok = await runtime.dispatchTask(task)
117
104
  expect(ok).toBe(true)
118
105
  expect(ops.setModelCalls).toEqual([TASK_M])
119
106
 
120
- injectDispatchTurn(0)
107
+ // run 完全落定(agent_settled)+ idle:唯一事件通道立即兑现,无需推进 tick
108
+ runtime.handleRunSettled()
121
109
  await flushAsync()
122
110
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
123
111
 
124
- // 记录已清:重复 turn_end 不二次恢复
125
- runtime.handleTurnEnd(0)
112
+ // 记录已清:重复 settled 不二次恢复
113
+ runtime.handleRunSettled()
126
114
  await flushAsync()
127
115
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
128
116
  })
@@ -133,144 +121,29 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
133
121
  expect(ok).toBe(true)
134
122
  expect(ops.setModelCalls).toEqual([])
135
123
 
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
124
  runtime.handleRunSettled()
172
125
  await flushAsync()
173
- expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
126
+ expect(ops.setModelCalls).toEqual([])
174
127
  })
175
128
 
176
- it('agent_settled 与用户新 run 交错(非 idle)→ 不动作,tick 兜底仍兑现', async () => {
129
+ it('settled 非 idle(与用户新 run 交错)→ 转 awaiting-restore 推迟,tick 重入 idle 兑现', async () => {
177
130
  task = await addModelTask('job', TASK_M)
178
131
  await runtime.dispatchTask(task)
179
-
180
- ops.idle = false
181
- injectDispatchTurn(0)
182
- await flushAsync()
183
132
  expect(ops.setModelCalls).toEqual([TASK_M])
184
133
 
185
134
  // settled 事件处理时用户已开始新 run(isIdle false):不能切(会把新 run 模型换掉)
135
+ ops.idle = false
186
136
  runtime.handleRunSettled()
187
137
  await flushAsync()
188
138
  expect(ops.setModelCalls).toEqual([TASK_M])
189
139
 
190
- // 兜底不变:后续 tick 重入 idle 仍恢复
140
+ // 兜底不变:后续 tick 重入 idle 仍恢复(awaiting-restore 无窗口过期)
191
141
  ops.idle = true
192
142
  runtime.startScheduler()
193
143
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
194
144
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
195
145
  })
196
146
 
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
147
  it('恢复 setModel(原) false → restore-failed 终态:warn 日志 + 清标记 + 对账不再动作', async () => {
275
148
  task = await addModelTask('job', TASK_M)
276
149
  await runtime.dispatchTask(task)
@@ -278,7 +151,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
278
151
 
279
152
  // 模拟恢复期 setModel 失败(无可用 key 等)
280
153
  ops.setModelResult = false
281
- injectDispatchTurn(0)
154
+ runtime.handleRunSettled()
282
155
  await flushAsync()
283
156
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
284
157
  const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
@@ -296,7 +169,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
296
169
  await runtime.dispatchTask(task)
297
170
  ops.currentRef = ORIG // 用户手动切回
298
171
 
299
- injectDispatchTurn(0)
172
+ runtime.handleRunSettled()
300
173
  await flushAsync()
301
174
  expect(ops.setModelCalls).toEqual([TASK_M]) // 无恢复调用
302
175
  })
@@ -324,8 +197,8 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
324
197
  expect(okC).toBe(true)
325
198
  expect(backend.sentMessages).toHaveLength(2)
326
199
 
327
- // A 结算(turn_end + idle 恢复)后 B 重试成功
328
- injectDispatchTurn(0)
200
+ // A 结算(settled + idle 恢复)后 B 重试成功
201
+ runtime.handleRunSettled()
329
202
  await flushAsync()
330
203
  const okRetry = await runtime.dispatchTask(taskB)
331
204
  expect(okRetry).toBe(true)
@@ -460,7 +333,7 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
460
333
  expect(internal.pendingModelSwitch!.targetModelRef).toBe(TASK_M)
461
334
 
462
335
  // A 结算后 B 重试成功(系统收敛,目标模型生效)
463
- injectDispatchTurn(0)
336
+ runtime.handleRunSettled()
464
337
  await flushAsync()
465
338
  const okRetry = await runtime.dispatchTask(taskB)
466
339
  expect(okRetry).toBe(true)
@@ -493,15 +366,15 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
493
366
  expect(taskB.lastStatus).toBe('success')
494
367
 
495
368
  // B 的恢复链路随后正常闭环
496
- injectDispatchTurn(0)
369
+ runtime.handleRunSettled()
497
370
  await flushAsync()
498
371
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M, ORIG])
499
372
  })
500
373
  })
501
374
 
502
375
  describe('模型 op 串行化(MF-2 回归)', () => {
503
- it('turn_end 恢复在途时新需切模型任务 dispatch → 新 setModel 排队等恢复完成(先恢复后切换)', async () => {
504
- // MF-2 回归:handleTurnEnd 的恢复是 fire-and-forget(先同步清记录再 await setModel(原))。
376
+ it('settled 恢复在途时新需切模型任务 dispatch → 新 setModel 排队等恢复完成(先恢复后切换)', async () => {
377
+ // MF-2 回归:handleRunSettled 的恢复是 fire-and-forget(先同步清记录再 await setModel(原))。
505
378
  // 修复前恢复在途窗口内 pendingModelSwitch 已为 null → 新 dispatch 的互斥检查放行,
506
379
  // setModel(目标) 与在途 setModel(原) 并发、完成顺序不定(恢复后完成则该任务 turn
507
380
  // 静默跑错模型)。修复后恢复持模型 op 队列,后继 setModel 排队等其完成。
@@ -519,12 +392,12 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
519
392
  vi.spyOn(ops, 'setModelByRef').mockImplementation(async (ref: string) => {
520
393
  ops.setModelCalls.push(ref)
521
394
  if (ops.setModelResult) ops.currentRef = ref
522
- if (ref === ORIG) await restoreGate // turn_end 恢复挂起在「已发起未完成」态
395
+ if (ref === ORIG) await restoreGate // settled 恢复挂起在「已发起未完成」态
523
396
  return ops.setModelResult
524
397
  })
525
398
 
526
- // A 的 turn 正常结束且 idle → fire-and-forget 恢复启动并挂起
527
- injectDispatchTurn(0)
399
+ // A 的 run 完全落定且 idle → fire-and-forget 恢复启动并挂起
400
+ runtime.handleRunSettled()
528
401
  await flushAsync()
529
402
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG])
530
403
  // 记录已同步清(先关后恢复不变)——修复前正是这个窗口放行了并发 setModel
@@ -558,17 +431,17 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
558
431
  expect(backend.sentMessages).toHaveLength(2)
559
432
 
560
433
  // B 的恢复链路随后正常闭环(终态回 ORIG)
561
- injectDispatchTurn(0)
434
+ runtime.handleRunSettled()
562
435
  await flushAsync()
563
436
  expect(ops.setModelCalls).toEqual([TASK_M, ORIG, OTHER_M, ORIG])
564
437
  })
565
438
  })
566
439
 
567
440
  describe('对账兜底', () => {
568
- it('在途标记超过 2 tick 未关闭 → 强制开放并对账恢复(事件丢失路径)', async () => {
441
+ it('agent_settled 丢失:在途标记超过 2 tick 未关闭 → 强制开放并对账恢复(事件丢失路径)', async () => {
569
442
  task = await addModelTask('job', TASK_M)
570
443
  await runtime.dispatchTask(task)
571
- // 无任何事件注入(恢复回调永不执行)
444
+ // 无任何事件注入(settled 恢复通道不触发)
572
445
 
573
446
  runtime.startScheduler()
574
447
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1: ticksOpen=1
@@ -598,8 +471,8 @@ describe('U4_MODEL_SWITCH: dispatch 模型切换', () => {
598
471
  const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
599
472
  expect(warnText).toContain('model switch failed')
600
473
 
601
- // 无未决记录:事件归属不建立、turn_end 无恢复
602
- injectDispatchTurn(0)
474
+ // 无未决记录:settled 不动作(切换失败 = 无恢复对象)
475
+ runtime.handleRunSettled()
603
476
  await flushAsync()
604
477
  expect(ops.setModelCalls).toEqual([TASK_M])
605
478
  })
@@ -0,0 +1,253 @@
1
+ /**
2
+ * U6c:backend.loadTasks 折叠接活跃路径裁剪 + session_tree handler 重折叠。
3
+ *
4
+ * 从 leafId 沿 parentId 回溯得活跃路径,被撤子树的任务 op entry 不再恢复 pending
5
+ * 定时任务——到点不触发真实 turn(A14 可观察验收锚)。
6
+ */
7
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
8
+ import { describe, expect, it, vi } from 'vitest'
9
+
10
+ import { PiSchedulerBackend, type SchedulerBackendCtx } from '../backend.js'
11
+ import { SchedulerRuntime, TICK_INTERVAL_MS } from '../runtime.js'
12
+ import { TASK_ENTRY_TYPE } from '../types.js'
13
+ import type { ScheduledTask, SchedulerEntryOp, TaskSnapshot } from '../types.js'
14
+
15
+ const SESSION_FILE = '/test/session.json'
16
+
17
+ // ── fixture ─────────────────────────────────────────
18
+
19
+ interface BranchEntry {
20
+ id: string
21
+ parentId: string | null
22
+ type: string
23
+ customType?: string
24
+ data?: unknown
25
+ }
26
+
27
+ /** 构造 base task 快照(upsert op 用,字段集对齐 replay.test.ts 惯例) */
28
+ function snapshot(overrides: Partial<TaskSnapshot> = {}): TaskSnapshot {
29
+ return {
30
+ id: 't1',
31
+ name: 'test',
32
+ prompt: 't1 prompt',
33
+ kind: 'recurring',
34
+ schedule: { mode: 'interval', intervalMs: 60000 },
35
+ enabled: true,
36
+ createdAt: 0,
37
+ nextRunAt: 100,
38
+ runCount: 0,
39
+ history: [],
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ function upsertOp(taskId: string, overrides: Partial<TaskSnapshot> = {}): SchedulerEntryOp {
45
+ return {
46
+ op: 'upsert',
47
+ taskId,
48
+ ownerSessionFile: SESSION_FILE,
49
+ task: snapshot({ id: taskId, prompt: `${taskId} prompt`, ...overrides }),
50
+ }
51
+ }
52
+
53
+ function taskOpEntry(id: string, parentId: string | null, op: SchedulerEntryOp): BranchEntry {
54
+ return { type: 'custom', id, parentId, customType: TASK_ENTRY_TYPE, data: op }
55
+ }
56
+
57
+ /** 撤回后真实形态:label entry 落文件尾(parentId = 回退后叶子),leafId 指向它 */
58
+ function labelEntry(id: string, parentId: string | null): BranchEntry {
59
+ return { type: 'label', id, parentId, targetId: 'u-x', label: 'taiji:revoked' }
60
+ }
61
+
62
+ /** 可变树状态的 ctx(模拟撤回前后 swap:entries + leafId 经闭包读取) */
63
+ function makeCtx(initialEntries: BranchEntry[], initialLeafId: string | null) {
64
+ let entries = initialEntries
65
+ let leafId = initialLeafId
66
+ const ctx: SchedulerBackendCtx = {
67
+ sessionManager: {
68
+ getEntries: () => entries.slice(),
69
+ getSessionFile: () => SESSION_FILE,
70
+ getLeafId: () => leafId,
71
+ },
72
+ }
73
+ return {
74
+ ctx,
75
+ setTree(nextEntries: BranchEntry[], nextLeafId: string | null): void {
76
+ entries = nextEntries
77
+ leafId = nextLeafId
78
+ },
79
+ }
80
+ }
81
+
82
+ /** 捕获 pi.on 注册的 handler + 副作用记录面(sendMessage / appendEntry vi.fn)。
83
+ * on 字段仅满足 ExtensionAPI 结构完整性:session_tree 注册面已迁至 factory 顶层
84
+ * (index.ts),backend 构造不再调 pi.on(注册面契约见 session-tree-registration.test.ts)。 */
85
+ function makePi() {
86
+ const handlers = new Map<string, (...args: unknown[]) => unknown>()
87
+ const sendMessage = vi.fn(async () => {})
88
+ const appendEntry = vi.fn()
89
+ const registerProvider = vi.fn()
90
+ const unregisterProvider = vi.fn()
91
+ const pi = {
92
+ on: (event: string, handler: (...args: unknown[]) => unknown) => {
93
+ handlers.set(event, handler)
94
+ },
95
+ sendMessage,
96
+ appendEntry,
97
+ registerProvider,
98
+ unregisterProvider,
99
+ }
100
+ return { pi: pi as unknown as ExtensionAPI, handlers, sendMessage, appendEntry }
101
+ }
102
+
103
+ // ── loadTasks 活跃路径裁剪(U6c)─────────────────────
104
+
105
+ describe('PiSchedulerBackend.loadTasks 活跃路径裁剪(U6c)', () => {
106
+ it('有分支 fixture:被撤子树的任务 op 不进重建态', () => {
107
+ // 树:e1(t1 upsert,active);e1 → e3(t2 upsert,revoked-branch 物理后写);
108
+ // 撤回落 label 锚 L(parentId = 回退后叶子 e1),leafId = L → 活跃路径 {L, e1}
109
+ const entries = [
110
+ taskOpEntry('e1', null, upsertOp('t1')),
111
+ taskOpEntry('e3', 'e1', upsertOp('t2')),
112
+ labelEntry('L', 'e1'),
113
+ ]
114
+ const { ctx } = makeCtx(entries, 'L')
115
+
116
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
117
+
118
+ const ids = tasks.map(t => t.id)
119
+ expect(ids).toContain('t1')
120
+ // 反向断言:被撤子树任务不恢复 pending(无裁剪时会从 e3 折叠复活)
121
+ expect(ids).not.toContain('t2')
122
+ })
123
+
124
+ it('同任务跨分支:取活跃路径内最新 upsert(分支上被撤的后续推进不参与折叠)', () => {
125
+ const entries = [
126
+ taskOpEntry('e1', null, upsertOp('t1', { prompt: 'old prompt' })),
127
+ taskOpEntry('e2', 'e1', upsertOp('t1', { prompt: 'active-branch prompt' })),
128
+ taskOpEntry('e3', 'e1', upsertOp('t1', { prompt: 'revoked-branch prompt' })),
129
+ labelEntry('L', 'e2'),
130
+ ]
131
+ const { ctx } = makeCtx(entries, 'L')
132
+
133
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
134
+
135
+ expect(tasks).toHaveLength(1)
136
+ expect(tasks[0]!.prompt).toBe('active-branch prompt')
137
+ })
138
+
139
+ it('无分支回归:leafId = 文件尾 → 线性折叠最新(现行为不变)', () => {
140
+ const entries = [
141
+ taskOpEntry('e1', null, upsertOp('t1', { prompt: 'v1' })),
142
+ taskOpEntry('e2', 'e1', upsertOp('t1', { prompt: 'v2' })),
143
+ ]
144
+ const { ctx } = makeCtx(entries, 'e2')
145
+
146
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
147
+
148
+ expect(tasks).toHaveLength(1)
149
+ expect(tasks[0]!.prompt).toBe('v2')
150
+ })
151
+
152
+ it('legacy fixture 回归:entry 无 id/parentId(duck-typed 最小形状)→ 线性全量折叠,行为与裁剪前一致', () => {
153
+ const entries = [
154
+ { type: 'custom', customType: TASK_ENTRY_TYPE, data: upsertOp('t1') },
155
+ { type: 'custom', customType: TASK_ENTRY_TYPE, data: upsertOp('t2') },
156
+ ]
157
+ const ctx: SchedulerBackendCtx = {
158
+ sessionManager: {
159
+ getEntries: () => entries.slice(),
160
+ getSessionFile: () => SESSION_FILE,
161
+ },
162
+ }
163
+
164
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
165
+
166
+ expect(tasks.map(t => t.id).sort()).toEqual(['t1', 't2'])
167
+ })
168
+
169
+ it('leafId 防御:null / 指向不存在 entry → 回退文件尾(撤回落 label 锚在尾,等价命中活跃路径)', () => {
170
+ const entries = [
171
+ taskOpEntry('e1', null, upsertOp('t1')),
172
+ taskOpEntry('e3', 'e1', upsertOp('t2')),
173
+ labelEntry('L', 'e1'),
174
+ ]
175
+
176
+ for (const leafId of [null, 'missing-id']) {
177
+ const { ctx } = makeCtx(entries, leafId)
178
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
179
+ // 尾部锚 = label L(parentId=e1)→ 回溯 {L, e1},被撤子树 t2 仍被裁掉
180
+ expect(tasks.map(t => t.id)).toEqual(['t1'])
181
+ }
182
+ })
183
+ })
184
+
185
+ // ── session_tree handler 重折叠 + 到点不触发(U6c / A14)──
186
+
187
+ describe('session_tree 重折叠(U6c)', () => {
188
+ it('撤回后 session_tree → runtime 任务集重折叠(被撤任务移除);handler 本身零 dispatch 零 append', () => {
189
+ // 撤回前:线性文件(leaf=尾=e2),t1/t2 均在
190
+ const preRevoke = [
191
+ taskOpEntry('e1', null, upsertOp('t1')),
192
+ taskOpEntry('e2', 'e1', upsertOp('t2')),
193
+ ]
194
+ const { ctx, setTree } = makeCtx(preRevoke, null)
195
+ const { pi, sendMessage, appendEntry } = makePi()
196
+
197
+ const backend = new PiSchedulerBackend(ctx, pi)
198
+ const runtime = new SchedulerRuntime(backend)
199
+ runtime.loadTasks(backend.loadTasks())
200
+ expect(runtime.getTask('t1')).toBeDefined()
201
+ expect(runtime.getTask('t2')).toBeDefined()
202
+
203
+ // 撤回:树回退(label 锚落尾 + leafId 指向它)→ session_tree 事件 → 重折叠
204
+ // (生产注册面在 factory 顶层 index.ts,此处直接驱动委托体)
205
+ setTree([...preRevoke, labelEntry('L', 'e1')], 'L')
206
+ backend.refoldSessionTree()
207
+
208
+ expect(runtime.getTask('t1')).toBeDefined()
209
+ expect(runtime.getTask('t2')).toBeUndefined()
210
+
211
+ // 纯重建断言:handler 自身不 dispatch、不 append op(不污染回退后的新分支)
212
+ expect(sendMessage).not.toHaveBeenCalled()
213
+ expect(appendEntry).not.toHaveBeenCalled()
214
+ })
215
+
216
+ it('到点不触发(A14):重折叠后被撤子树任务 interval 到点无 turn;活跃路径任务照常 dispatch', async () => {
217
+ vi.useFakeTimers()
218
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
219
+ try {
220
+ // 两任务同在撤回前文件上,nextRunAt = now + 1s(首个 30s tick 即到期)
221
+ const dueAt = Date.now() + 1000
222
+ const preRevoke = [
223
+ taskOpEntry('e1', null, upsertOp('t1', { nextRunAt: dueAt })),
224
+ taskOpEntry('e2', 'e1', upsertOp('t2', { nextRunAt: dueAt })),
225
+ ]
226
+ const { ctx, setTree } = makeCtx(preRevoke, null)
227
+ const { pi, sendMessage } = makePi()
228
+
229
+ const backend = new PiSchedulerBackend(ctx, pi)
230
+ const runtime = new SchedulerRuntime(backend)
231
+ runtime.loadTasks(backend.loadTasks())
232
+
233
+ // 撤回回退到 e1(t2 所在子树被移出活跃路径)
234
+ setTree([...preRevoke, labelEntry('L', 'e1')], 'L')
235
+ backend.refoldSessionTree()
236
+ expect(runtime.getTask('t2')).toBeUndefined()
237
+
238
+ // tick 常驻循环不动启停逻辑:重折叠后照常消费新任务集
239
+ runtime.startScheduler()
240
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
241
+
242
+ const prompts = sendMessage.mock.calls.map(call => (call[0] as { content: string }).content)
243
+ expect(prompts).toContain('t1 prompt')
244
+ // A14 验收锚:被撤子树任务到点不触发真实 turn
245
+ expect(prompts).not.toContain('t2 prompt')
246
+ expect(sendMessage).toHaveBeenCalledTimes(1)
247
+ } finally {
248
+ // tick interval 在 fake timers 域内清理,防泄漏到后续用例
249
+ vi.clearAllTimers()
250
+ vi.useRealTimers()
251
+ }
252
+ })
253
+ })