@zhushanwen/pi-scheduler 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * U6c:backend.loadTasks 折叠接活跃路径裁剪 + session_tree handler 重折叠。
3
+ *
4
+ * 从 leafId 沿 parentId 回溯得活跃路径,被撤子树的任务 op entry 不再恢复 pending
5
+ * 定时任务——到点不触发真实 turn(A14 可观察验收锚)。
6
+ */
7
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
8
+ import { describe, expect, it, vi } from 'vitest'
9
+
10
+ import { PiSchedulerBackend, type SchedulerBackendCtx } from '../backend.js'
11
+ import { SchedulerRuntime, TICK_INTERVAL_MS } from '../runtime.js'
12
+ import { TASK_ENTRY_TYPE } from '../types.js'
13
+ import type { ScheduledTask, SchedulerEntryOp, TaskSnapshot } from '../types.js'
14
+
15
+ const SESSION_FILE = '/test/session.json'
16
+
17
+ // ── fixture ─────────────────────────────────────────
18
+
19
+ interface BranchEntry {
20
+ id: string
21
+ parentId: string | null
22
+ type: string
23
+ customType?: string
24
+ data?: unknown
25
+ }
26
+
27
+ /** 构造 base task 快照(upsert op 用,字段集对齐 replay.test.ts 惯例) */
28
+ function snapshot(overrides: Partial<TaskSnapshot> = {}): TaskSnapshot {
29
+ return {
30
+ id: 't1',
31
+ name: 'test',
32
+ prompt: 't1 prompt',
33
+ kind: 'recurring',
34
+ schedule: { mode: 'interval', intervalMs: 60000 },
35
+ enabled: true,
36
+ createdAt: 0,
37
+ nextRunAt: 100,
38
+ runCount: 0,
39
+ history: [],
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ function upsertOp(taskId: string, overrides: Partial<TaskSnapshot> = {}): SchedulerEntryOp {
45
+ return {
46
+ op: 'upsert',
47
+ taskId,
48
+ ownerSessionFile: SESSION_FILE,
49
+ task: snapshot({ id: taskId, prompt: `${taskId} prompt`, ...overrides }),
50
+ }
51
+ }
52
+
53
+ function taskOpEntry(id: string, parentId: string | null, op: SchedulerEntryOp): BranchEntry {
54
+ return { type: 'custom', id, parentId, customType: TASK_ENTRY_TYPE, data: op }
55
+ }
56
+
57
+ /** 撤回后真实形态:label entry 落文件尾(parentId = 回退后叶子),leafId 指向它 */
58
+ function labelEntry(id: string, parentId: string | null): BranchEntry {
59
+ return { type: 'label', id, parentId, targetId: 'u-x', label: 'taiji:revoked' }
60
+ }
61
+
62
+ /** 可变树状态的 ctx(模拟撤回前后 swap:entries + leafId 经闭包读取) */
63
+ function makeCtx(initialEntries: BranchEntry[], initialLeafId: string | null) {
64
+ let entries = initialEntries
65
+ let leafId = initialLeafId
66
+ const ctx: SchedulerBackendCtx = {
67
+ sessionManager: {
68
+ getEntries: () => entries.slice(),
69
+ getSessionFile: () => SESSION_FILE,
70
+ getLeafId: () => leafId,
71
+ },
72
+ }
73
+ return {
74
+ ctx,
75
+ setTree(nextEntries: BranchEntry[], nextLeafId: string | null): void {
76
+ entries = nextEntries
77
+ leafId = nextLeafId
78
+ },
79
+ }
80
+ }
81
+
82
+ /** 捕获 pi.on 注册的 handler + 副作用记录面(sendMessage / appendEntry vi.fn)。
83
+ * on 字段仅满足 ExtensionAPI 结构完整性:session_tree 注册面已迁至 factory 顶层
84
+ * (index.ts),backend 构造不再调 pi.on(注册面契约见 session-tree-registration.test.ts)。 */
85
+ function makePi() {
86
+ const handlers = new Map<string, (...args: unknown[]) => unknown>()
87
+ const sendMessage = vi.fn(async () => {})
88
+ const appendEntry = vi.fn()
89
+ const registerProvider = vi.fn()
90
+ const unregisterProvider = vi.fn()
91
+ const pi = {
92
+ on: (event: string, handler: (...args: unknown[]) => unknown) => {
93
+ handlers.set(event, handler)
94
+ },
95
+ sendMessage,
96
+ appendEntry,
97
+ registerProvider,
98
+ unregisterProvider,
99
+ }
100
+ return { pi: pi as unknown as ExtensionAPI, handlers, sendMessage, appendEntry }
101
+ }
102
+
103
+ // ── loadTasks 活跃路径裁剪(U6c)─────────────────────
104
+
105
+ describe('PiSchedulerBackend.loadTasks 活跃路径裁剪(U6c)', () => {
106
+ it('有分支 fixture:被撤子树的任务 op 不进重建态', () => {
107
+ // 树:e1(t1 upsert,active);e1 → e3(t2 upsert,revoked-branch 物理后写);
108
+ // 撤回落 label 锚 L(parentId = 回退后叶子 e1),leafId = L → 活跃路径 {L, e1}
109
+ const entries = [
110
+ taskOpEntry('e1', null, upsertOp('t1')),
111
+ taskOpEntry('e3', 'e1', upsertOp('t2')),
112
+ labelEntry('L', 'e1'),
113
+ ]
114
+ const { ctx } = makeCtx(entries, 'L')
115
+
116
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
117
+
118
+ const ids = tasks.map(t => t.id)
119
+ expect(ids).toContain('t1')
120
+ // 反向断言:被撤子树任务不恢复 pending(无裁剪时会从 e3 折叠复活)
121
+ expect(ids).not.toContain('t2')
122
+ })
123
+
124
+ it('同任务跨分支:取活跃路径内最新 upsert(分支上被撤的后续推进不参与折叠)', () => {
125
+ const entries = [
126
+ taskOpEntry('e1', null, upsertOp('t1', { prompt: 'old prompt' })),
127
+ taskOpEntry('e2', 'e1', upsertOp('t1', { prompt: 'active-branch prompt' })),
128
+ taskOpEntry('e3', 'e1', upsertOp('t1', { prompt: 'revoked-branch prompt' })),
129
+ labelEntry('L', 'e2'),
130
+ ]
131
+ const { ctx } = makeCtx(entries, 'L')
132
+
133
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
134
+
135
+ expect(tasks).toHaveLength(1)
136
+ expect(tasks[0]!.prompt).toBe('active-branch prompt')
137
+ })
138
+
139
+ it('无分支回归:leafId = 文件尾 → 线性折叠最新(现行为不变)', () => {
140
+ const entries = [
141
+ taskOpEntry('e1', null, upsertOp('t1', { prompt: 'v1' })),
142
+ taskOpEntry('e2', 'e1', upsertOp('t1', { prompt: 'v2' })),
143
+ ]
144
+ const { ctx } = makeCtx(entries, 'e2')
145
+
146
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
147
+
148
+ expect(tasks).toHaveLength(1)
149
+ expect(tasks[0]!.prompt).toBe('v2')
150
+ })
151
+
152
+ it('legacy fixture 回归:entry 无 id/parentId(duck-typed 最小形状)→ 线性全量折叠,行为与裁剪前一致', () => {
153
+ const entries = [
154
+ { type: 'custom', customType: TASK_ENTRY_TYPE, data: upsertOp('t1') },
155
+ { type: 'custom', customType: TASK_ENTRY_TYPE, data: upsertOp('t2') },
156
+ ]
157
+ const ctx: SchedulerBackendCtx = {
158
+ sessionManager: {
159
+ getEntries: () => entries.slice(),
160
+ getSessionFile: () => SESSION_FILE,
161
+ },
162
+ }
163
+
164
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
165
+
166
+ expect(tasks.map(t => t.id).sort()).toEqual(['t1', 't2'])
167
+ })
168
+
169
+ it('leafId 防御:null / 指向不存在 entry → 回退文件尾(撤回落 label 锚在尾,等价命中活跃路径)', () => {
170
+ const entries = [
171
+ taskOpEntry('e1', null, upsertOp('t1')),
172
+ taskOpEntry('e3', 'e1', upsertOp('t2')),
173
+ labelEntry('L', 'e1'),
174
+ ]
175
+
176
+ for (const leafId of [null, 'missing-id']) {
177
+ const { ctx } = makeCtx(entries, leafId)
178
+ const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
179
+ // 尾部锚 = label L(parentId=e1)→ 回溯 {L, e1},被撤子树 t2 仍被裁掉
180
+ expect(tasks.map(t => t.id)).toEqual(['t1'])
181
+ }
182
+ })
183
+ })
184
+
185
+ // ── session_tree handler 重折叠 + 到点不触发(U6c / A14)──
186
+
187
+ describe('session_tree 重折叠(U6c)', () => {
188
+ it('撤回后 session_tree → runtime 任务集重折叠(被撤任务移除);handler 本身零 dispatch 零 append', () => {
189
+ // 撤回前:线性文件(leaf=尾=e2),t1/t2 均在
190
+ const preRevoke = [
191
+ taskOpEntry('e1', null, upsertOp('t1')),
192
+ taskOpEntry('e2', 'e1', upsertOp('t2')),
193
+ ]
194
+ const { ctx, setTree } = makeCtx(preRevoke, null)
195
+ const { pi, sendMessage, appendEntry } = makePi()
196
+
197
+ const backend = new PiSchedulerBackend(ctx, pi)
198
+ const runtime = new SchedulerRuntime(backend)
199
+ runtime.loadTasks(backend.loadTasks())
200
+ expect(runtime.getTask('t1')).toBeDefined()
201
+ expect(runtime.getTask('t2')).toBeDefined()
202
+
203
+ // 撤回:树回退(label 锚落尾 + leafId 指向它)→ session_tree 事件 → 重折叠
204
+ // (生产注册面在 factory 顶层 index.ts,此处直接驱动委托体)
205
+ setTree([...preRevoke, labelEntry('L', 'e1')], 'L')
206
+ backend.refoldSessionTree()
207
+
208
+ expect(runtime.getTask('t1')).toBeDefined()
209
+ expect(runtime.getTask('t2')).toBeUndefined()
210
+
211
+ // 纯重建断言:handler 自身不 dispatch、不 append op(不污染回退后的新分支)
212
+ expect(sendMessage).not.toHaveBeenCalled()
213
+ expect(appendEntry).not.toHaveBeenCalled()
214
+ })
215
+
216
+ it('到点不触发(A14):重折叠后被撤子树任务 interval 到点无 turn;活跃路径任务照常 dispatch', async () => {
217
+ vi.useFakeTimers()
218
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
219
+ try {
220
+ // 两任务同在撤回前文件上,nextRunAt = now + 1s(首个 30s tick 即到期)
221
+ const dueAt = Date.now() + 1000
222
+ const preRevoke = [
223
+ taskOpEntry('e1', null, upsertOp('t1', { nextRunAt: dueAt })),
224
+ taskOpEntry('e2', 'e1', upsertOp('t2', { nextRunAt: dueAt })),
225
+ ]
226
+ const { ctx, setTree } = makeCtx(preRevoke, null)
227
+ const { pi, sendMessage } = makePi()
228
+
229
+ const backend = new PiSchedulerBackend(ctx, pi)
230
+ const runtime = new SchedulerRuntime(backend)
231
+ runtime.loadTasks(backend.loadTasks())
232
+
233
+ // 撤回回退到 e1(t2 所在子树被移出活跃路径)
234
+ setTree([...preRevoke, labelEntry('L', 'e1')], 'L')
235
+ backend.refoldSessionTree()
236
+ expect(runtime.getTask('t2')).toBeUndefined()
237
+
238
+ // tick 常驻循环不动启停逻辑:重折叠后照常消费新任务集
239
+ runtime.startScheduler()
240
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
241
+
242
+ const prompts = sendMessage.mock.calls.map(call => (call[0] as { content: string }).content)
243
+ expect(prompts).toContain('t1 prompt')
244
+ // A14 验收锚:被撤子树任务到点不触发真实 turn
245
+ expect(prompts).not.toContain('t2 prompt')
246
+ expect(sendMessage).toHaveBeenCalledTimes(1)
247
+ } finally {
248
+ // tick interval 在 fake timers 域内清理,防泄漏到后续用例
249
+ vi.clearAllTimers()
250
+ vi.useRealTimers()
251
+ }
252
+ })
253
+ })
@@ -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,17 +68,18 @@ 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
  })
