@zhushanwen/pi-scheduler 0.9.2 → 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.
@@ -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
+ })
@@ -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 () => {