@zhushanwen/pi-scheduler 0.9.1 → 0.9.3

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.
@@ -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,13 @@ vi.mock('../runtime.js', async (importOriginal) => {
69
71
  })
70
72
 
71
73
  import schedulerExtension from '../index.js'
72
-
73
- const TICK_INTERVAL_MS = 30_000
74
+ import { TICK_INTERVAL_MS } from '../runtime.js'
75
+ import { TASK_ENTRY_TYPE } from '../types.js'
76
+ /** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(常量不导出,测试锚定同值)。 */
77
+ const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
74
78
 
75
79
  /**
76
- * 最小 fake pi:与 index-session-start.test.ts 同款,覆盖 factory 消费的 API 面
80
+ * 最小 fake pi:覆盖 factory 消费的 API 面
77
81
  * (on 捕获事件 handler 供手动触发;registerTool/registerCommand/sendMessage/appendEntry 兜底)。
78
82
  */
79
83
  function createMockPi(): {
@@ -195,3 +199,101 @@ describe('G1: index.ts 代际接线(S9)', () => {
195
199
  }
196
200
  })
197
201
  })
202
+
203
+ // ── F1: session_start 停旧 runtime(crash-fix U4)──
204
+ // session_start 多发/重入时先停上一代 runtime 的 tick interval:dispatch 的 await sendMessage
205
+ // 窗口与 session 替换交错时,旧 session_shutdown 可能永远等不到 → 旧 30s tick timer 泄漏 →
206
+ // 下一 tick 的 refreshWidget 访问 stale ctx.ui 抛错 → unhandledRejection → pi 主进程 exit 1。
207
+ // F1 在 session_start 开头幂等 stopScheduler,从源头消灭。
208
+ //
209
+ // 行为断言口径(验收 U4):观测面 = refreshWidget 的推送调用序列(本文件 mock 环境下
210
+ // refreshWidget 走 setSchedulerWidgetMock,与真实 ctx.ui.setWidget 同一调用序列)。widget
211
+ // 推送修正(D1 指纹跳推 + D2 保活底线帧)后每次 tick 不再必然推帧——任务集静态期间零推送、
212
+ // 空任务集恒零推送,故注入非空任务集、以「跨 10min 保活窗口必推一帧」作为「该 runtime 的
213
+ // timer 是否还在 tick」的行为观测面(保活帧间隔与 index.ts WIDGET_KEEPALIVE_INTERVAL_MS
214
+ // 同值 10min)。F1 缺失时旧 timer 跨窗口必多推一帧,即被捕获。
215
+ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
216
+ const BASE = Date.parse('2026-01-01T00:00:00Z')
217
+
218
+ /**
219
+ * 构造远期到期的启用任务 upsert entry(owner = sessionFile,避免 fork owner 过滤);
220
+ * nextRunAt 取 BASE + 1h,测试时间窗(分钟级)内无 dispatch 噪音,任务集保持静态。
221
+ */
222
+ function taskEntry(sessionFile: string) {
223
+ return {
224
+ type: 'custom',
225
+ customType: TASK_ENTRY_TYPE,
226
+ data: {
227
+ op: 'upsert',
228
+ taskId: 'aaaa1111',
229
+ ownerSessionFile: sessionFile,
230
+ task: {
231
+ id: 'aaaa1111',
232
+ name: 'keepalive probe',
233
+ prompt: 'probe prompt',
234
+ kind: 'recurring',
235
+ schedule: { mode: 'interval', intervalMs: 3_600_000 },
236
+ enabled: true,
237
+ createdAt: BASE,
238
+ nextRunAt: BASE + 3_600_000,
239
+ runCount: 0,
240
+ history: [],
241
+ },
242
+ },
243
+ }
244
+ }
245
+
246
+ /** 同 createFakeCtx,但注入非空任务集(保活帧只在非空任务集时推送,见上方口径注释)。 */
247
+ function createFakeCtxWithTasks(sessionFile: string): ExtensionContext {
248
+ return {
249
+ cwd: '/test-index-session-start',
250
+ isIdle: () => true,
251
+ hasPendingMessages: () => false,
252
+ ui: { setWidget: vi.fn() },
253
+ sessionManager: {
254
+ getEntries: () => [taskEntry(sessionFile)],
255
+ getSessionFile: () => sessionFile,
256
+ },
257
+ } as unknown as ExtensionContext
258
+ }
259
+
260
+ beforeEach(() => {
261
+ isCtxStaleCaptures.length = 0
262
+ runtimeInstances.length = 0
263
+ setSchedulerWidgetMock.mockClear()
264
+ vi.useFakeTimers()
265
+ vi.setSystemTime(new Date(BASE))
266
+ })
267
+
268
+ afterEach(() => {
269
+ for (const rt of runtimeInstances) {
270
+ rt.stopScheduler()
271
+ }
272
+ vi.useRealTimers()
273
+ })
274
+
275
+ it('U4: 双 session_start 后旧 timer 已停——跨保活窗口只有新 runtime 推帧', async () => {
276
+ const { pi, events } = createMockPi()
277
+ schedulerExtension(pi)
278
+ const sessionStart = events.get('session_start')
279
+ expect(sessionStart).toBeDefined()
280
+
281
+ // 第一次 session_start:runtime1 + timer1 启动,初始渲染 1 次
282
+ sessionStart!({ type: 'session_start', reason: 'startup' }, createFakeCtxWithTasks('/test/session-1.json'))
283
+ expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(1)
284
+
285
+ // 前置因果锚点:静置越过保活窗口,timer1 推一帧保活帧(排除「timer 从未启动」的假绿;
286
+ // 也锁定跳推行为——窗口内其余 tick 零推送)
287
+ await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
288
+ expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2)
289
+
290
+ // 第二次 session_start(session 替换):F1 在装配新 runtime 前停掉 timer1
291
+ sessionStart!({ type: 'session_start', reason: 'new_session' }, createFakeCtxWithTasks('/test/session-2.json'))
292
+ expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(3) // runtime2 初始渲染(实例态重置,首帧必推)
293
+
294
+ // 行为断言(验收口径):再静置一个保活窗口,只有 runtime2 的 timer 触发推送——恰 +1;
295
+ // F1 缺失时 timer1/timer2 都活着,此处为 +2
296
+ await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
297
+ expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(4)
298
+ })
299
+ })
@@ -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 () => {
@@ -149,6 +143,8 @@ describe('SchedulerRuntime', () => {
149
143
  // 卡死)时,tick2 的 step2 再标 pending → step3 对同一 task 并发第二个 dispatch → 同一
150
144
  // prompt 双注入。守卫:同任务在途(Set<taskId>)
151
145
  // 时 skip 本轮并 warn;不同任务不受影响。
146
+ // 末两条为调用方 API 层验收:不经 tick,直接并发/串行调 dispatchTask,
147
+ // 验证守卫在调用方入口同样拦截(warn + 第二次 false)与完成后放行。
152
148
  describe('dispatchTask in-flight 守卫(R3-S1)', () => {
153
149
  beforeEach(() => {
154
150
  vi.useFakeTimers()
@@ -212,6 +208,54 @@ describe('SchedulerRuntime', () => {
212
208
  expect(task1.runCount).toBe(1)
213
209
  expect(task2.runCount).toBe(1)
214
210
  })
211
+
212
+ // ── 调用方 API 层 ──
213
+ it('调用方守卫:同一 taskId 并发 dispatchTask → 第二次被拦截(send 只调 1 次 + warn)', async () => {
214
+ loggerMock.warn.mockClear()
215
+ // 可控延迟的 sendMessage:让第一次 dispatch 挂起
216
+ let resolveSend: (() => void) | undefined
217
+ const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
218
+ backend.sendMessage = vi.fn(() => sendPromise)
219
+
220
+ const task = await runtime.addTask('inflight-test', { mode: 'interval', intervalMs: 60_000 })
221
+
222
+ // 第一次 dispatch(挂起),sendMessage 被调 1 次
223
+ const first = runtime.dispatchTask(task)
224
+ expect(backend.sendMessage).toHaveBeenCalledTimes(1)
225
+
226
+ // 第二次 dispatch(同一 taskId,在途被拦截)
227
+ const second = await runtime.dispatchTask(task)
228
+ expect(second).toBe(false)
229
+
230
+ // warn 包含 in-flight 提示
231
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
232
+ expect(warnText).toContain('already in flight')
233
+
234
+ // sendMessage 仍只有 1 次(拦截有效)
235
+ expect(backend.sendMessage).toHaveBeenCalledTimes(1)
236
+
237
+ // 放行第一次 dispatch
238
+ resolveSend!()
239
+ await first
240
+ })
241
+
242
+ it('调用方守卫:第一次完成后 → 第二次 dispatchTask 正常执行(send 调 2 次)', async () => {
243
+ const task = await runtime.addTask('serial-inflight', { mode: 'interval', intervalMs: 60_000 })
244
+
245
+ // 第一次 dispatch(串行等待完成)
246
+ const first = await runtime.dispatchTask(task)
247
+ expect(first).toBe(true)
248
+ expect(backend.sentMessages).toHaveLength(1)
249
+
250
+ // 第二次 dispatch(in-flight 已清除,正常执行)
251
+ // 需要重置 nextRunAt 让任务再次到期
252
+ task.nextRunAt = 0
253
+ const second = await runtime.dispatchTask(task)
254
+ expect(second).toBe(true)
255
+
256
+ // send 被调 2 次(串行完成)
257
+ expect(backend.sentMessages).toHaveLength(2)
258
+ })
215
259
  })
216
260
 
217
261
  // ── M10b:rate-limit ──
@@ -515,7 +559,6 @@ describe('SchedulerRuntime', () => {
515
559
  // 自停;其他错误 → warn "tick error" 继续调度。修复前 tick 内异常无人接住 →
516
560
  // unhandledRejection → pi 主进程 exit 1。
517
561
  describe('tick 错误分诊(F2)', () => {
518
- const TICK_INTERVAL_MS = 30_000
519
562
 
520
563
  beforeEach(() => {
521
564
  vi.useFakeTimers()
@@ -589,7 +632,6 @@ describe('SchedulerRuntime', () => {
589
632
  // clearExtensionCache 后 jiti 重 import 全新模块环境,旧闭包的模块级代数冻结不再递增,
590
633
  // 只剩文案能识别 stale;模块级方案的装配级验证见 index-generation.test.ts factory 重跑用例)
591
634
  describe('G1: 代际检测分诊(S9)', () => {
592
- const TICK_INTERVAL_MS = 30_000
593
635
  let staleFlag: boolean
594
636
  let genRuntime: SchedulerRuntime
595
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
+ })
@@ -35,9 +35,8 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
35
35
  }))
36
36
 
37
37
  import schedulerExtension from '../index.js'
38
+ import { TICK_INTERVAL_MS } from '../runtime.js'
38
39
  import { TASK_ENTRY_TYPE } from '../types.js'
39
-
40
- const TICK_INTERVAL_MS = 30_000
41
40
  /** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(10min;常量不导出,测试锚定同值)。 */
42
41
  const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
43
42
  const BASE = Date.parse('2026-01-01T00:00:00Z')
package/src/backend.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+ import { filterActivePath } from '@zhushanwen/pi-session-path'
2
3
 
3
4
  import { replayFoldEntries, type SchedulerEntryLike } from './replay.js'
4
5
  import { TASK_ENTRY_TYPE } from './types.js'
@@ -70,6 +71,14 @@ export interface SchedulerBackend {
70
71
  * 不触发任何写入(项目规则 #6)。
71
72
  */
72
73
  getEntries(): SchedulerEntryLike[]
74
+ /**
75
+ * U6c:session_tree(树回退)重折叠订阅。生产注册面在 extension factory 顶层
76
+ * (index.ts:pi.on('session_tree') 每代恰一,事件经 activeBackend 委托
77
+ * PiSchedulerBackend.refoldSessionTree),事件到达时按活跃路径重折叠任务集并回调
78
+ * listener——落点是 SchedulerRuntime 的内存任务 Map(runtime 构造时订阅,backend
79
+ * 不能反向持有 runtime)。测试替身可不实现(构造侧 ?. 订阅为 no-op,既有单测路径不变)。
80
+ */
81
+ onSessionTree?(listener: (tasks: ScheduledTask[]) => void): void
73
82
  }
74
83
 
75
84
  /**
@@ -81,6 +90,14 @@ export interface SchedulerBackendCtx {
81
90
  sessionManager: {
82
91
  getEntries(): SchedulerEntryLike[]
83
92
  getSessionFile(): string | undefined
93
+ /**
94
+ * 当前叶子 entry id(U6c 活跃路径回溯锚)。可选:真实 ExtensionContext 恒存在
95
+ * (ReadonlySessionManager Pick 清单含 getLeafId);最小 duck-typed ctx(单测)
96
+ * 缺失时按文件尾回退——session_start 时点与 pi 树重放规则(叶子 = 文件最后
97
+ * 一条 entry)等价,session_tree 时点由生产 ctx 携带精确锚。
98
+ * (pi 树重放规则实锚登记 pi-semantics PS-60:dist/core/session-manager.js:673 _buildIndex)
99
+ */
100
+ getLeafId?(): string | null
84
101
  }
85
102
  /**
86
103
  * 会话是否空闲(not streaming)。真实 ExtensionContext 保证存在(SDK
@@ -96,6 +113,14 @@ export interface SchedulerBackendCtx {
96
113
  model?: SchedulerCurrentModel
97
114
  }
98
115
 
116
+ // ── 活跃路径裁剪(U6c)──
117
+
118
+ // 活跃路径裁剪(leafId 沿 parentId 回溯,撤回后被撤子树的任务 op 不进折叠输入,
119
+ // 被撤任务不得恢复 pending、到点触发真实 turn——A14)收敛于
120
+ // @zhushanwen/pi-session-path 单一实现(四包同构副本收编,防御语义与回退口径见
121
+ // 该包 filterActivePath 注释;runtime 侧 entry-tree-builder 保持独立——extension
122
+ // 不能 import runtime 包)。
123
+
99
124
  // ── 生产实现 ──
100
125
 
101
126
  /**
@@ -111,22 +136,47 @@ export class PiSchedulerBackend implements SchedulerBackend {
111
136
  ExtensionAPI,
112
137
  'sendMessage' | 'appendEntry' | 'registerProvider' | 'unregisterProvider'
113
138
  >
139
+ private sessionTreeListener: ((tasks: ScheduledTask[]) => void) | null = null
114
140
 
115
141
  constructor(
116
142
  ctx: SchedulerBackendCtx,
117
- pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry' | 'registerProvider' | 'unregisterProvider'>,
143
+ pi: Pick<
144
+ ExtensionAPI,
145
+ 'sendMessage' | 'appendEntry' | 'registerProvider' | 'unregisterProvider'
146
+ >,
118
147
  ) {
119
148
  this.ctx = ctx
120
149
  this.pi = pi
121
150
  }
122
151
 
152
+ /** U6c:SchedulerRuntime 构造时订阅重折叠回调(见 SchedulerBackend.onSessionTree)。 */
153
+ onSessionTree(listener: (tasks: ScheduledTask[]) => void): void {
154
+ this.sessionTreeListener = listener
155
+ }
156
+
157
+ /**
158
+ * U6c:session_tree(树回退)事件委托体,由 extension factory 顶层注册的 handler
159
+ * 经 activeBackend 调用(index.ts;不在本类构造函数注册,注册面理由见该处注释)。
160
+ * 纯重建体:只按活跃路径重折叠任务集(loadTasks)并回调 runtime 换 Map——不做
161
+ * 任何 sendMessage / appendEntry / tickTimer 启停(tick 常驻循环照常消费新任务集,
162
+ * 被撤子树任务不在集内即到点不触发,A14)。
163
+ */
164
+ refoldSessionTree(): void {
165
+ this.sessionTreeListener?.(this.loadTasks())
166
+ }
167
+
123
168
  /**
124
169
  * 读路径:折叠当前 session 的 TASK_ENTRY_TYPE custom entries 恢复任务(非接口成员,
125
- * 由装配点 session_start 调用)。replayFoldEntries 内部含 fork owner 过滤与异常兜底。
170
+ * 由装配点 session_start / session_tree 重折叠调用)。replayFoldEntries 内部含 fork
171
+ * owner 过滤与异常兜底。U6c:折叠输入接活跃路径裁剪——从 leafId 沿 parentId 回溯,
172
+ * 被撤子树的任务 op 不再恢复 pending(否则被撤任务到点照常触发真实 turn,A14)。
126
173
  */
127
174
  loadTasks(): ScheduledTask[] {
128
175
  return [
129
- ...replayFoldEntries(this.ctx.sessionManager.getEntries(), this.ctx.sessionManager.getSessionFile()).values(),
176
+ ...replayFoldEntries(
177
+ filterActivePath(this.ctx.sessionManager),
178
+ this.ctx.sessionManager.getSessionFile(),
179
+ ).values(),
130
180
  ]
131
181
  }
132
182
 
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionContext, TurnEndEvent } from '@earendil-works/pi-coding-agent'
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
2
2
  import type { GuiContext } from '@zhushanwen/extension-protocol'
3
3
  import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
4
4
  import { getLogger } from '@zhushanwen/pi-extension-logger'
@@ -70,6 +70,9 @@ const WIDGET_KEEPALIVE_INTERVAL_MS = WIDGET_KEEPALIVE_MINUTES * MS_PER_MINUTE
70
70
  */
71
71
  export default function schedulerExtension(pi: ExtensionAPI): void {
72
72
  let service: SchedulerService | null = null
73
+ // U6c:当代 backend(session_start 装配点更新)。factory 顶层 session_tree handler
74
+ // 经它委托当代重折叠(注册面理由见下方 session_tree 注册点注释)。
75
+ let activeBackend: PiSchedulerBackend | null = null
73
76
  // IMPORT-FLUSH-GUARD(MF-1):importLegacyStore 对未 flush 的新 session 返回延迟删除 .imported
74
77
  // 的 cleanup——turn_end / session_shutdown 时执行:确认 flush(sessionFile 已出现)则删,
75
78
  // 未 flush 保留供崩溃恢复重导入(否则未 flush 即退出 → 全部旧任务丢失且源文件已销毁)。
@@ -105,6 +108,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
105
108
  service?.runtime.stopScheduler()
106
109
  // 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
107
110
  const backend = new PiSchedulerBackend(ctx, pi)
111
+ // U6c:同步给 factory 顶层 session_tree handler(下方注册)——同代 session_start
112
+ // 复发时本赋值覆盖为最新 backend,委托恒指当代 ctx 的实例。
113
+ activeBackend = backend
108
114
  // 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
109
115
  // append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
110
116
  // ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
@@ -186,37 +192,48 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
186
192
  refreshWidget(ctx)
187
193
  })
188
194
 
195
+ // U6c:树回退(撤回 __taiji_nav__ → navigateTree / 用户树跳转)后重折叠任务集。
196
+ // 注册在 factory 顶层而非 PiSchedulerBackend 构造函数:pi 的 on 是追加语义(loader.js
197
+ // on 实现 list.push,无去重无 off,0.84.4 实装核对),且 RPC 模式下每次 session 替换
198
+ // session_start 在同一代内触发两次(agent-session-runtime finishSessionReplacement
199
+ // 内部 rebindSession 一路 + RPC handler 再 rebindSession 一路,均经 bindExtensions
200
+ // emit session_start)——构造函数注册会同代线性累积 handler(残留旧代 backend 在旧
201
+ // ctx 上重复执行幂等 loadTasks,handler 数随替换无界缓增)。factory 每代恰好运行一次
202
+ // (loader.js initializeExtension 每代新建 handlers Map 并重跑 factory),顶层注册
203
+ // 结构性保证每代恰一 handler;委托 activeBackend(session_start 装配点更新)保证折叠
204
+ // 输入恒为当代实例,事件早于首个 session_start 时 no-op。
205
+ // 纯重建体(goal/plan/todo 的 session_tree handler 同款):只按活跃路径重折叠任务集
206
+ // 并回调 runtime 换 Map——不做任何 sendMessage / appendEntry / tickTimer 启停(tick
207
+ // 常驻循环照常消费新任务集,被撤子树任务不在集内即到点不触发,A14)。
208
+ pi.on('session_tree', (_event: unknown, _ctx: ExtensionContext) => {
209
+ activeBackend?.refoldSessionTree()
210
+ })
211
+
189
212
  // turn_end 单注册共用(U4 恢复挂点与 MF-1 cleanup 同事件):真实 pi 的 on 是 handler 列表
190
213
  // 追加,但测试仿真 mock 为覆盖式单 handler,且同事件单注册与「listener 防重复注册」纪律一致。
191
- pi.on('turn_end', (event?: TurnEndEvent) => {
214
+ // (U4 模型恢复不在本事件:D1 归属简化后恢复唯一事件通道 = agent_settled,见 runtime.handleRunSettled)
215
+ pi.on('turn_end', () => {
192
216
  // IMPORT-FLUSH-GUARD(MF-1):延迟删除的主触发点——turn_end 前该轮所有 message_end 已持久化
193
217
  // (agent-session.js _handleAgentEvent 在 message_end 处理中调 appendMessage 触发 flush),
194
218
  // sessionFile 已出现 → cleanup 删 .imported;仍未 flush(无 assistant 消息的轮次)→ 静默保留,
195
219
  // 下次 turn_end / session_shutdown 重试。cleanup 幂等(importer.ts importFromFile)。
196
220
  importCleanup?.()
197
- // U4 dispatch 模型切换恢复挂点(设计 D3 修订版):状态机、恢复动作与 stale 代际守卫都在
198
- // SchedulerRuntime。`event?.` 容错:pi 契约 payload 恒在,测试仿真可无参调用,缺省不匹配不动作。
199
- service?.runtime.handleTurnEnd(event?.turnIndex)
200
221
  // ack 安全网注销(幂等):正常路径已在 streamSimple 调用点自撤,这里覆盖「覆写未被调用」
201
222
  // 的轮次(E2)。
202
223
  ackController?.handleTurnEnd()
203
224
  })
204
225
 
205
- // U4 dispatch 模型切换:归属状态机其余事件监听(P-MODEL-③④ 实测序态)。handler 只转发
206
- // 事件数据;agent_settled = run 完全沉降(无 retry/compaction/queued continuation)后的
207
- // 窗口封口 + awaiting-restore 模型恢复的即时兑现(区别于 agent_end 的纯封口)。
208
- pi.on('agent_start', () => service?.runtime.handleAgentStart())
209
- pi.on('turn_start', (event) => service?.runtime.handleTurnStart(event?.turnIndex))
226
+ // message_start 仅剩 ack 触发器判别(u-ack-turn):只有我们注入的 custom 消息(前缀
227
+ // pi-scheduler-ack:)才同步武装覆写;外来/assistant 消息一律忽略。(U4 dispatch 归属匹配
228
+ // 已随 D1 归属简化删除)
210
229
  pi.on('message_start', (event) => {
211
- service?.runtime.handleMessageStart(event?.message)
212
- // ack 触发器判别(u-ack-turn):只有我们注入的 custom 消息(前缀 pi-scheduler-ack:)
213
- // 才同步武装覆写;外来/assistant 消息一律忽略。
214
230
  ackController?.handleMessageStart(event?.message)
215
231
  })
216
- pi.on('agent_end', () => service?.runtime.handleRunClosed())
217
- // agent_settled 除封口外兼作 awaiting-restore 模型恢复的即时兑现挂点(不与 agent_end
218
- // 共用:end 后仍可能有自动续跑 turn,此时切回会把续跑 turn 的模型换掉,见
219
- // runtime.handleRunSettled 注释)
232
+
233
+ // U4 dispatch 模型切换恢复的唯一事件挂点:agent_settled = run 完全沉降(无 retry/
234
+ // compaction/queued continuation)后的 isIdle 复核兑现(D1 归属简化——不挂 agent_start/
235
+ // turn_start/message_start/turn_end/agent_end,runtime 无 turnIndex 归属状态机;end 后仍
236
+ // 可能有自动续跑 turn,恢复只认 settled,见 runtime.handleRunSettled 注释)。
220
237
  pi.on('agent_settled', () => service?.runtime.handleRunSettled())
221
238
 
222
239
  pi.on('session_shutdown', async () => {