@zhushanwen/pi-scheduler 0.9.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.9.1",
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.14.0",
55
- "@zhushanwen/pi-ext-guards": "0.4.1",
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.10.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 等行为由 index-session-start.test.ts U4 锚定,此处不重复)。
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
- // 与 index-session-start.test.ts 同款(MF-3):mock 掉 importer,装配路径仍被调用、FS 副作用为零。
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:与 index-session-start.test.ts 同款,覆盖 factory 消费的 API 面
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 ──
@@ -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
- })