@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.
@@ -1,18 +1,18 @@
1
1
  // src/__tests__/widget-push.test.ts
2
2
  //
3
- // scheduler widget 推送修正(D1 指纹跳推 + D2 保活底线帧)集成单测:
3
+ // scheduler widget 推送(D1 指纹跳推)集成单测:
4
4
  // 走 index.ts 真实装配链(session_start → runtime.onAfterTick → refreshWidget),
5
5
  // 观测面 = ctx.ui.setWidget 调用序列(每个实际推送恰一次调用——跳推判定在
6
6
  // setSchedulerWidget 之前,setWidgetDual 的 TUI 分支与清屏分支都落 setWidget)。
7
7
  //
8
- // 行为契约(scheduler widget 推送修正设计 D1-b/D2):
9
- // - 任务集指纹不变 → 跳推(时间流逝不是状态变化,静态期零推送——G1)
10
- // - 保活底线双条件:任务集非空(含 disabled——任务存在即调度意图)且距上次实际推送
11
- // ≥10min → 强制推一帧;空任务集不发保活帧(清屏后零帧,会话可被 idle reaper 正常回收——G3)
8
+ // 行为契约(scheduler widget 推送修正设计 D1-b):
9
+ // - 任务集指纹不变 → 跳推(时间流逝不是状态变化,静态期零推送——G1;跨原保活窗口
10
+ // 时长仍零推送,锁定原 D2 保活底线帧已随 idle reaper 退役不回归)
11
+ // - 空任务集同样静态期零推送(清屏帧只推一次)
12
12
  // - fail-open:指纹计算异常即推送(宁可多推不可漏显)+ warn 留痕
13
13
  // - locale 入指纹:切语言一次推送自愈(集成臂;kind 字段在 widget.test.ts 纯函数臂锁定)
14
14
  //
15
- // timer 红线:fake timers(vitest useFakeTimers),保活窗口用大步进 advance(fake 无墙钟成本)。
15
+ // timer 红线:fake timers(vitest useFakeTimers),静置用大步进 advance(fake 无墙钟成本)。
16
16
 
17
17
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
18
18
  import { tmpdir } from 'node:os'
@@ -35,11 +35,10 @@ 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
- /** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(10min;常量不导出,测试锚定同值)。 */
42
- const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
40
+ /** 原保活窗口时长(10min;原 D2 已退役)——静态用例静置越过后断言仍零推送。 */
41
+ const RETIRED_KEEPALIVE_WINDOW_MS = 10 * 60 * 1000
43
42
  const BASE = Date.parse('2026-01-01T00:00:00Z')
44
43
 
45
44
  /** 构造一个远期到期的启用任务 upsert entry(owner = sessionFile,避免 fork owner 过滤)。 */
@@ -99,7 +98,7 @@ function createFakeCtx(sessionFile: string, entries: unknown[] = []): {
99
98
  return { ctx, setWidget }
100
99
  }
101
100
 
