@zhushanwen/pi-scheduler 0.9.3 → 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 +10 -10
- package/src/__tests__/U4-MODEL-SWITCH.test.ts +36 -43
- package/src/__tests__/index-generation.test.ts +23 -21
- package/src/__tests__/widget-push.test.ts +14 -53
- package/src/ack-turn.ts +6 -6
- package/src/index.ts +15 -37
- package/src/runtime.ts +50 -106
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,10 @@
|
|
|
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.11.
|
|
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
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
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* 覆盖五块机制(D1 终态:agent_settled + isIdle 复核拆 turnIndex 归属状态机——原
|
|
6
|
+
* turnIndex 归属:4 状态字段 + 6 事件挂点 + customType 前缀匹配):
|
|
6
7
|
* - 切换:dispatchTaskInner 在 sendMessage 前 setModel(busy 亦生效——本层不做 idle 检查)
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
* -
|
|
14
|
-
*
|
|
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 交错)→
|
|
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
|
|
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 日志 + 清标记 +
|
|
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('
|
|
210
|
-
// 场景(runTaskNow
|
|
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
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
223
|
-
const internal = runtime as unknown as { pendingModelSwitch:
|
|
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-
|
|
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
|
|
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
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
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) //
|
|
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('无未决记录时模型漂移 =
|
|
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):观测面 =
|
|
210
|
-
//
|
|
211
|
-
//
|
|
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
|
|
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
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
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(
|
|
294
|
+
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2) // runtime2 初始渲染(实例态重置,首帧必推)
|
|
295
|
+
expect(runtimeInstances).toHaveLength(2)
|
|
293
296
|
|
|
294
|
-
//
|
|
295
|
-
// F1
|
|
296
|
-
|
|
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
|
|
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
|
|
9
|
-
// - 任务集指纹不变 → 跳推(时间流逝不是状态变化,静态期零推送——G1
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
41
|
-
const
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
121
|
-
await vi.advanceTimersByTimeAsync(
|
|
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('
|
|
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
348
|
-
* fileEntries 再同步 _persist
|
|
349
|
-
* hasAssistant 命中即走同步 wx
|
|
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
|
-
//
|
|
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
|
|
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
|
|
267
|
-
// isError
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
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
|
-
//
|
|
314
|
-
//
|
|
315
|
-
const
|
|
316
|
-
if (
|
|
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.
|
|
396
|
-
* dist/core/agent-session.js
|
|
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
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
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.
|
|
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
|
|
542
|
+
// ── 模型切换:agent_settled 恢复与 tick 事实查询(U4,D3 修订版 + D1 归属简化
|
|
543
|
+
// + ADR-0122 事实查询翻转)──
|
|
558
544
|
|
|
559
545
|
/**
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* - idle →
|
|
680
|
-
*
|
|
681
|
-
*
|
|
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。
|
|
685
|
-
* → 30s tick 对账强制开放兜底(见 MODEL_SWITCH_RECONCILE_TICKS 红线登记)。
|
|
632
|
+
* (P-MODEL-② 同族形态)——恢复只认 settled。
|
|
686
633
|
*/
|
|
687
634
|
handleRunSettled(): void {
|
|
688
635
|
if (this.isCtxStale?.()) return
|
|
689
|
-
|
|
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
|
|