72
78
 
73
79
  import schedulerExtension from '../index.js'
80
+ import { TICK_INTERVAL_MS } from '../runtime.js'
74
81
  import { TASK_ENTRY_TYPE } from '../types.js'
75
82
 
76
- const TICK_INTERVAL_MS = 30_000
77
- /** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(常量不导出,测试锚定同值)。 */
78
- const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
79
-
80
83
  /**
81
84
  * 最小 fake pi:覆盖 factory 消费的 API 面
82
85
  * (on 捕获事件 handler 供手动触发;registerTool/registerCommand/sendMessage/appendEntry 兜底)。
@@ -207,12 +210,9 @@ describe('G1: index.ts 代际接线(S9)', () => {
207
210
  // 下一 tick 的 refreshWidget 访问 stale ctx.ui 抛错 → unhandledRejection → pi 主进程 exit 1。
208
211
  // F1 在 session_start 开头幂等 stopScheduler,从源头消灭。
209
212
  //
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 跨窗口必多推一帧,即被捕获。
213
+ // 行为断言口径(验收 U4):观测面 = InstrumentedRuntime 的 stopScheduler 调用序列
214
+ // (widget 推送是 D1 指纹跳推语义——任务集静态期间零推送,不能靠推送计数区分新旧 timer;
215
+ // stopScheduler 白盒断言直接锁定「新装配前旧实例必须被停」的 F1 语义本身)。
216
216
  describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
217
217
  const BASE = Date.parse('2026-01-01T00:00:00Z')
218
218
 
@@ -244,7 +244,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
244
244
  }
245
245
  }
246
246
 
247
- /** 同 createFakeCtx,但注入非空任务集(保活帧只在非空任务集时推送,见上方口径注释)。 */
247
+ /** 同 createFakeCtx,但注入非空任务集(首帧渲染需要非空任务集可断言)。 */
248
248
  function createFakeCtxWithTasks(sessionFile: string): ExtensionContext {
249
249
  return {
250
250
  cwd: '/test-index-session-start',
@@ -261,6 +261,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
261
261
  beforeEach(() => {
262
262
  isCtxStaleCaptures.length = 0
263
263
  runtimeInstances.length = 0
264
+ stopSchedulerCalls.length = 0
264
265
  setSchedulerWidgetMock.mockClear()
265
266
  vi.useFakeTimers()
266
267
  vi.setSystemTime(new Date(BASE))
@@ -273,7 +274,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
273
274
  vi.useRealTimers()
274
275
  })
275
276
 
276
- it('U4: 双 session_start 后旧 timer 已停——跨保活窗口只有新 runtime 推帧', async () => {
277
+ it('U4: 双 session_start 后旧 timer 已停(stopScheduler 恰命中首个实例)', async () => {
277
278
  const { pi, events } = createMockPi()
278
279
  schedulerExtension(pi)
279
280
  const sessionStart = events.get('session_start')
@@ -282,19 +283,19 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
282
283
  // 第一次 session_start:runtime1 + timer1 启动,初始渲染 1 次
283
284
  sessionStart!({ type: 'session_start', reason: 'startup' }, createFakeCtxWithTasks('/test/session-1.json'))
284
285
  expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(1)
286
+ expect(runtimeInstances).toHaveLength(1)
285
287
 
286
- // 前置因果锚点:静置越过保活窗口,timer1 推一帧保活帧(排除「timer 从未启动」的假绿;
287
- // 也锁定跳推行为——窗口内其余 tick 零推送)
288
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
289
- expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2)
288
+ // 前置因果锚点:静置数个 tick——timer1 在 tick(无异常),任务集静态零推送(D1 跳推)
289
+ await vi.advanceTimersByTimeAsync(2 * TICK_INTERVAL_MS)
290
+ expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(1)
290
291
 
291
292
  // 第二次 session_start(session 替换):F1 在装配新 runtime 前停掉 timer1
292
293
  sessionStart!({ type: 'session_start', reason: 'new_session' }, createFakeCtxWithTasks('/test/session-2.json'))
293
- expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(3) // runtime2 初始渲染(实例态重置,首帧必推)
294
+ expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2) // runtime2 初始渲染(实例态重置,首帧必推)
295
+ expect(runtimeInstances).toHaveLength(2)
294
296
 
295
- // 行为断言(验收口径):再静置一个保活窗口,只有 runtime2 的 timer 触发推送——恰 +1;
296
- // F1 缺失时 timer1/timer2 都活着,此处为 +2
297
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
298
- expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(4)
297
+ // 行为断言(验收口径):stopScheduler 恰被调一次且命中 runtime1;
298
+ // F1 缺失时旧 timer 不被停(数组为空或不含 runtime1)
299
+ expect(stopSchedulerCalls).toEqual([runtimeInstances[0]])
299
300
  })
300
301
  })
