@zhushanwen/pi-scheduler 0.9.0 → 0.9.2
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 -4
- package/src/__tests__/index-generation.test.ts +106 -3
- package/src/__tests__/runtime.test.ts +50 -0
- package/src/format.ts +13 -0
- package/src/replay.ts +13 -184
- package/src/types.ts +23 -123
- package/src/__tests__/U4-DISPATCH_INFLIGHT.test.ts +0 -75
- package/src/__tests__/index-session-start.test.ts +0 -139
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"taiji": {
|
|
@@ -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.
|
|
54
|
+
"@zhushanwen/extension-protocol": "0.16.0",
|
|
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.10.1"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
60
|
"test": "vitest run",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
//
|
|
16
16
|
// 本文件用 InstrumentedRuntime(继承真实 SchedulerRuntime,记录构造参 isCtxStale)
|
|
17
17
|
// 捕获注入回调与装配事实。InstrumentedRuntime 全部行为继承父类,不影响装配链本身
|
|
18
|
-
// (F1 停旧 timer
|
|
18
|
+
// (F1 停旧 timer 等行为由本文件 F1 describe 锚定,此处不重复)。
|
|
19
19
|
// steer 直投模型(scheduler-steer-direct-dispatch):装配点不再创建 delivery handle,
|
|
20
20
|
// runtime 构造收 backend + isCtxStale? + modelOps? 三参(U4:modelOps 缺省 = 无模型
|
|
21
21
|
// 切换能力降级)。
|
|
@@ -33,7 +33,9 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
|
33
33
|
setPiHandle: vi.fn(),
|
|
34
34
|
}))
|
|
35
35
|
|
|
36
|
-
//
|
|
36
|
+
// MF-3:session_start 会真实执行 importLegacyStore(ctx.cwd, ...),触碰用户真实 FS
|
|
37
|
+
// (~/.pi/agent/scheduler/... 的 renameSync/existsSync 探测)。mock 掉 importer
|
|
38
|
+
// 模块,装配路径仍被调用、FS 副作用为零。
|
|
37
39
|
vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
|
|
38
40
|
|
|
39
41
|
// u-p2b 接线断言:index.ts 的 refreshWidget 必须走结构化/双模入口 `setSchedulerWidget`
|
|
@@ -69,11 +71,14 @@ vi.mock('../runtime.js', async (importOriginal) => {
|
|
|
69
71
|
})
|
|
70
72
|
|
|
71
73
|
import schedulerExtension from '../index.js'
|
|
74
|
+
import { TASK_ENTRY_TYPE } from '../types.js'
|
|
72
75
|
|
|
73
76
|
const TICK_INTERVAL_MS = 30_000
|
|
77
|
+
/** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(常量不导出,测试锚定同值)。 */
|
|
78
|
+
const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
|
|
74
79
|
|
|
75
80
|
/**
|
|
76
|
-
* 最小 fake pi
|
|
81
|
+
* 最小 fake pi:覆盖 factory 消费的 API 面
|
|
77
82
|
* (on 捕获事件 handler 供手动触发;registerTool/registerCommand/sendMessage/appendEntry 兜底)。
|
|
78
83
|
*/
|
|
79
84
|
function createMockPi(): {
|
|
@@ -195,3 +200,101 @@ describe('G1: index.ts 代际接线(S9)', () => {
|
|
|
195
200
|
}
|
|
196
201
|
})
|
|
197
202
|
})
|
|
203
|
+
|
|
204
|
+
// ── F1: session_start 停旧 runtime(crash-fix U4)──
|
|
205
|
+
// session_start 多发/重入时先停上一代 runtime 的 tick interval:dispatch 的 await sendMessage
|
|
206
|
+
// 窗口与 session 替换交错时,旧 session_shutdown 可能永远等不到 → 旧 30s tick timer 泄漏 →
|
|
207
|
+
// 下一 tick 的 refreshWidget 访问 stale ctx.ui 抛错 → unhandledRejection → pi 主进程 exit 1。
|
|
208
|
+
// F1 在 session_start 开头幂等 stopScheduler,从源头消灭。
|
|
209
|
+
//
|
|
210
|
+
// 行为断言口径(验收 U4):观测面 = refreshWidget 的推送调用序列(本文件 mock 环境下
|
|
211
|
+
// refreshWidget 走 setSchedulerWidgetMock,与真实 ctx.ui.setWidget 同一调用序列)。widget
|
|
212
|
+
// 推送修正(D1 指纹跳推 + D2 保活底线帧)后每次 tick 不再必然推帧——任务集静态期间零推送、
|
|
213
|
+
// 空任务集恒零推送,故注入非空任务集、以「跨 10min 保活窗口必推一帧」作为「该 runtime 的
|
|
214
|
+
// timer 是否还在 tick」的行为观测面(保活帧间隔与 index.ts WIDGET_KEEPALIVE_INTERVAL_MS
|
|
215
|
+
// 同值 10min)。F1 缺失时旧 timer 跨窗口必多推一帧,即被捕获。
|
|
216
|
+
describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
217
|
+
const BASE = Date.parse('2026-01-01T00:00:00Z')
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 构造远期到期的启用任务 upsert entry(owner = sessionFile,避免 fork owner 过滤);
|
|
221
|
+
* nextRunAt 取 BASE + 1h,测试时间窗(分钟级)内无 dispatch 噪音,任务集保持静态。
|
|
222
|
+
*/
|
|
223
|
+
function taskEntry(sessionFile: string) {
|
|
224
|
+
return {
|
|
225
|
+
type: 'custom',
|
|
226
|
+
customType: TASK_ENTRY_TYPE,
|
|
227
|
+
data: {
|
|
228
|
+
op: 'upsert',
|
|
229
|
+
taskId: 'aaaa1111',
|
|
230
|
+
ownerSessionFile: sessionFile,
|
|
231
|
+
task: {
|
|
232
|
+
id: 'aaaa1111',
|
|
233
|
+
name: 'keepalive probe',
|
|
234
|
+
prompt: 'probe prompt',
|
|
235
|
+
kind: 'recurring',
|
|
236
|
+
schedule: { mode: 'interval', intervalMs: 3_600_000 },
|
|
237
|
+
enabled: true,
|
|
238
|
+
createdAt: BASE,
|
|
239
|
+
nextRunAt: BASE + 3_600_000,
|
|
240
|
+
runCount: 0,
|
|
241
|
+
history: [],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 同 createFakeCtx,但注入非空任务集(保活帧只在非空任务集时推送,见上方口径注释)。 */
|
|
248
|
+
function createFakeCtxWithTasks(sessionFile: string): ExtensionContext {
|
|
249
|
+
return {
|
|
250
|
+
cwd: '/test-index-session-start',
|
|
251
|
+
isIdle: () => true,
|
|
252
|
+
hasPendingMessages: () => false,
|
|
253
|
+
ui: { setWidget: vi.fn() },
|
|
254
|
+
sessionManager: {
|
|
255
|
+
getEntries: () => [taskEntry(sessionFile)],
|
|
256
|
+
getSessionFile: () => sessionFile,
|
|
257
|
+
},
|
|
258
|
+
} as unknown as ExtensionContext
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
beforeEach(() => {
|
|
262
|
+
isCtxStaleCaptures.length = 0
|
|
263
|
+
runtimeInstances.length = 0
|
|
264
|
+
setSchedulerWidgetMock.mockClear()
|
|
265
|
+
vi.useFakeTimers()
|
|
266
|
+
vi.setSystemTime(new Date(BASE))
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
afterEach(() => {
|
|
270
|
+
for (const rt of runtimeInstances) {
|
|
271
|
+
rt.stopScheduler()
|
|
272
|
+
}
|
|
273
|
+
vi.useRealTimers()
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('U4: 双 session_start 后旧 timer 已停——跨保活窗口只有新 runtime 推帧', async () => {
|
|
277
|
+
const { pi, events } = createMockPi()
|
|
278
|
+
schedulerExtension(pi)
|
|
279
|
+
const sessionStart = events.get('session_start')
|
|
280
|
+
expect(sessionStart).toBeDefined()
|
|
281
|
+
|
|
282
|
+
// 第一次 session_start:runtime1 + timer1 启动,初始渲染 1 次
|
|
283
|
+
sessionStart!({ type: 'session_start', reason: 'startup' }, createFakeCtxWithTasks('/test/session-1.json'))
|
|
284
|
+
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(1)
|
|
285
|
+
|
|
286
|
+
// 前置因果锚点:静置越过保活窗口,timer1 推一帧保活帧(排除「timer 从未启动」的假绿;
|
|
287
|
+
// 也锁定跳推行为——窗口内其余 tick 零推送)
|
|
288
|
+
await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
|
|
289
|
+
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2)
|
|
290
|
+
|
|
291
|
+
// 第二次 session_start(session 替换):F1 在装配新 runtime 前停掉 timer1
|
|
292
|
+
sessionStart!({ type: 'session_start', reason: 'new_session' }, createFakeCtxWithTasks('/test/session-2.json'))
|
|
293
|
+
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(3) // runtime2 初始渲染(实例态重置,首帧必推)
|
|
294
|
+
|
|
295
|
+
// 行为断言(验收口径):再静置一个保活窗口,只有 runtime2 的 timer 触发推送——恰 +1;
|
|
296
|
+
// F1 缺失时 timer1/timer2 都活着,此处为 +2
|
|
297
|
+
await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
|
|
298
|
+
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(4)
|
|
299
|
+
})
|
|
300
|
+
})
|
|
@@ -149,6 +149,8 @@ describe('SchedulerRuntime', () => {
|
|
|
149
149
|
// 卡死)时,tick2 的 step2 再标 pending → step3 对同一 task 并发第二个 dispatch → 同一
|
|
150
150
|
// prompt 双注入。守卫:同任务在途(Set<taskId>)
|
|
151
151
|
// 时 skip 本轮并 warn;不同任务不受影响。
|
|
152
|
+
// 末两条为调用方 API 层验收:不经 tick,直接并发/串行调 dispatchTask,
|
|
153
|
+
// 验证守卫在调用方入口同样拦截(warn + 第二次 false)与完成后放行。
|
|
152
154
|
describe('dispatchTask in-flight 守卫(R3-S1)', () => {
|
|
153
155
|
beforeEach(() => {
|
|
154
156
|
vi.useFakeTimers()
|
|
@@ -212,6 +214,54 @@ describe('SchedulerRuntime', () => {
|
|
|
212
214
|
expect(task1.runCount).toBe(1)
|
|
213
215
|
expect(task2.runCount).toBe(1)
|
|
214
216
|
})
|
|
217
|
+
|
|
218
|
+
// ── 调用方 API 层 ──
|
|
219
|
+
it('调用方守卫:同一 taskId 并发 dispatchTask → 第二次被拦截(send 只调 1 次 + warn)', async () => {
|
|
220
|
+
loggerMock.warn.mockClear()
|
|
221
|
+
// 可控延迟的 sendMessage:让第一次 dispatch 挂起
|
|
222
|
+
let resolveSend: (() => void) | undefined
|
|
223
|
+
const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
|
|
224
|
+
backend.sendMessage = vi.fn(() => sendPromise)
|
|
225
|
+
|
|
226
|
+
const task = await runtime.addTask('inflight-test', { mode: 'interval', intervalMs: 60_000 })
|
|
227
|
+
|
|
228
|
+
// 第一次 dispatch(挂起),sendMessage 被调 1 次
|
|
229
|
+
const first = runtime.dispatchTask(task)
|
|
230
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
231
|
+
|
|
232
|
+
// 第二次 dispatch(同一 taskId,在途被拦截)
|
|
233
|
+
const second = await runtime.dispatchTask(task)
|
|
234
|
+
expect(second).toBe(false)
|
|
235
|
+
|
|
236
|
+
// warn 包含 in-flight 提示
|
|
237
|
+
const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
|
|
238
|
+
expect(warnText).toContain('already in flight')
|
|
239
|
+
|
|
240
|
+
// sendMessage 仍只有 1 次(拦截有效)
|
|
241
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
242
|
+
|
|
243
|
+
// 放行第一次 dispatch
|
|
244
|
+
resolveSend!()
|
|
245
|
+
await first
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('调用方守卫:第一次完成后 → 第二次 dispatchTask 正常执行(send 调 2 次)', async () => {
|
|
249
|
+
const task = await runtime.addTask('serial-inflight', { mode: 'interval', intervalMs: 60_000 })
|
|
250
|
+
|
|
251
|
+
// 第一次 dispatch(串行等待完成)
|
|
252
|
+
const first = await runtime.dispatchTask(task)
|
|
253
|
+
expect(first).toBe(true)
|
|
254
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
255
|
+
|
|
256
|
+
// 第二次 dispatch(in-flight 已清除,正常执行)
|
|
257
|
+
// 需要重置 nextRunAt 让任务再次到期
|
|
258
|
+
task.nextRunAt = 0
|
|
259
|
+
const second = await runtime.dispatchTask(task)
|
|
260
|
+
expect(second).toBe(true)
|
|
261
|
+
|
|
262
|
+
// send 被调 2 次(串行完成)
|
|
263
|
+
expect(backend.sentMessages).toHaveLength(2)
|
|
264
|
+
})
|
|
215
265
|
})
|
|
216
266
|
|
|
217
267
|
// ── M10b:rate-limit ──
|
package/src/format.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
// 时间格式化器(r4 M1 locale 版):formatSchedule / formatRelativeTime 是 locale 必填的
|
|
2
|
+
// 本地实现(UiLocale 双语输出,i18n.ts / widget / service / commands 消费);fix 轨 locale
|
|
3
|
+
// 化与 U2 protocol 下沉(无 locale 面,服务 taiji plugin 管理面)暂双轨并存,收敛待后续
|
|
4
|
+
// 把 locale 版提升进 @zhushanwen/extension-protocol。formatDuration / MS_PER_* 单源
|
|
5
|
+
// @zhushanwen/extension-protocol(re-export 保持既有 import 路径)。
|
|
1
6
|
import { formatDuration, MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND } from './parsing.js'
|
|
2
7
|
import type { ScheduleSpec, TaskKind } from './types.js'
|
|
3
8
|
|
|
9
|
+
export {
|
|
10
|
+
MS_PER_DAY,
|
|
11
|
+
MS_PER_HOUR,
|
|
12
|
+
MS_PER_MINUTE,
|
|
13
|
+
MS_PER_SECOND,
|
|
14
|
+
formatDuration,
|
|
15
|
+
} from '@zhushanwen/extension-protocol'
|
|
16
|
+
|
|
4
17
|
/**
|
|
5
18
|
* 界面语言(L2 extension 侧文案语言,经 `<dataDir>/ui-preferences.json` 通道获取)。
|
|
6
19
|
* 定义在 format.ts:i18n.ts 依赖本模块的格式化器,类型反向定义会成环——i18n.ts 再导出。
|
package/src/replay.ts
CHANGED
|
@@ -1,194 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
// 折叠器单源于 @zhushanwen/extension-protocol(U2 折叠器下沉)。包内实现零 pi 依赖
|
|
2
|
+
// (logger 以 ReplayFoldOptions.warn 注入),本文件是扩展侧薄 wrapper:注入共享 logger
|
|
3
|
+
// 保持降级日志行为不变,并保持既有 import 路径与二参签名不变(backend.ts 零改动)。
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
replayFoldEntries as replayFoldEntriesShared,
|
|
7
|
+
type ScheduledTask,
|
|
8
|
+
type SchedulerEntryLike,
|
|
9
|
+
} from '@zhushanwen/extension-protocol'
|
|
2
10
|
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
3
11
|
|
|
4
|
-
import { appendExecutionRecord, snapshotToTask, TASK_ENTRY_TYPE } from './types.js'
|
|
5
|
-
import type { ScheduledTask, SchedulerEntryOp } from './types.js'
|
|
6
|
-
|
|
7
12
|
const logger = getLogger('scheduler')
|
|
8
13
|
|
|
9
|
-
/**
|
|
10
|
-
* CustomEntry 的最小可识别形状(duck-typed,不依赖 @earendil-works/pi-coding-agent 的
|
|
11
|
-
* 具体 SessionEntry 类型)。与 pending-notifications/state.ts、goal/ports.ts 同款:
|
|
12
|
-
* 纯函数可独立单测,调用方传入真实 SessionEntry[] 时按结构兼容。
|
|
13
|
-
*/
|
|
14
|
-
export interface SchedulerEntryLike {
|
|
15
|
-
type: string
|
|
16
|
-
customType?: string
|
|
17
|
-
data?: unknown
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* 从 CustomEntry 序列折叠恢复 per-task 末态(event sourcing)。
|
|
22
|
-
*
|
|
23
|
-
* session_start 重放入口:PiSchedulerBackend.loadTasks() 委托本函数,把当前 session 的
|
|
24
|
-
* TASK_ENTRY_TYPE custom entries 折叠成运行时任务 Map。
|
|
25
|
-
*
|
|
26
|
-
* 两步:
|
|
27
|
-
* ① 全量折叠(foldEntries)——遍历 customType===TASK_ENTRY_TYPE 的 entries,按 op 类型折叠:
|
|
28
|
-
* - upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)
|
|
29
|
-
* - advance → nextRunAt=新值 / lastRunAt=at / runCount++ / history.push(裁20) / lastStatus=status(gap2)
|
|
30
|
-
* - toggle → enabled=新值
|
|
31
|
-
* - delete → 从 Map 移除(后续同 taskId 的 advance/toggle 对不存在 taskId 为 no-op,安全)
|
|
32
|
-
* advance/toggle/delete 对不存在的 taskId 为 no-op,因此 fork 继承的完整序列必须先全量折叠,
|
|
33
|
-
* 不能在处理 upsert 时按 owner 跳过(否则后续无 owner 字段的 op 会作用于「不存在的 taskId」丢失)。
|
|
34
|
-
* ② 折叠完成后按 task.ownerSessionFile !== currentSessionFile 整体过滤(gap1,filterNonOwnerTasks):
|
|
35
|
-
* 移除 fork 继承的非 owner 任务——fork 出的 session B 继承了 owner=A 的 upsert+advance 序列,
|
|
36
|
-
* 但 B 不应拥有 A 的任务,重放后整体过滤掉。
|
|
37
|
-
*
|
|
38
|
-
* append-only 时序保证 nextRunAt 不回退(D1):advance entry 按写入顺序折叠,自然取到最后推进值。
|
|
39
|
-
*
|
|
40
|
-
* getEntries 解析/迭代异常 → logger.warn + 返回空 Map(gap4):降级为无任务而非崩溃 session_start。
|
|
41
|
-
*/
|
|
42
14
|
export function replayFoldEntries(
|
|
43
15
|
entries: Iterable<SchedulerEntryLike>,
|
|
44
16
|
currentSessionFile: string | undefined,
|
|
45
17
|
): Map<string, ScheduledTask> {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
filterNonOwnerTasks(tasks, currentSessionFile)
|
|
50
|
-
return tasks
|
|
51
|
-
} catch (err) {
|
|
52
|
-
// gap4:session JSONL 损坏 / 迭代器抛错时降级为无任务,不让 session_start 崩溃。
|
|
53
|
-
logger.warn('replayFoldEntries failed', { error: toErrorMessage(err) })
|
|
54
|
-
return new Map()
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** 步骤① 全量折叠:跳过非本扩展 entry 与守卫不过的损坏 entry;单条折叠抛错只跳过该条(MF-2)。 */
|
|
59
|
-
function foldEntries(entries: Iterable<SchedulerEntryLike>, tasks: Map<string, ScheduledTask>): void {
|
|
60
|
-
for (const entry of entries) {
|
|
61
|
-
if (entry.type !== 'custom' || entry.customType !== TASK_ENTRY_TYPE) continue
|
|
62
|
-
if (!isSchedulerEntryOp(entry.data)) continue
|
|
63
|
-
const op = entry.data // SchedulerEntryOp(守卫 isSchedulerEntryOp 收窄)
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
applyOp(tasks, op)
|
|
67
|
-
} catch (err) {
|
|
68
|
-
// MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
|
|
69
|
-
// snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
|
|
70
|
-
// 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
|
|
71
|
-
logger.warn('skipping corrupted scheduler entry', { error: toErrorMessage(err) })
|
|
72
|
-
continue
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** 按 op 判别值分发到对应折叠函数;无 default——未知 op 静默跳过(守卫已排除,理论不可达)。 */
|
|
78
|
-
function applyOp(tasks: Map<string, ScheduledTask>, op: SchedulerEntryOp): void {
|
|
79
|
-
switch (op.op) {
|
|
80
|
-
case 'upsert':
|
|
81
|
-
applyUpsert(tasks, op)
|
|
82
|
-
break
|
|
83
|
-
case 'advance':
|
|
84
|
-
applyAdvance(tasks, op)
|
|
85
|
-
break
|
|
86
|
-
case 'toggle':
|
|
87
|
-
applyToggle(tasks, op)
|
|
88
|
-
break
|
|
89
|
-
case 'delete':
|
|
90
|
-
applyDelete(tasks, op)
|
|
91
|
-
break
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)。 */
|
|
96
|
-
function applyUpsert(
|
|
97
|
-
tasks: Map<string, ScheduledTask>,
|
|
98
|
-
op: Extract<SchedulerEntryOp, { op: 'upsert' }>,
|
|
99
|
-
): void {
|
|
100
|
-
tasks.set(op.taskId, { ...snapshotToTask(op.task), ownerSessionFile: op.ownerSessionFile })
|
|
18
|
+
return replayFoldEntriesShared(entries, currentSessionFile, {
|
|
19
|
+
warn: (message, context) => logger.warn(message, context),
|
|
20
|
+
})
|
|
101
21
|
}
|
|
102
22
|
|
|
103
|
-
|
|
104
|
-
function applyAdvance(
|
|
105
|
-
tasks: Map<string, ScheduledTask>,
|
|
106
|
-
op: Extract<SchedulerEntryOp, { op: 'advance' }>,
|
|
107
|
-
): void {
|
|
108
|
-
const task = tasks.get(op.taskId)
|
|
109
|
-
if (!task) return // no-op for unknown taskId(fork 场景安全)
|
|
110
|
-
task.nextRunAt = op.nextRunAt
|
|
111
|
-
task.lastRunAt = op.at
|
|
112
|
-
task.runCount += 1
|
|
113
|
-
appendExecutionRecord(task, op.at, op.status)
|
|
114
|
-
task.lastStatus = op.status // gap2:advance 必须恢复 lastStatus(不只 nextRunAt/lastRunAt/runCount/history)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** toggle → enabled=新值;携带 nextRunAt 时一并应用(P1,防快照回退到旧过期值)。 */
|
|
118
|
-
function applyToggle(
|
|
119
|
-
tasks: Map<string, ScheduledTask>,
|
|
120
|
-
op: Extract<SchedulerEntryOp, { op: 'toggle' }>,
|
|
121
|
-
): void {
|
|
122
|
-
const task = tasks.get(op.taskId)
|
|
123
|
-
if (!task) return
|
|
124
|
-
task.enabled = op.enabled
|
|
125
|
-
// P1:enable 重算到未来的 nextRunAt 随 toggle op 持久化;重放时应用,
|
|
126
|
-
// 防 upsert 快照回退到旧过期值导致首个 tick 立即触发
|
|
127
|
-
if (op.nextRunAt !== undefined) task.nextRunAt = op.nextRunAt
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** delete → 从 Map 移除;taskId 不存在时 no-op。 */
|
|
131
|
-
function applyDelete(tasks: Map<string, ScheduledTask>, op: Extract<SchedulerEntryOp, { op: 'delete' }>): void {
|
|
132
|
-
tasks.delete(op.taskId)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* 步骤② fork owner 过滤(gap1):折叠完成后整体移除非 owner 任务。
|
|
137
|
-
* currentSessionFile 为 undefined(--no-session 模式)时,所有带 ownerSessionFile 的任务都被移除
|
|
138
|
-
* (无 session 文件可归属,等同空 session)——该模式下 addTask 用 '' 兜底 owner,此处比较仍一致。
|
|
139
|
-
*/
|
|
140
|
-
function filterNonOwnerTasks(tasks: Map<string, ScheduledTask>, currentSessionFile: string | undefined): void {
|
|
141
|
-
for (const [id, task] of tasks) {
|
|
142
|
-
if (task.ownerSessionFile !== currentSessionFile) {
|
|
143
|
-
tasks.delete(id)
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* 类型守卫:判断 entry.data 是否为合法 SchedulerEntryOp——按 op 变体校验必填字段,
|
|
150
|
-
* 不只校验 op 判别值(MF-2 修复)。仅按判别值收窄时,损坏 entry `{op:'upsert'}`(缺 task)
|
|
151
|
-
* 会通过守卫后在 snapshotToTask(undefined) 抛 TypeError,被外层整体 catch 捕获 → 返回空 Map
|
|
152
|
-
* → 一条损坏 entry 清空全部任务(违反「只忽略该条」的设计意图)。
|
|
153
|
-
*
|
|
154
|
-
* 变体必填校验:
|
|
155
|
-
* upsert → taskId: string + task: 非 null 对象(嵌套字段损坏由重放循环内逐条 try/catch 兜底跳过)
|
|
156
|
-
* advance → taskId: string + nextRunAt: number + at: number + status: 'success'
|
|
157
|
-
* toggle → taskId: string + enabled: boolean(nextRunAt?: number 可选——合法 toggle 可缺省,不能误拒)
|
|
158
|
-
* delete → taskId: string
|
|
159
|
-
* 校验不过 → 返回 false,该条被跳过(不触发整体 catch)。
|
|
160
|
-
*
|
|
161
|
-
* 替代 `as { op?: unknown }` 结构断言(taste/no-unsafe-cast):守卫函数内做真实校验,
|
|
162
|
-
* Record<string, unknown> 配合 typeof/值校验,不是「无校验断言」。
|
|
163
|
-
*/
|
|
164
|
-
function isSchedulerEntryOp(data: unknown): data is SchedulerEntryOp {
|
|
165
|
-
if (!data || typeof data !== 'object') return false
|
|
166
|
-
const record = data as Record<string, unknown>
|
|
167
|
-
const op = record.op
|
|
168
|
-
if (op === 'upsert') {
|
|
169
|
-
return (
|
|
170
|
-
typeof record.taskId === 'string' &&
|
|
171
|
-
typeof record.task === 'object' &&
|
|
172
|
-
record.task !== null
|
|
173
|
-
)
|
|
174
|
-
}
|
|
175
|
-
if (op === 'advance') {
|
|
176
|
-
return (
|
|
177
|
-
typeof record.taskId === 'string' &&
|
|
178
|
-
typeof record.nextRunAt === 'number' &&
|
|
179
|
-
typeof record.at === 'number' &&
|
|
180
|
-
record.status === 'success'
|
|
181
|
-
)
|
|
182
|
-
}
|
|
183
|
-
if (op === 'toggle') {
|
|
184
|
-
return (
|
|
185
|
-
typeof record.taskId === 'string' &&
|
|
186
|
-
typeof record.enabled === 'boolean' &&
|
|
187
|
-
(record.nextRunAt === undefined || typeof record.nextRunAt === 'number')
|
|
188
|
-
)
|
|
189
|
-
}
|
|
190
|
-
if (op === 'delete') {
|
|
191
|
-
return typeof record.taskId === 'string'
|
|
192
|
-
}
|
|
193
|
-
return false
|
|
194
|
-
}
|
|
23
|
+
export type { SchedulerEntryLike } from '@zhushanwen/extension-protocol'
|
package/src/types.ts
CHANGED
|
@@ -1,103 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
export type ScheduleSpec =
|
|
6
|
-
| { mode: 'cron'; cronExpression: string }
|
|
7
|
-
| { mode: 'interval'; intervalMs: number }
|
|
8
|
-
|
|
9
|
-
// ── 任务 ──
|
|
10
|
-
|
|
11
|
-
export type TaskKind = 'once' | 'recurring'
|
|
12
|
-
export type TaskStatus = 'success' | 'failed'
|
|
13
|
-
|
|
14
|
-
export interface ScheduledTask {
|
|
15
|
-
id: string // 8 位 hex,自动生成
|
|
16
|
-
name: string // 可读名称(用户指定或从 prompt 自动截取前 30 字)
|
|
17
|
-
prompt: string // 到期时注入的 message
|
|
18
|
-
kind: TaskKind
|
|
19
|
-
schedule: ScheduleSpec // once 时 intervalMs = delayMs
|
|
20
|
-
model?: string // 任务执行模型(scoped model id,provider/model);undefined = 跟随会话当前模型
|
|
21
|
-
enabled: boolean
|
|
22
|
-
createdAt: number
|
|
23
|
-
nextRunAt: number
|
|
24
|
-
expiresAt?: number // undefined = 永不过期
|
|
25
|
-
runCount: number
|
|
26
|
-
lastRunAt?: number
|
|
27
|
-
lastStatus?: TaskStatus
|
|
28
|
-
lastError?: string // 最近一次失败原因(cron 失效 / appendEntry 失败)
|
|
29
|
-
history: ExecutionRecord[] // 最近 HISTORY_LIMIT 条
|
|
30
|
-
ownerSessionFile?: string // append-only owner:记录任务创建时所属的 session JSONL,fork 重放时按此过滤非 owner 任务(gap1)
|
|
31
|
-
pending?: boolean // 运行时标记:到期待 dispatch(非持久化语义,勿与 TaskStatus 混淆)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface ExecutionRecord {
|
|
35
|
-
at: number
|
|
36
|
-
status: TaskStatus
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* history 裁剪上限(ext-simplify-08 L5 单点):runtime 侧 dispatch 累积
|
|
41
|
-
* (onDispatchSuccess)与 replay 侧 advance 折叠共用同一上限——
|
|
42
|
-
* 双处各持字面量会漂移(曾 runtime.ts / replay.ts 各写一份 20),收敛到 types.ts 单点导出。
|
|
43
|
-
*/
|
|
44
|
-
export const HISTORY_LIMIT = 20
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 追加执行记录并按 HISTORY_LIMIT 裁剪(ext-simplify-17 B4 单点):runtime 侧 dispatch
|
|
48
|
-
* 累积与 replay 侧 advance 折叠共用的 push+trim——三处内联时与上限字面量同源存在
|
|
49
|
-
* 「漏改一处」漂移风险,随上限一并收敛。
|
|
50
|
-
*/
|
|
51
|
-
export function appendExecutionRecord(task: ScheduledTask, at: number, status: TaskStatus): void {
|
|
52
|
-
task.history.push({ at, status })
|
|
53
|
-
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ── CustomEntry event sourcing(append-only 任务存储)──
|
|
1
|
+
// entry 契约与折叠器单源于 @zhushanwen/extension-protocol(U2 折叠器下沉:扩展与 taiji plugin
|
|
2
|
+
// 同源消费,杜绝双实现漂移)。本文件保持既有 import 路径不变(re-export),仅保留
|
|
3
|
+
// 扩展侧写面私有物(toTaskSnapshot / 旧 store 形状 / 工具参数 / ack 机制类型)。
|
|
57
4
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
* backend.appendEntry 写入 / replay.foldEntries 过滤 / importer 旧 store 导入三处共用——
|
|
61
|
-
* 各持字面量时「改一处漏两处」会使写入与重放识别脱节(写入新标识、重放不认,任务静默丢失)。
|
|
62
|
-
*/
|
|
63
|
-
export const TASK_ENTRY_TYPE = 'pi-scheduler:task'
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* upsert op 携带的全量任务快照(不含 ownerSessionFile / pending)。
|
|
67
|
-
* ownerSessionFile 放在 op 顶层(见 SchedulerEntryOp.upsert),
|
|
68
|
-
* pending 是运行时标记不持久化。其余字段与 ScheduledTask 对齐。
|
|
69
|
-
*/
|
|
70
|
-
export interface TaskSnapshot {
|
|
71
|
-
id: string
|
|
72
|
-
name: string
|
|
73
|
-
prompt: string
|
|
74
|
-
kind: TaskKind
|
|
75
|
-
schedule: ScheduleSpec
|
|
76
|
-
model?: string
|
|
77
|
-
enabled: boolean
|
|
78
|
-
createdAt: number
|
|
79
|
-
nextRunAt: number
|
|
80
|
-
expiresAt?: number
|
|
81
|
-
runCount: number
|
|
82
|
-
lastRunAt?: number
|
|
83
|
-
lastStatus?: TaskStatus
|
|
84
|
-
lastError?: string
|
|
85
|
-
history: ExecutionRecord[]
|
|
86
|
-
}
|
|
5
|
+
// ack 覆写 config 类型同源自 pi(文件尾 SchedulerCurrentModel / SchedulerProviderOverride 消费)
|
|
6
|
+
import type { ProviderConfig } from '@earendil-works/pi-coding-agent'
|
|
87
7
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
8
|
+
export {
|
|
9
|
+
appendExecutionRecord,
|
|
10
|
+
HISTORY_LIMIT,
|
|
11
|
+
snapshotToTask,
|
|
12
|
+
TASK_ENTRY_TYPE,
|
|
13
|
+
} from '@zhushanwen/extension-protocol'
|
|
14
|
+
export type {
|
|
15
|
+
ExecutionRecord,
|
|
16
|
+
ScheduleSpec,
|
|
17
|
+
ScheduledTask,
|
|
18
|
+
SchedulerEntryOp,
|
|
19
|
+
TaskKind,
|
|
20
|
+
TaskSnapshot,
|
|
21
|
+
TaskStatus,
|
|
22
|
+
} from '@zhushanwen/extension-protocol'
|
|
23
|
+
|
|
24
|
+
// 本地保留面(写侧 helper / 旧 store 形状 / 工具参数)签名引用的类型——re-export 不引入作用域,需显式 import
|
|
25
|
+
import type { ScheduledTask, TaskKind, TaskSnapshot } from '@zhushanwen/extension-protocol'
|
|
101
26
|
|
|
102
27
|
/**
|
|
103
28
|
* ScheduledTask → TaskSnapshot(ext-simplify-17 B2 canonical,解构剥离式):剥离
|
|
@@ -114,31 +39,6 @@ export function toTaskSnapshot(task: ScheduledTask): TaskSnapshot {
|
|
|
114
39
|
return { ...rest, history: history.slice() }
|
|
115
40
|
}
|
|
116
41
|
|
|
117
|
-
/**
|
|
118
|
-
* TaskSnapshot → ScheduledTask(replay 侧重建):pending 不持久化故不恢复,
|
|
119
|
-
* ownerSessionFile 由调用方(replay applyUpsert)从 op 顶层补回。history 逐项深拷贝——
|
|
120
|
-
* 避免恢复出的运行时 task 与快照共享数组/元素引用(task 后续被 mutate 时不污染快照)。
|
|
121
|
-
*/
|
|
122
|
-
export function snapshotToTask(snapshot: TaskSnapshot): ScheduledTask {
|
|
123
|
-
return {
|
|
124
|
-
id: snapshot.id,
|
|
125
|
-
name: snapshot.name,
|
|
126
|
-
prompt: snapshot.prompt,
|
|
127
|
-
kind: snapshot.kind,
|
|
128
|
-
schedule: snapshot.schedule,
|
|
129
|
-
model: snapshot.model,
|
|
130
|
-
enabled: snapshot.enabled,
|
|
131
|
-
createdAt: snapshot.createdAt,
|
|
132
|
-
nextRunAt: snapshot.nextRunAt,
|
|
133
|
-
expiresAt: snapshot.expiresAt,
|
|
134
|
-
runCount: snapshot.runCount,
|
|
135
|
-
lastRunAt: snapshot.lastRunAt,
|
|
136
|
-
lastStatus: snapshot.lastStatus,
|
|
137
|
-
lastError: snapshot.lastError,
|
|
138
|
-
history: snapshot.history.map(h => ({ ...h })),
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
42
|
// ── 持久化 ──
|
|
143
43
|
|
|
144
44
|
export interface SchedulerStore {
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* U4_DISPATCH_INFLIGHT:调用方 in-flight 守卫验收
|
|
3
|
-
*
|
|
4
|
-
* 两个子用例:
|
|
5
|
-
* (1) 同一 taskId 的 dispatchTask 并发调用 → 第二次立即返回 false + logger.warn
|
|
6
|
-
* (2) 第一次 dispatchTask 完成后(finally 清除)→ 第二次正常执行
|
|
7
|
-
*
|
|
8
|
-
* 断言 backend.sendMessage 调用总次数为 1(拦截场景)或 2(串行场景)。
|
|
9
|
-
*/
|
|
10
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
-
|
|
12
|
-
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
13
|
-
|
|
14
|
-
import { MockSchedulerBackend } from './mock-backend.js'
|
|
15
|
-
import { SchedulerRuntime } from '../runtime.js'
|
|
16
|
-
|
|
17
|
-
describe('U4_DISPATCH_INFLIGHT: 调用方 in-flight 守卫', () => {
|
|
18
|
-
it('(1) 同一 taskId 并发 dispatch → 第二次被拦截(send 只调 1 次 + warn)', async () => {
|
|
19
|
-
const warnSpy = vi.spyOn(getLogger('scheduler'), 'warn').mockImplementation(() => {})
|
|
20
|
-
|
|
21
|
-
// 可控延迟的 sendMessage:让第一次 dispatch 挂起
|
|
22
|
-
let resolveSend: (() => void) | undefined
|
|
23
|
-
const sendPromise = new Promise<void>(resolve => {
|
|
24
|
-
resolveSend = resolve
|
|
25
|
-
})
|
|
26
|
-
const backend = new MockSchedulerBackend()
|
|
27
|
-
backend.sendMessage = vi.fn(() => sendPromise)
|
|
28
|
-
const runtime = new SchedulerRuntime(backend)
|
|
29
|
-
|
|
30
|
-
const task = await runtime.addTask('inflight-test', { mode: 'interval', intervalMs: 60_000 })
|
|
31
|
-
|
|
32
|
-
// 第一次 dispatch(挂起)
|
|
33
|
-
const first = runtime.dispatchTask(task)
|
|
34
|
-
// sendMessage 被调 1 次
|
|
35
|
-
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
36
|
-
|
|
37
|
-
// 第二次 dispatch(同一 taskId,在途被拦截)
|
|
38
|
-
const second = await runtime.dispatchTask(task)
|
|
39
|
-
expect(second).toBe(false)
|
|
40
|
-
|
|
41
|
-
// warn 包含 in-flight 提示
|
|
42
|
-
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
43
|
-
expect(warnText).toContain('already in flight')
|
|
44
|
-
|
|
45
|
-
// sendMessage 仍只有 1 次(拦截有效)
|
|
46
|
-
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
47
|
-
|
|
48
|
-
// 放行第一次 dispatch
|
|
49
|
-
resolveSend!()
|
|
50
|
-
await first
|
|
51
|
-
|
|
52
|
-
warnSpy.mockRestore()
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('(2) 第一次完成后 → 第二次 dispatchTask 正常执行(send 调 2 次)', async () => {
|
|
56
|
-
const backend = new MockSchedulerBackend()
|
|
57
|
-
const runtime = new SchedulerRuntime(backend)
|
|
58
|
-
|
|
59
|
-
const task = await runtime.addTask('serial-inflight', { mode: 'interval', intervalMs: 60_000 })
|
|
60
|
-
|
|
61
|
-
// 第一次 dispatch(串行等待完成)
|
|
62
|
-
const first = await runtime.dispatchTask(task)
|
|
63
|
-
expect(first).toBe(true)
|
|
64
|
-
expect(backend.sentMessages).toHaveLength(1)
|
|
65
|
-
|
|
66
|
-
// 第二次 dispatch(in-flight 已清除,正常执行)
|
|
67
|
-
// 需要重置 nextRunAt 让任务再次到期
|
|
68
|
-
task.nextRunAt = 0
|
|
69
|
-
const second = await runtime.dispatchTask(task)
|
|
70
|
-
expect(second).toBe(true)
|
|
71
|
-
|
|
72
|
-
// send 被调 2 次(串行完成)
|
|
73
|
-
expect(backend.sentMessages).toHaveLength(2)
|
|
74
|
-
})
|
|
75
|
-
})
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
// src/__tests__/index-session-start.test.ts
|
|
2
|
-
//
|
|
3
|
-
// F1 集成单测(crash-fix U4):session_start 多发/重入时先停上一代 runtime 的 tick interval。
|
|
4
|
-
// 排查结论:dispatch 的 await sendMessage 窗口与 session 替换交错时,旧 session_shutdown 可能
|
|
5
|
-
// 永远等不到 → 旧 30s tick timer 泄漏 → 下一 tick 的 refreshWidget 访问 stale ctx.ui 抛错 →
|
|
6
|
-
// unhandledRejection → pi 主进程 exit 1。F1 在 session_start 开头幂等 stopScheduler,从源头消灭。
|
|
7
|
-
//
|
|
8
|
-
// 行为断言口径(验收 U4):观测面 = onAfterTick → refreshWidget → ctx.ui.setWidget 的调用
|
|
9
|
-
// 序列。widget 推送修正(D1 指纹跳推 + D2 保活底线帧)后每次 tick 不再必然推帧——任务集
|
|
10
|
-
// 静态期间零推送、空任务集恒零推送,故本套件注入非空任务集、以「跨 10min 保活窗口必推
|
|
11
|
-
// 一帧」作为「该 runtime 的 timer 是否还在 tick」的行为观测面(保活帧间隔与 index.ts
|
|
12
|
-
// WIDGET_KEEPALIVE_INTERVAL_MS 同值 10min)。F1 缺失时旧 timer 跨窗口必多推一帧,即被捕获。
|
|
13
|
-
|
|
14
|
-
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
15
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
16
|
-
|
|
17
|
-
// 与 sdk-contract.test.ts 同款(MF-3):session_start 会真实执行 importLegacyStore(ctx.cwd, ...),
|
|
18
|
-
// 触碰用户真实 FS(~/.pi/agent/scheduler/... 的 renameSync/existsSync 探测)。mock 掉 importer
|
|
19
|
-
// 模块,装配路径仍被调用、FS 副作用为零。
|
|
20
|
-
vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
|
|
21
|
-
|
|
22
|
-
import schedulerExtension from '../index.js'
|
|
23
|
-
import { TASK_ENTRY_TYPE } from '../types.js'
|
|
24
|
-
|
|
25
|
-
const TICK_INTERVAL_MS = 30_000
|
|
26
|
-
/** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(常量不导出,测试锚定同值)。 */
|
|
27
|
-
const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
|
|
28
|
-
const BASE = Date.parse('2026-01-01T00:00:00Z')
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* 最小 fake pi:覆盖 index.ts factory + commands.ts 注册路径消费的 API 面
|
|
32
|
-
* (on / registerTool / registerCommand / sendMessage / appendEntry)。
|
|
33
|
-
* on 捕获事件 handler 供手动触发;sendMessage/appendEntry 为 vi.fn 兜底(本套件无任务 dispatch)。
|
|
34
|
-
*/
|
|
35
|
-
function createMockPi(): {
|
|
36
|
-
pi: ExtensionAPI
|
|
37
|
-
events: Map<string, (...args: unknown[]) => void>
|
|
38
|
-
} {
|
|
39
|
-
const events = new Map<string, (...args: unknown[]) => void>()
|
|
40
|
-
const pi = {
|
|
41
|
-
registerTool: vi.fn(),
|
|
42
|
-
registerCommand: vi.fn(),
|
|
43
|
-
on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
|
|
44
|
-
sendMessage: vi.fn(),
|
|
45
|
-
appendEntry: vi.fn(),
|
|
46
|
-
} as unknown as ExtensionAPI
|
|
47
|
-
return { pi, events }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* 构造远期到期的启用任务 upsert entry(owner = sessionFile,避免 fork owner 过滤);
|
|
52
|
-
* nextRunAt 取 BASE + 1h,测试时间窗(分钟级)内无 dispatch 噪音,任务集保持静态。
|
|
53
|
-
*/
|
|
54
|
-
function taskEntry(sessionFile: string) {
|
|
55
|
-
return {
|
|
56
|
-
type: 'custom',
|
|
57
|
-
customType: TASK_ENTRY_TYPE,
|
|
58
|
-
data: {
|
|
59
|
-
op: 'upsert',
|
|
60
|
-
taskId: 'aaaa1111',
|
|
61
|
-
ownerSessionFile: sessionFile,
|
|
62
|
-
task: {
|
|
63
|
-
id: 'aaaa1111',
|
|
64
|
-
name: 'keepalive probe',
|
|
65
|
-
prompt: 'probe prompt',
|
|
66
|
-
kind: 'recurring',
|
|
67
|
-
schedule: { mode: 'interval', intervalMs: 3_600_000 },
|
|
68
|
-
enabled: true,
|
|
69
|
-
createdAt: BASE,
|
|
70
|
-
nextRunAt: BASE + 3_600_000,
|
|
71
|
-
runCount: 0,
|
|
72
|
-
history: [],
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* 最小 fake ctx:覆盖 session_start 装配链读到的全部字段——PiSchedulerBackend 构造
|
|
80
|
-
* (sessionManager:getEntries 注入非空任务集)、runtime 装配、refreshWidget(ui.setWidget)。
|
|
81
|
-
* setWidget 以独立引用导出:session_start 初始渲染 + 保活帧是「哪个 runtime 的 timer 还在
|
|
82
|
-
* tick」的行为观测面(每个实际推送恰一次调用)。
|
|
83
|
-
*/
|
|
84
|
-
function createFakeCtx(sessionFile: string): {
|
|
85
|
-
ctx: ExtensionContext
|
|
86
|
-
setWidget: ReturnType<typeof vi.fn>
|
|
87
|
-
} {
|
|
88
|
-
const setWidget = vi.fn()
|
|
89
|
-
const ctx = {
|
|
90
|
-
cwd: '/test-index-session-start',
|
|
91
|
-
isIdle: () => true,
|
|
92
|
-
hasPendingMessages: () => false,
|
|
93
|
-
ui: { setWidget },
|
|
94
|
-
sessionManager: {
|
|
95
|
-
getEntries: () => [taskEntry(sessionFile)],
|
|
96
|
-
getSessionFile: () => sessionFile,
|
|
97
|
-
},
|
|
98
|
-
} as unknown as ExtensionContext
|
|
99
|
-
return { ctx, setWidget }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
103
|
-
beforeEach(() => {
|
|
104
|
-
vi.useFakeTimers()
|
|
105
|
-
vi.setSystemTime(new Date(BASE))
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
afterEach(() => {
|
|
109
|
-
vi.useRealTimers()
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('U4: 双 session_start 后旧 timer 已停——跨保活窗口只有新 runtime 推帧', async () => {
|
|
113
|
-
const { pi, events } = createMockPi()
|
|
114
|
-
schedulerExtension(pi)
|
|
115
|
-
const sessionStart = events.get('session_start')
|
|
116
|
-
expect(sessionStart).toBeDefined()
|
|
117
|
-
|
|
118
|
-
// 第一次 session_start:runtime1 + timer1 启动,初始渲染 1 次
|
|
119
|
-
const first = createFakeCtx('/test/session-1.json')
|
|
120
|
-
sessionStart!({ type: 'session_start', reason: 'startup' }, first.ctx)
|
|
121
|
-
expect(first.setWidget).toHaveBeenCalledTimes(1)
|
|
122
|
-
|
|
123
|
-
// 前置因果锚点:静置越过保活窗口,timer1 推一帧保活帧(排除「timer 从未启动」的假绿;
|
|
124
|
-
// 也锁定跳推行为——窗口内其余 tick 零推送)
|
|
125
|
-
await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
|
|
126
|
-
expect(first.setWidget).toHaveBeenCalledTimes(2)
|
|
127
|
-
|
|
128
|
-
// 第二次 session_start(session 替换):F1 在装配新 runtime 前停掉 timer1
|
|
129
|
-
const second = createFakeCtx('/test/session-2.json')
|
|
130
|
-
sessionStart!({ type: 'session_start', reason: 'new_session' }, second.ctx)
|
|
131
|
-
expect(second.setWidget).toHaveBeenCalledTimes(1) // runtime2 初始渲染(实例态重置,首帧必推)
|
|
132
|
-
|
|
133
|
-
// 行为断言(验收口径):再静置一个保活窗口,只有 runtime2 的 timer 触发推送——
|
|
134
|
-
// F1 缺失时 timer1/timer2 都活着,first 与 second 各再 +1
|
|
135
|
-
await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
|
|
136
|
-
expect(second.setWidget).toHaveBeenCalledTimes(2) // 恰 +1:新 runtime 正常调度
|
|
137
|
-
expect(first.setWidget).toHaveBeenCalledTimes(2) // 旧 runtime 的 tick 不再发生(timer 已停)
|
|
138
|
-
})
|
|
139
|
-
})
|