@zhushanwen/pi-scheduler 0.9.2 → 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 +4 -3
- package/src/__tests__/U4-MODEL-SWITCH.test.ts +39 -166
- package/src/__tests__/active-path.test.ts +253 -0
- package/src/__tests__/index-generation.test.ts +1 -2
- package/src/__tests__/runtime.test.ts +6 -14
- package/src/__tests__/session-tree-registration.test.ts +152 -0
- package/src/__tests__/widget-push.test.ts +1 -2
- package/src/backend.ts +53 -3
- package/src/index.ts +34 -17
- package/src/runtime.ts +63 -111
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.9.
|
|
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.
|
|
54
|
+
"@zhushanwen/extension-protocol": "0.17.0",
|
|
55
55
|
"@zhushanwen/pi-ext-guards": "0.4.2",
|
|
56
56
|
"@zhushanwen/pi-extension-logger": "0.6.1",
|
|
57
|
-
"@zhushanwen/pi-llm-shared": "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
|
|
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
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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):
|
|
11
|
+
* - 串行化(MF-2):settled 恢复在途 / 切换 setModel 在途未建记录窗口内,后继需切模型
|
|
11
12
|
* 任务的 setModel 排队等前序模型 op 完成(任意两个 setModel 不并发、双记录不叠写)
|
|
12
|
-
* -
|
|
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 的
|
|
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 冲刷:
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
// 记录已清:重复
|
|
125
|
-
runtime.
|
|
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([
|
|
126
|
+
expect(ops.setModelCalls).toEqual([])
|
|
174
127
|
})
|
|
175
128
|
|
|
176
|
-
it('
|
|
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
|
-
|
|
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
|
-
|
|
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 结算(
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
504
|
-
// MF-2 回归:
|
|
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 //
|
|
395
|
+
if (ref === ORIG) await restoreGate // settled 恢复挂起在「已发起未完成」态
|
|
523
396
|
return ops.setModelResult
|
|
524
397
|
})
|
|
525
398
|
|
|
526
|
-
// A 的
|
|
527
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
//
|
|
602
|
-
|
|
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
|
+
})
|
|
@@ -71,9 +71,8 @@ vi.mock('../runtime.js', async (importOriginal) => {
|
|
|
71
71
|
})
|
|
72
72
|
|
|
73
73
|
import schedulerExtension from '../index.js'
|
|
74
|
+
import { TICK_INTERVAL_MS } from '../runtime.js'
|
|
74
75
|
import { TASK_ENTRY_TYPE } from '../types.js'
|
|
75
|
-
|
|
76
|
-
const TICK_INTERVAL_MS = 30_000
|
|
77
76
|
/** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(常量不导出,测试锚定同值)。 */
|
|
78
77
|
const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
|
|
79
78
|
|