@@ -11,7 +11,7 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
11
11
  }))
12
12
 
13
13
  import { MockSchedulerBackend } from './mock-backend.js'
14
- import { SchedulerRuntime } from '../runtime.js'
14
+ import { TICK_INTERVAL_MS, SchedulerRuntime } from '../runtime.js'
15
15
 
16
16
  // MockSchedulerBackend 零 FS 副作用:runtime 不再触碰 store,无需 mock store.js。
17
17
 
@@ -48,16 +48,8 @@ describe('SchedulerRuntime', () => {
48
48
  })
49
49
 
50
50
  describe('listTasks', () => {
51
- it('returns tasks sorted by nextRunAt', async () => {
52
- await runtime.addTask('task 1', { mode: 'interval', intervalMs: 60000 })
53
- await runtime.addTask('task 2', { mode: 'interval', intervalMs: 30000 })
54
- const tasks = runtime.listTasks()
55
- expect(tasks).toHaveLength(2)
56
- // 30s interval 的 nextRunAt 早于 60s 的,应排前
57
- expect(tasks[0]!.nextRunAt).toBeLessThan(tasks[1]!.nextRunAt)
58
- })
59
-
60
- // 强化断言:30s 任务 nextRunAt 更小(更早),应是 listTasks()[0]
51
+ // 排序契约:30s 任务 nextRunAt 更小(更早),应是 listTasks()[0]
52
+ //(含排序断言全集:tasks[0].id 定位 + nextRunAt 严格小于)
61
53
  it('orders shorter-interval task first', async () => {
62
54
  const t60 = await runtime.addTask('60s', { mode: 'interval', intervalMs: 60000 })
63
55
  const t30 = await runtime.addTask('30s', { mode: 'interval', intervalMs: 30000 })
@@ -115,7 +107,9 @@ describe('SchedulerRuntime', () => {
115
107
  const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
116
108
  await runtime.dispatchTask(task)
117
109
  expect(backend.sentMessages).toHaveLength(1)
118
- expect(backend.sentMessages[0]!.msg).toEqual(expect.objectContaining({ content: 'test' }))
110
+ expect(backend.sentMessages[0]!.msg).toEqual(expect.objectContaining({ content: 'test', customType: 'pi-scheduler:dispatched' }))
111
+ // steer 直投契约(runtime.ts sendMessage 第二参):丢 opts = pi idle 时定时任务静默不开轮次
112
+ expect(backend.sentMessages[0]!.opts).toEqual({ deliverAs: 'steer', triggerTurn: true })
119
113
  })
120
114
 
121
115
  it('skips disabled task', async () => {
@@ -565,7 +559,6 @@ describe('SchedulerRuntime', () => {
565
559
  // 自停;其他错误 → warn "tick error" 继续调度。修复前 tick 内异常无人接住 →
566
560
  // unhandledRejection → pi 主进程 exit 1。
567
561
  describe('tick 错误分诊(F2)', () => {
568
- const TICK_INTERVAL_MS = 30_000
569
562
 
570
563
  beforeEach(() => {
571
564
  vi.useFakeTimers()
@@ -639,7 +632,6 @@ describe('SchedulerRuntime', () => {
639
632
  // clearExtensionCache 后 jiti 重 import 全新模块环境,旧闭包的模块级代数冻结不再递增,
640
633
  // 只剩文案能识别 stale;模块级方案的装配级验证见 index-generation.test.ts factory 重跑用例)
641
634
  describe('G1: 代际检测分诊(S9)', () => {
642
- const TICK_INTERVAL_MS = 30_000
643
635
  let staleFlag: boolean
644
636
  let genRuntime: SchedulerRuntime
645
637
 
@@ -0,0 +1,152 @@
1
+ // src/__tests__/session-tree-registration.test.ts
2
+ //
3
+ // U6c session_tree 注册面契约:注册在 extension factory 顶层(每代恰一 handler),
4
+ // 同代 session_start 复发不累积。回归背景:注册曾在 PiSchedulerBackend 构造函数内,
5
+ // 而 pi RPC 模式下每次 session 替换 session_start 在同一代内触发两次
6
+ // (agent-session-runtime finishSessionReplacement 内部 rebindSession + RPC handler
7
+ // 再 rebindSession,0.84.4 实装核对),pi 的 on 是追加语义(loader.js list.push,
8
+ // 无去重无 off)——handler 随替换线性累积,残留旧代 backend 在旧 ctx 上重复执行。
9
+ //
10
+ // mock pi 的 on 必须是追加语义(list.push,对齐 pi 实装):覆盖式 Map.set 会掩盖
11
+ // 累积回归,正是本套件要防的形态。
12
+
13
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
14
+ import { describe, expect, it, vi } from 'vitest'
15
+
16
+ // MF-3(与 sdk-contract.test.ts 同款):mock importer,session_start 装配零 FS 副作用。
17
+ vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
18
+
19
+ // mock 共享 logger(与 widget-push.test.ts 同款):防测试触碰真实日志目录。
20
+ const { loggerMock } = vi.hoisted(() => ({
21
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
22
+ }))
23
+ vi.mock('@zhushanwen/pi-extension-logger', () => ({
24
+ getLogger: () => loggerMock,
25
+ createLogger: () => loggerMock,
26
+ setPiHandle: vi.fn(),
27
+ }))
28
+
29
+ import schedulerExtension from '../index.js'
30
+ import { TASK_ENTRY_TYPE } from '../types.js'
31
+
32
+ type Handler = (...args: unknown[]) => unknown
33
+
34
+ /** 追加语义 mock pi:on = list.push(对齐 pi loader.js on 实装,无去重无 off)。 */
35
+ function createAppendPi(): {
36
+ pi: ExtensionAPI
37
+ handlers: Map<string, Handler[]>
38
+ tools: { name: string; execute: (...args: unknown[]) => Promise<{ content: { text: string }[] }> }[]
39
+ } {
40
+ const handlers = new Map<string, Handler[]>()
41
+ const tools: { name: string; execute: (...args: unknown[]) => Promise<{ content: { text: string }[] }> }[] = []
42
+ const pi = {
43
+ registerTool: (tool: { name: string; execute: (...args: unknown[]) => Promise<{ content: { text: string }[] }> }) =>
44
+ tools.push(tool),
45
+ registerCommand: vi.fn(),
46
+ on: (event: string, handler: Handler) => {
47
+ const list = handlers.get(event) ?? []
48
+ list.push(handler)
49
+ handlers.set(event, list)
50
+ },
51
+ sendMessage: vi.fn(),
52
+ appendEntry: vi.fn(),
53
+ } as unknown as ExtensionAPI
54
+ return { pi, handlers, tools }
55
+ }
56
+
57
+ /** 远期到期的启用任务 upsert entry(owner = sessionFile;测试窗内无 dispatch 噪音)。 */
58
+ function taskEntry(sessionFile: string, taskId: string) {
59
+ return {
60
+ type: 'custom',
61
+ customType: TASK_ENTRY_TYPE,
62
+ data: {
63
+ op: 'upsert',
64
+ taskId,
65
+ ownerSessionFile: sessionFile,
66
+ task: {
67
+ id: taskId,
68
+ name: taskId,
69
+ prompt: `${taskId} prompt`,
70
+ kind: 'recurring',
71
+ schedule: { mode: 'interval', intervalMs: 3_600_000 },
72
+ enabled: true,
73
+ createdAt: 0,
74
+ nextRunAt: Date.now() + 3_600_000,
75
+ runCount: 0,
76
+ history: [],
77
+ },
78
+ },
79
+ }
80
+ }
81
+
82
+ /** fake ctx:entries 经容器可变(模拟撤回前后 swap);ui.setWidget 观测面。 */
83
+ function createFakeCtx(sessionFile: string, entriesBox: { entries: unknown[] }): ExtensionContext {
84
+ return {
85
+ cwd: '/test-tree-reg',
86
+ isIdle: () => true,
87
+ hasPendingMessages: () => false,
88
+ ui: { setWidget: vi.fn() },
89
+ sessionManager: {
90
+ getEntries: () => entriesBox.entries,
91
+ getSessionFile: () => sessionFile,
92
+ },
93
+ } as unknown as ExtensionContext
94
+ }
95
+
96
+ describe('session_tree 注册面(factory 顶层恰一,同代 session_start 复发不累积)', () => {
97
+ it('factory 运行恰注册 1 个 session_tree handler', () => {
98
+ const { pi, handlers } = createAppendPi()
99
+ schedulerExtension(pi)
100
+ expect(handlers.get('session_tree')).toHaveLength(1)
101
+ })
102
+
103
+ it('同代 session_start 复发(RPC 模式每次替换触发 2 次)→ session_tree handler 恒恰 1', () => {
104
+ const { pi, handlers } = createAppendPi()
105
+ schedulerExtension(pi)
106
+ const entriesBox = { entries: [] as unknown[] }
107
+ const ctx = createFakeCtx('/test/tree-reg.json', entriesBox)
108
+ const sessionStart = handlers.get('session_start')![0]!
109
+
110
+ sessionStart({ type: 'session_start', reason: 'new' }, ctx)
111
+ sessionStart({ type: 'session_start', reason: 'resume' }, ctx)
112
+
113
+ expect(handlers.get('session_tree')).toHaveLength(1)
114
+
115
+ // 收尾停当代 runtime 的 tick interval(fake timers 未启用,interval 需显式清)
116
+ void handlers.get('session_shutdown')![0]()
117
+ })
118
+
119
+ it('复发后 session_tree 委托当代 backend:撤回后重折叠反映到任务查询', () => {
120
+ const file = '/test/tree-reg-delegate.json'
121
+ const entriesBox = { entries: [taskEntry(file, 't1')] as unknown[] }
122
+ const { pi, handlers, tools } = createAppendPi()
123
+ schedulerExtension(pi)
124
+ const ctx = createFakeCtx(file, entriesBox)
125
+ const sessionStart = handlers.get('session_start')![0]!
126
+
127
+ // 同代复发两次:第 2 个 backend 为当代(旧代已被 stopScheduler + 代数判 stale)
128
+ sessionStart({ type: 'session_start', reason: 'new' }, ctx)
129
+ sessionStart({ type: 'session_start', reason: 'resume' }, ctx)
130
+
131
+ // 基线:t1 在当代任务集内
132
+ const control = tools.find(t => t.name === 'schedule_control')!
133
+
134
+ return control
135
+ .execute('call-base', { action: 'list' }, undefined, undefined, ctx)
136
+ .then(result => {
137
+ expect(result.content[0]!.text).toContain('t1')
138
+
139
+ // 撤回:entry 移出(getEntries 变化)→ session_tree 事件 → 当代 backend 重折叠
140
+ entriesBox.entries = []
141
+ handlers.get('session_tree')![0]!({ type: 'session_tree' }, ctx)
142
+
143
+ return control.execute('call-after', { action: 'list' }, undefined, undefined, ctx)
144
+ })
145
+ .then(result => {
146
+ expect(result.content[0]!.text).not.toContain('t1')
147
+ })
148
+ .finally(() => {
149
+ void handlers.get('session_shutdown')![0]()
150
+ })
151
+ })
152
+ })