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