102
- describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () => {
101
+ describe('widget 推送判定(D1 指纹跳推)', () => {
103
102
  beforeEach(() => {
104
103
  vi.useFakeTimers()
105
104
  vi.setSystemTime(BASE)
@@ -110,7 +109,7 @@ describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () =>
110
109
  vi.useRealTimers()
111
110
  })
112
111
 
113
- it('D1: 任务集静态期间零推送(指纹不变跳推),任务集内但有 disabled 同样静态', async () => {
112
+ it('D1: 任务集静态期间零推送——静置越过原保活窗口仍零推送(D2 退役不回归)', async () => {
114
113
  const { pi, events } = createMockPi()
115
114
  schedulerExtension(pi)
116
115
  const sessionStart = events.get('session_start')!
@@ -118,34 +117,12 @@ describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () =>
118
117
  sessionStart({ type: 'session_start', reason: 'startup' }, ctx)
119
118
  expect(setWidget).toHaveBeenCalledTimes(1) // 首帧必推(实例态重置)
120
119
 
121
- // 静置 9.5min(17 个 tick):每次 tick 指纹相同、保活未到期 → 零推送
122
- await vi.advanceTimersByTimeAsync(17 * TICK_INTERVAL_MS)
123
- expect(setWidget).toHaveBeenCalledTimes(1)
124
- })
125
-
126
- it('D2: 非空任务集跨保活窗口 → 强制推一帧,内容与首帧相同(静态面恒定)', async () => {
127
- const { pi, events } = createMockPi()
128
- schedulerExtension(pi)
129
- const sessionStart = events.get('session_start')!
130
- const { ctx, setWidget } = createFakeCtx('/test/wp-keepalive.json', [
131
- taskEntry('/test/wp-keepalive.json'),
132
- ])
133
- sessionStart({ type: 'session_start', reason: 'startup' }, ctx)
120
+ // 静置越过原保活窗口(10min + 2 个 tick):指纹相同 → 零推送(无保活底线帧)
121
+ await vi.advanceTimersByTimeAsync(RETIRED_KEEPALIVE_WINDOW_MS + 2 * TICK_INTERVAL_MS)
134
122
  expect(setWidget).toHaveBeenCalledTimes(1)
135
- const firstFrame = setWidget.mock.calls[0]![1]
136
-
137
- // 静置越过保活窗口:下一 tick(保活到期后首个 30s 节拍)强制推一帧
138
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
139
- expect(setWidget).toHaveBeenCalledTimes(2)
140
- // 保活帧纯粹为心跳:文本与首帧相同(D1 静态化后显示面不随时间变化)
141
- expect(setWidget.mock.calls[1]![1]).toEqual(firstFrame)
142
-
143
- // 第二个保活窗口同样恰好一帧(有界心跳,非恢复 30s 节拍)
144
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
145
- expect(setWidget).toHaveBeenCalledTimes(3)
146
123
  })
147
124
 
148
- it('D2: 空任务集不发保活帧(清屏后零帧,会话保持可回收)', async () => {
125
+ it('D1: 空任务集静态期零推送(清屏帧只推一次)', async () => {
149
126
  const { pi, events } = createMockPi()
150
127
  schedulerExtension(pi)
151
128
  const sessionStart = events.get('session_start')!
@@ -154,28 +131,11 @@ describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () =>
154
131
  expect(setWidget).toHaveBeenCalledTimes(1) // 首帧 = 清屏帧(setWidget(key, undefined))
155
132
  expect(setWidget.mock.calls[0]![1]).toBeUndefined()
156
133
 
157
- // 静置 30min(远超保活窗口):无任务 → 无保活帧
134
+ // 静置 30min:空任务集指纹稳定 → 零推送
158
135
  await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
159
136
  expect(setWidget).toHaveBeenCalledTimes(1)
160
137
  })
161
138
 
162
- it('D2: 全 disabled 的任务集仍保活(任务存在即调度意图)', async () => {
163
- const { pi, events } = createMockPi()
164
- schedulerExtension(pi)
165
- const sessionStart = events.get('session_start')!
166
- const file = '/test/wp-disabled.json'
167
- const { ctx, setWidget } = createFakeCtx(file, [taskEntry(file, { enabled: false })])
168
- sessionStart({ type: 'session_start', reason: 'startup' }, ctx)
169
- expect(setWidget).toHaveBeenCalledTimes(1)
170
-
171
- await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
172
- expect(setWidget).toHaveBeenCalledTimes(2) // disabled 不豁免保活(re-enable 后须可执行)
173
- // 用户可见面:保活帧携带完整静态行(计数段在场——TUI 最近任务行只取 enabled 任务,
174
- // 全 disabled 时行 = 计数段,这是既有 enabled 过滤行为)
175
- const frame = String((setWidget.mock.calls[1]![1] as string[])[0] ?? '')
176
- expect(frame).toBe('[scheduler] 0 scheduled')
177
- })
178
-
179
139
  it('fail-open: 指纹计算异常 → 该 tick 照常推送 + warn 留痕', async () => {
180
140
  const { pi, events } = createMockPi()
181
141
  schedulerExtension(pi)
package/src/ack-turn.ts CHANGED
@@ -11,9 +11,9 @@
11
11
  // ① 建任务成功 ⇒ maybeStartAck:只读检查未落盘 + 空闲 + 可用,注入 custom 触发器
12
12
  // (triggerTurn);写盘兜底判定收敛在 session 边界;
13
13
  // ② 触发器的 message_start 到达(本轮首次模型请求之前——pi-agent-core
14
- // agent-loop.js:51-53 初始 prompt 逐条 emit message_start/message_end 先于
15
- // :56 runLoop → :122 streamAssistantResponse 首次模型请求,按 0.84.4 实装核实;
16
- // 直达链锚 PS-08)⇒ 同步 registerProvider 覆写当前 provider 的 streamSimple;
14
+ // agent-loop.js 初始 prompt 逐条 emit message_start/message_end 先于
15
+ // runLoop → streamAssistantResponse 首次模型请求;直达链锚 PS-08,
16
+ // verifiedWith 以 pi-semantics.json 为准)⇒ 同步 registerProvider 覆写当前 provider 的 streamSimple;
17
17
  // ③ 我们的 streamSimple 被调用 ⇒ 返回 stream 之前同步注销覆写(one-shot 自撤),
18
18
  // 本轮内被 steer/followUp drain 出的后续请求与后续轮次全部走真实 provider;
19
19
  // ④ turn_end / session 边界 ⇒ 安全网注销(幂等)。
@@ -344,9 +344,9 @@ export function createAckTurnController(deps: AckTurnDeps): AckTurnController {
344
344
  /**
345
345
  * 落盘判据①:会话内已有 assistant 消息 ⇒ pi 已 flush,任务必然已落盘。
346
346
  *
347
- * 反向推论承重锚(语义登记 docs/pi-semantics.json PS-14):pi _appendEntry 先 push 进
348
- * fileEntries 再同步 _persist(dist/core/session-manager.js:757-762),assistant 在列时
349
- * hasAssistant 命中即走同步 wx 全量补写分支(:741-752,0.84.4 无异步 flush)——getEntries
347
+ * 反向推论承重锚(语义登记 docs/pi-semantics.json PS-14,verifiedWith 以该登记为准):
348
+ * pi _appendEntry 先 push 进 fileEntries 再同步 _persist,assistant 在列时
349
+ * hasAssistant 命中即走同步 wx 全量补写分支(0.84.4 无异步 flush)——getEntries
350
350
  * 读到 assistant 的时刻磁盘必已含全部内存 entry,无异步窗口。
351
351
  *
352
352
  * entry 形状来自 replay 的 SchedulerEntryLike(无 role 字段声明),故用 `in` 收窄到
package/src/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'
@@ -7,7 +7,6 @@ import { createAckTurnController, type AckTurnController } from './ack-turn.js'
7
7
  import { PiSchedulerBackend } from './backend.js'
8
8
  import { registerScheduleCommand } from './commands.js'
9
9
  import { formatSchedule } from './format.js'
10
- import { MS_PER_MINUTE } from './parsing.js'
11
10
  import { readUiLocale, t } from './i18n.js'
12
11
  import { importLegacyStore } from './importer.js'
13
12
  import { abortPendingScheduleForms } from './interaction.js'
@@ -40,26 +39,14 @@ import type { ScheduledTask } from './types.js'
40
39
  // 绑定,永不再递增 → 其 isCtxStale 恒 false。该盲区由两道既有防线覆盖:pi 在替换前 await
41
40
  // fire session_shutdown(F1 stopScheduler 主防线,teardownCurrent)+ runtime 侧
42
41
  // STALE_CTX_MARKER 文案兜底(F2 catch 分诊)。
42
+ // 绑定,永不再递增 → 其 isCtxStale 恒 false。该盲区由两道既有防线覆盖:pi 在替换前 await
43
+ // fire session_shutdown(F1 stopScheduler 主防线,teardownCurrent)+ runtime 侧
44
+ // STALE_CTX_MARKER 文案兜底(F2 catch 分诊)。
43
45
  let sessionGeneration = 0
44
46
 
45
47
  /** 包内既有 logger(ack 编排的日志面与其它模块同源)。 */
46
48
  const logger = getLogger('scheduler')
47
49
 
48
- /**
49
- * D2 保活底线帧间隔(10min):任务集静态期间每 ≥10min 强制推一帧(内容与上一帧相同,
50
- * 纯粹为维持 rpc-client 入站全帧 touch `_lastActivityAt` 的心跳),防 idle reaper
51
- * (DEFAULT_PI_RECLAIM_IDLE_MS,生产 2h,5min 一拍、严格大于判定)回收挂有定时任务的
52
- * 会话——定时任务跨空闲期不停摆(G3)。
53
- *
54
- * **方案不变量:保活间隔 ≪ idle 回收阈值,须维持 ≥3 倍余量。** 生产 10min vs 2h = 12 倍;
55
- * 保活帧实际到达受 tick(TICK_INTERVAL_MS=30s)驱动波动(+0~30s),余量须覆盖 tick 波动
56
- * 与 reaper 拍相位。未来调整本值、idle 阈值或 tick 间隔任一侧,须重验 ≥3 倍余量并重跑
57
- * C-场景(真机加速验收的加速值也须 ≥3 倍保活间隔,防零余量竞态随机误回收)。
58
- */
59
- /** 保活间隔的分钟数(语义单位,避免裸魔数;乘 MS_PER_MINUTE 得毫秒间隔)。 */
60
- const WIDGET_KEEPALIVE_MINUTES = 10
61
- const WIDGET_KEEPALIVE_INTERVAL_MS = WIDGET_KEEPALIVE_MINUTES * MS_PER_MINUTE
62
-
63
50
  /**
64
51
  * pi-scheduler extension factory。
65
52
  * 注册 schedule + schedule_control 两个 tool、/schedule command、session 事件。
@@ -70,6 +57,9 @@ const WIDGET_KEEPALIVE_INTERVAL_MS = WIDGET_KEEPALIVE_MINUTES * MS_PER_MINUTE
70
57
  */
71
58
  export default function schedulerExtension(pi: ExtensionAPI): void {
72
59
  let service: SchedulerService | null = null
60
+ // U6c:当代 backend(session_start 装配点更新)。factory 顶层 session_tree handler
61
+ // 经它委托当代重折叠(注册面理由见下方 session_tree 注册点注释)。
62
+ let activeBackend: PiSchedulerBackend | null = null
73
63
  // IMPORT-FLUSH-GUARD(MF-1):importLegacyStore 对未 flush 的新 session 返回延迟删除 .imported
74
64
  // 的 cleanup——turn_end / session_shutdown 时执行:确认 flush(sessionFile 已出现)则删,
75
65
  // 未 flush 保留供崩溃恢复重导入(否则未 flush 即退出 → 全部旧任务丢失且源文件已销毁)。
@@ -86,7 +76,6 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
86
76
  // 空窗;模块级 vs 闭包级的语义差异在本包有实证(见上方 sessionGeneration 注释)。
87
77
  // 生命周期:session_start 装配新会话实例时重置(refreshWidgetState 调用点)。
88
78
  let lastWidgetFingerprint: string | null = null
89
- let lastWidgetPushedAt = 0
90
79
 
91
80
  const getService = (): SchedulerService => {
92
81
  if (!service) throw new Error('Scheduler not initialized: session not started')
@@ -105,6 +94,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
105
94
  service?.runtime.stopScheduler()
106
95
  // 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
107
96
  const backend = new PiSchedulerBackend(ctx, pi)
97
+ // U6c:同步给 factory 顶层 session_tree handler(下方注册)——同代 session_start
98
+ // 复发时本赋值覆盖为最新 backend,委托恒指当代 ctx 的实例。
99
+ activeBackend = backend
108
100
  // 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
109
101
  // append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
110
102
  // ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
@@ -178,45 +170,56 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
178
170
  })
179
171
 
180
172
  // 注册 widget(SDK setWidget 第一重载:直接传 string[])。初始渲染一次,
181
- // 后续随每次 tickScheduler 末尾的 onAfterTick 回调刷新(推送频率由指纹跳推 +
182
- // 保活底线帧判定,见 refreshWidget)。
173
+ // 后续随每次 tickScheduler 末尾的 onAfterTick 回调刷新(推送频率由指纹跳推判定,
174
+ // 见 refreshWidget)。
183
175
  // 新会话实例起点重置推送状态:首帧(含空任务清屏帧)必推,不继承前代指纹/时间戳。
184
176
  lastWidgetFingerprint = null
185
- lastWidgetPushedAt = 0
186
177
  refreshWidget(ctx)
187
178
  })
188
179
 
180
+ // U6c:树回退(撤回 __taiji_nav__ → navigateTree / 用户树跳转)后重折叠任务集。
181
+ // 注册在 factory 顶层而非 PiSchedulerBackend 构造函数:pi 的 on 是追加语义(loader.js
182
+ // on 实现 list.push,无去重——0.84.4 连 off 都没有,1.0.0 起返回 unsubscribe 但本处
183
+ // 不消费,handler 生命周期随 factory 代际收敛),且 RPC 模式下每次 session 替换
184
+ // session_start 在同一代内触发两次(agent-session-runtime finishSessionReplacement
185
+ // 内部 rebindSession 一路 + RPC handler 再 rebindSession 一路,均经 bindExtensions
186
+ // emit session_start)——构造函数注册会同代线性累积 handler(残留旧代 backend 在旧
187
+ // ctx 上重复执行幂等 loadTasks,handler 数随替换无界缓增)。factory 每代恰好运行一次
188
+ // (loader.js initializeExtension 每代新建 handlers Map 并重跑 factory),顶层注册
189
+ // 结构性保证每代恰一 handler;委托 activeBackend(session_start 装配点更新)保证折叠
190
+ // 输入恒为当代实例,事件早于首个 session_start 时 no-op。
191
+ // 纯重建体(goal/plan/todo 的 session_tree handler 同款):只按活跃路径重折叠任务集
192
+ // 并回调 runtime 换 Map——不做任何 sendMessage / appendEntry / tickTimer 启停(tick
193
+ // 常驻循环照常消费新任务集,被撤子树任务不在集内即到点不触发,A14)。
194
+ pi.on('session_tree', (_event: unknown, _ctx: ExtensionContext) => {
195
+ activeBackend?.refoldSessionTree()
196
+ })
197
+
189
198
  // turn_end 单注册共用(U4 恢复挂点与 MF-1 cleanup 同事件):真实 pi 的 on 是 handler 列表
190
199
  // 追加,但测试仿真 mock 为覆盖式单 handler,且同事件单注册与「listener 防重复注册」纪律一致。
191
- pi.on('turn_end', (event?: TurnEndEvent) => {
200
+ // (U4 模型恢复不在本事件:D1 归属简化后恢复唯一事件通道 = agent_settled,见 runtime.handleRunSettled)
201
+ pi.on('turn_end', () => {
192
202
  // IMPORT-FLUSH-GUARD(MF-1):延迟删除的主触发点——turn_end 前该轮所有 message_end 已持久化
193
203
  // (agent-session.js _handleAgentEvent 在 message_end 处理中调 appendMessage 触发 flush),
194
204
  // sessionFile 已出现 → cleanup 删 .imported;仍未 flush(无 assistant 消息的轮次)→ 静默保留,
195
205
  // 下次 turn_end / session_shutdown 重试。cleanup 幂等(importer.ts importFromFile)。
196
206
  importCleanup?.()
197
- // U4 dispatch 模型切换恢复挂点(设计 D3 修订版):状态机、恢复动作与 stale 代际守卫都在
198
- // SchedulerRuntime。`event?.` 容错:pi 契约 payload 恒在,测试仿真可无参调用,缺省不匹配不动作。
199
- service?.runtime.handleTurnEnd(event?.turnIndex)
200
207
  // ack 安全网注销(幂等):正常路径已在 streamSimple 调用点自撤,这里覆盖「覆写未被调用」
201
208
  // 的轮次(E2)。
202
209
  ackController?.handleTurnEnd()
203
210
  })
204
211
 
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))
212
+ // message_start 仅剩 ack 触发器判别(u-ack-turn):只有我们注入的 custom 消息(前缀
213
+ // pi-scheduler-ack:)才同步武装覆写;外来/assistant 消息一律忽略。(U4 dispatch 归属匹配
214
+ // 已随 D1 归属简化删除)
210
215
  pi.on('message_start', (event) => {
211
- service?.runtime.handleMessageStart(event?.message)
212
- // ack 触发器判别(u-ack-turn):只有我们注入的 custom 消息(前缀 pi-scheduler-ack:)
213
- // 才同步武装覆写;外来/assistant 消息一律忽略。
214
216
  ackController?.handleMessageStart(event?.message)
215
217
  })
216
- pi.on('agent_end', () => service?.runtime.handleRunClosed())
217
- // agent_settled 除封口外兼作 awaiting-restore 模型恢复的即时兑现挂点(不与 agent_end
218
- // 共用:end 后仍可能有自动续跑 turn,此时切回会把续跑 turn 的模型换掉,见
219
- // runtime.handleRunSettled 注释)
218
+
219
+ // U4 dispatch 模型切换恢复的唯一事件挂点:agent_settled = run 完全沉降(无 retry/
220
+ // compaction/queued continuation)后的 isIdle 复核兑现(D1 归属简化——不挂 agent_start/
221
+ // turn_start/message_start/turn_end/agent_end,runtime 无 turnIndex 归属状态机;end 后仍
222
+ // 可能有自动续跑 turn,恢复只认 settled,见 runtime.handleRunSettled 注释)。
220
223
  pi.on('agent_settled', () => service?.runtime.handleRunSettled())
221
224
 
222
225
  pi.on('session_shutdown', async () => {
@@ -246,8 +249,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
246
249
  // 注册 schedule tool(触发反转:直建,不再弹确认表单——人侧表单入口在 /schedule 命令)。
247
250
  // execute 内联闭包:从 SDK 全签名 (toolCallId, params, signal, onUpdate, ctx) 提取
248
251
  // 转调 handleSchedule 直建流(预校验 → abort 检查 → service.create)。
249
- // 错误路径 throw(W4):pi 只对 execute throw 置 isError:true(返回值里的
250
- // isError 被 agent-loop 丢弃);getService() 未初始化异常穿透到这里,包装
252
+ // 错误路径 throw(W4):pi 对 execute throw 置 isError:true(agent-loop.js:581-588;
253
+ // 1.0.0 起返回值 isError:true 也被尊重,本工具恒走 throw 路径两版语义等价);
254
+ // getService() 未初始化异常穿透到这里,包装
251
255
  // 'Error: Scheduler not initialized' 格式(R3 格式保持)。
252
256
  pi.registerTool({
253
257
  name: 'schedule',
@@ -308,14 +312,11 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
308
312
  * `ctx as GuiContext`:pi 的 `ExtensionContext` 与协议包最小结构(mode/hasUI/ui.setWidget)
309
313
  * 静态不完全兼容,先例见 todo/src/index.ts makeRefreshDisplay。
310
314
  *
311
- * 推送频率判定(scheduler widget 推送修正设计 D1-b / D2):
315
+ * 推送频率判定(scheduler widget 推送修正设计 D1-b;原 D2 保活底线帧随 idle reaper
316
+ * 退役一并删除——其存在理由唯一指向 reaper 回收防御):
312
317
  * - D1 指纹跳推:任务集稳定指纹(computeTasksFingerprint,字段集含 kind/locale)与上次
313
- * 实际推送相同且保活未到期 → 跳过推送。时间流逝不是状态变化,任务集不变期间零推送
318
+ * 实际推送相同 → 跳过推送。时间流逝不是状态变化,任务集不变期间零推送
314
319
  * (rpc/tui 两模式同效——判定在 setWidgetDual 之前)。
315
- * - D2 保活底线帧:有任务(任务集非空,含全部 disabled——任务存在即调度意图,re-enable
316
- * 后须可执行)且距上次实际推送超过 WIDGET_KEEPALIVE_INTERVAL_MS → 强制推一帧(内容
317
- * 与上帧相同,纯粹为维持心跳防 idle reaper 回收)。空任务集不发保活帧:清屏后任务集
318
- * 保持空 → 无帧 → 会话按 idle 规则正常回收(保活与任务存在性绑定)。
319
320
  * - fail-open:指纹计算异常即推送(宁可多推不可漏显,设计 §3.1 失败路径)。
320
321
  */
321
322
  function refreshWidget(ctx: ExtensionContext): void {
@@ -330,22 +331,16 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
330
331
  fingerprint = computeTasksFingerprint(tasks, locale)
331
332
  } catch (err) {
332
333
  // fail-open(辅助显示面的降级 ≠ 吞错):指纹异常说明序列化路径有 bug,跳推判定不可信
333
- // → 直接推送保显示正确;缓存失效(null)使下一帧也必推,保活计时照常刷新。
334
+ // → 直接推送保显示正确;缓存失效(null)使下一帧也必推。
334
335
  logger.warn('widget fingerprint computation failed, pushing anyway', { error: toErrorMessage(err) })
335
336
  lastWidgetFingerprint = null
336
- lastWidgetPushedAt = Date.now()
337
337
  setSchedulerWidget(ctx as GuiContext, tasks)
338
338
  return
339
339
  }
340
340
 
341
- const nowMs = Date.now()
342
- // D2 双条件:任务存在(含 disabled)+ 距上次实际推送超时。跳推不刷新 lastWidgetPushedAt
343
- // ——保活计时只从「实际推送」起算。
344
- const keepaliveDue = tasks.length > 0 && nowMs - lastWidgetPushedAt >= WIDGET_KEEPALIVE_INTERVAL_MS
345
- if (fingerprint === lastWidgetFingerprint && !keepaliveDue) return
341
+ if (fingerprint === lastWidgetFingerprint) return
346
342
 
347
343
  setSchedulerWidget(ctx as GuiContext, tasks)
348
344
  lastWidgetFingerprint = fingerprint
349
- lastWidgetPushedAt = nowMs
350
345
  }
351
346
  }