@zhushanwen/pi-scheduler 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "xyz-agent": {
@@ -45,8 +45,9 @@
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
- "@xyz-agent/session-delivery": "0.3.0",
49
- "@zhushanwen/pi-extension-logger": "0.4.0"
48
+ "@xyz-agent/session-delivery": "0.3.1",
49
+ "@zhushanwen/pi-ext-guards": "0.3.0",
50
+ "@zhushanwen/pi-extension-logger": "0.6.0"
50
51
  },
51
52
  "scripts": {
52
53
  "test": "vitest run",
@@ -181,6 +181,17 @@ describe('/schedule command', () => {
181
181
  expect(result).toBe('Usage: /schedule <schedule> <prompt>')
182
182
  })
183
183
 
184
+ it('treats prototype-chain key names as unknown (Map lookup, no Object.prototype hit)', async () => {
185
+ // 路由表用 Map 查找的语义锚:普通对象下标会把 "constructor"/"toString"/"__proto__"
186
+ // 解析到 Object.prototype(constructor 被当 handler 调用返回对象、__proto__ 直接 TypeError),
187
+ // 这里必须与其它未知子命令同走创建任务分支(无 schedule 输入 → usage 文案)。
188
+ for (const key of ['constructor', 'toString', '__proto__']) {
189
+ expect(await executeScheduleCommand(service, key)).toBe(
190
+ 'Usage: /schedule <schedule> <prompt>',
191
+ )
192
+ }
193
+ })
194
+
184
195
  it('no args returns TUI not-implemented message', async () => {
185
196
  const result = await executeScheduleCommand(service, '')
186
197
  expect(result).toContain('not yet implemented')
@@ -310,6 +310,20 @@ describe('replayFoldEntries', () => {
310
310
  expect(msg).toContain('skipping corrupted scheduler entry')
311
311
  })
312
312
 
313
+ // ── fork 场景安全:未知 taskId 的 advance/toggle 为静默 no-op,不触发逐条 catch ──
314
+ it('advance/toggle 对不存在的 taskId 为 no-op(无 upsert 时不产生副作用、不触发逐条 catch warn)', () => {
315
+ loggerMock.warn.mockClear()
316
+ const entries = [
317
+ entry({ op: 'advance', taskId: 'ghost', nextRunAt: 200, at: 100, status: 'success' }),
318
+ entry({ op: 'toggle', taskId: 'ghost', enabled: false }),
319
+ entry({ op: 'delete', taskId: 'ghost' }),
320
+ ]
321
+ const result = replayFoldEntries(entries, '/s.json')
322
+ expect(result.size).toBe(0)
323
+ // no-op 提前返回路径不抛错——MF-2 的逐条跳过 warn 不应被触发
324
+ expect(loggerMock.warn).not.toHaveBeenCalled()
325
+ })
326
+
313
327
  afterEach(() => {
314
328
  vi.restoreAllMocks()
315
329
  })
package/src/commands.ts CHANGED
@@ -84,11 +84,77 @@ export function registerScheduleCommand(
84
84
  })
85
85
  }
86
86
 
87
+ /** 单个子命令处理器。service 非空由 executeScheduleCommand 前置保证;parts 为 tokenize 后参数。 */
88
+ type SubcommandHandler = (service: SchedulerService, parts: string[]) => string | Promise<string>
89
+
90
+ /**
91
+ * on/off 共享:取 <id>,缺失返回 usage(文案与原 ${first} 一致——handler 仅在
92
+ * first === 'on' | 'off' 时被查表命中,keyword 即 first 的小写值)。
93
+ */
94
+ async function handleToggleKeyword(
95
+ service: SchedulerService,
96
+ parts: string[],
97
+ keyword: 'on' | 'off',
98
+ ): Promise<string> {
99
+ const id = parts[1]
100
+ if (!id) return `Usage: /schedule ${keyword} <id>`
101
+ const result = await service.toggle(id, keyword === 'on')
102
+ return result.message
103
+ }
104
+
105
+ /**
106
+ * 子命令路由表。查表未命中(落空)= 创建任务分支,与原 if 链的顺序语义一致。
107
+ * list/rm 保持同步返回、run 保持 await——不改变 async 边界内的微任务时序。
108
+ * 用 Map 而非普通对象:first 是外部输入(/schedule 命令参数),普通对象查表会命中
109
+ * Object.prototype 上的键(如 "constructor"/"__proto__"),导致错误路由;Map 只查自身键。
110
+ */
111
+ const SUBCOMMAND_HANDLERS: ReadonlyMap<string, SubcommandHandler> = new Map<string, SubcommandHandler>([
112
+ ['list', service => service.list().message],
113
+ ['on', (service, parts) => handleToggleKeyword(service, parts, 'on')],
114
+ ['off', (service, parts) => handleToggleKeyword(service, parts, 'off')],
115
+ [
116
+ 'rm',
117
+ (service, parts) => {
118
+ const id = parts[1]
119
+ if (!id) return 'Usage: /schedule rm <id>'
120
+ return service.delete(id).message
121
+ },
122
+ ],
123
+ [
124
+ 'run',
125
+ async (service, parts) => {
126
+ const id = parts[1]
127
+ if (!id) return 'Usage: /schedule run <id>'
128
+ const result = await service.run(id)
129
+ return result.message
130
+ },
131
+ ],
132
+ ])
133
+
134
+ /** 创建任务分支(路由落空):once 前缀 → once 任务;cron 前缀 → recurring cron 任务;其余按 interval schedule 解析。 */
135
+ async function createTaskFromArgs(
136
+ service: SchedulerService,
137
+ parts: string[],
138
+ first: string,
139
+ ): Promise<string> {
140
+ const kind = first === 'once' ? 'once' as const : first === 'cron' ? 'recurring' as const : undefined
141
+ const scheduleStart = kind ? 1 : 0
142
+ const scheduleInput = parts[scheduleStart]
143
+ if (!scheduleInput) return 'Usage: /schedule <schedule> <prompt>'
144
+
145
+ const prompt = parts.slice(scheduleStart + 1).join(' ')
146
+ if (!prompt) return 'Usage: /schedule <schedule> <prompt>'
147
+
148
+ const result = await service.create(prompt, scheduleInput, { kind })
149
+ return result.message
150
+ }
151
+
87
152
  /**
88
153
  * Core logic for /schedule command. Extracted for testability (handler returns
89
154
  * void per SDK contract; tests call this function directly to assert output).
90
155
  *
91
156
  * 命令层只保留参数路由与 usage 文案(C6),业务调用全部走 SchedulerService。
157
+ * 主函数只留编排:service 守卫 → 空 args → tokenize → 查表分发 → 创建分支。
92
158
  */
93
159
  export async function executeScheduleCommand(
94
160
  service: SchedulerService | null,
@@ -105,40 +171,9 @@ export async function executeScheduleCommand(
105
171
  const parts = tokenizeQuoted(trimmed)
106
172
  const first = parts[0]!.toLowerCase()
107
173
 
108
- // 子命令路由
109
- if (first === 'list') {
110
- return service.list().message
111
- }
112
-
113
- if (first === 'on' || first === 'off') {
114
- const id = parts[1]
115
- if (!id) return `Usage: /schedule ${first} <id>`
116
- const result = await service.toggle(id, first === 'on')
117
- return result.message
118
- }
119
-
120
- if (first === 'rm') {
121
- const id = parts[1]
122
- if (!id) return 'Usage: /schedule rm <id>'
123
- return service.delete(id).message
124
- }
125
-
126
- if (first === 'run') {
127
- const id = parts[1]
128
- if (!id) return 'Usage: /schedule run <id>'
129
- const result = await service.run(id)
130
- return result.message
131
- }
174
+ // 子命令路由:查表命中走对应分支,落空走创建任务分支
175
+ const handler = SUBCOMMAND_HANDLERS.get(first)
176
+ if (handler) return handler(service, parts)
132
177
 
133
- // 创建任务分支
134
- const kind = first === 'once' ? 'once' as const : first === 'cron' ? 'recurring' as const : undefined
135
- const scheduleStart = kind ? 1 : 0
136
- const scheduleInput = parts[scheduleStart]
137
- if (!scheduleInput) return 'Usage: /schedule <schedule> <prompt>'
138
-
139
- const prompt = parts.slice(scheduleStart + 1).join(' ')
140
- if (!prompt) return 'Usage: /schedule <schedule> <prompt>'
141
-
142
- const result = await service.create(prompt, scheduleInput, { kind })
143
- return result.message
178
+ return createTaskFromArgs(service, parts, first)
144
179
  }
package/src/replay.ts CHANGED
@@ -24,16 +24,16 @@ const HISTORY_LIMIT = 20
24
24
  * pi-scheduler:task custom entries 折叠成运行时任务 Map。
25
25
  *
26
26
  * 两步:
27
- * ① 全量折叠——遍历 customType==='pi-scheduler:task' 的 entries,按 op 类型折叠:
27
+ * ① 全量折叠(foldEntries)——遍历 customType==='pi-scheduler:task' 的 entries,按 op 类型折叠:
28
28
  * - upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)
29
29
  * - advance → nextRunAt=新值 / lastRunAt=at / runCount++ / history.push(裁20) / lastStatus=status(gap2)
30
30
  * - toggle → enabled=新值
31
31
  * - delete → 从 Map 移除(后续同 taskId 的 advance/toggle 对不存在 taskId 为 no-op,安全)
32
32
  * advance/toggle/delete 对不存在的 taskId 为 no-op,因此 fork 继承的完整序列必须先全量折叠,
33
33
  * 不能在处理 upsert 时按 owner 跳过(否则后续无 owner 字段的 op 会作用于「不存在的 taskId」丢失)。
34
- * ② 折叠完成后按 task.ownerSessionFile !== currentSessionFile 整体过滤(gap1):移除 fork
35
- * 继承的非 owner 任务——fork 出的 session B 继承了 owner=A 的 upsert+advance 序列,但 B
36
- * 不应拥有 A 的任务,重放后整体过滤掉。
34
+ * ② 折叠完成后按 task.ownerSessionFile !== currentSessionFile 整体过滤(gap1,filterNonOwnerTasks):
35
+ * 移除 fork 继承的非 owner 任务——fork 出的 session B 继承了 owner=A 的 upsert+advance 序列,
36
+ * 但 B 不应拥有 A 的任务,重放后整体过滤掉。
37
37
  *
38
38
  * append-only 时序保证 nextRunAt 不回退(D1):advance entry 按写入顺序折叠,自然取到最后推进值。
39
39
  *
@@ -45,61 +45,8 @@ export function replayFoldEntries(
45
45
  ): Map<string, ScheduledTask> {
46
46
  try {
47
47
  const tasks = new Map<string, ScheduledTask>()
48
-
49
- for (const entry of entries) {
50
- if (entry.type !== 'custom' || entry.customType !== 'pi-scheduler:task') continue
51
- if (!isSchedulerEntryOp(entry.data)) continue
52
- const op = entry.data // SchedulerEntryOp(守卫 isSchedulerEntryOp 收窄)
53
-
54
- try {
55
- switch (op.op) {
56
- case 'upsert': {
57
- tasks.set(op.taskId, { ...snapshotToTask(op.task), ownerSessionFile: op.ownerSessionFile })
58
- break
59
- }
60
- case 'advance': {
61
- const task = tasks.get(op.taskId)
62
- if (!task) break // no-op for unknown taskId(fork 场景安全)
63
- task.nextRunAt = op.nextRunAt
64
- task.lastRunAt = op.at
65
- task.runCount += 1
66
- task.history.push({ at: op.at, status: op.status })
67
- if (task.history.length > HISTORY_LIMIT) task.history.shift()
68
- task.lastStatus = op.status // gap2:advance 必须恢复 lastStatus(不只 nextRunAt/lastRunAt/runCount/history)
69
- break
70
- }
71
- case 'toggle': {
72
- const task = tasks.get(op.taskId)
73
- if (!task) break
74
- task.enabled = op.enabled
75
- // P1:enable 重算到未来的 nextRunAt 随 toggle op 持久化;重放时应用,
76
- // 防 upsert 快照回退到旧过期值导致首个 tick 立即触发
77
- if (op.nextRunAt !== undefined) task.nextRunAt = op.nextRunAt
78
- break
79
- }
80
- case 'delete': {
81
- tasks.delete(op.taskId)
82
- break
83
- }
84
- }
85
- } catch (err) {
86
- // MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
87
- // snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
88
- // 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
89
- logger.warn('skipping corrupted scheduler entry', { error: err instanceof Error ? err.message : String(err) })
90
- continue
91
- }
92
- }
93
-
94
- // 步骤② fork owner 过滤(gap1):折叠完成后整体移除非 owner 任务。
95
- // currentSessionFile 为 undefined(--no-session 模式)时,所有带 ownerSessionFile 的任务都被移除
96
- // (无 session 文件可归属,等同空 session)——该模式下 addTask 用 '' 兜底 owner,此处比较仍一致。
97
- for (const [id, task] of tasks) {
98
- if (task.ownerSessionFile !== currentSessionFile) {
99
- tasks.delete(id)
100
- }
101
- }
102
-
48
+ foldEntries(entries, tasks)
49
+ filterNonOwnerTasks(tasks, currentSessionFile)
103
50
  return tasks
104
51
  } catch (err) {
105
52
  // gap4:session JSONL 损坏 / 迭代器抛错时降级为无任务,不让 session_start 崩溃。
@@ -108,6 +55,97 @@ export function replayFoldEntries(
108
55
  }
109
56
  }
110
57
 
58
+ /** 步骤① 全量折叠:跳过非本扩展 entry 与守卫不过的损坏 entry;单条折叠抛错只跳过该条(MF-2)。 */
59
+ function foldEntries(entries: Iterable<SchedulerEntryLike>, tasks: Map<string, ScheduledTask>): void {
60
+ for (const entry of entries) {
61
+ if (entry.type !== 'custom' || entry.customType !== 'pi-scheduler:task') continue
62
+ if (!isSchedulerEntryOp(entry.data)) continue
63
+ const op = entry.data // SchedulerEntryOp(守卫 isSchedulerEntryOp 收窄)
64
+
65
+ try {
66
+ applyOp(tasks, op)
67
+ } catch (err) {
68
+ // MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
69
+ // snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
70
+ // 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
71
+ logger.warn('skipping corrupted scheduler entry', { error: err instanceof Error ? err.message : String(err) })
72
+ continue
73
+ }
74
+ }
75
+ }
76
+
77
+ /** 按 op 判别值分发到对应折叠函数;无 default——未知 op 静默跳过(守卫已排除,理论不可达)。 */
78
+ function applyOp(tasks: Map<string, ScheduledTask>, op: SchedulerEntryOp): void {
79
+ switch (op.op) {
80
+ case 'upsert':
81
+ applyUpsert(tasks, op)
82
+ break
83
+ case 'advance':
84
+ applyAdvance(tasks, op)
85
+ break
86
+ case 'toggle':
87
+ applyToggle(tasks, op)
88
+ break
89
+ case 'delete':
90
+ applyDelete(tasks, op)
91
+ break
92
+ }
93
+ }
94
+
95
+ /** upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)。 */
96
+ function applyUpsert(
97
+ tasks: Map<string, ScheduledTask>,
98
+ op: Extract<SchedulerEntryOp, { op: 'upsert' }>,
99
+ ): void {
100
+ tasks.set(op.taskId, { ...snapshotToTask(op.task), ownerSessionFile: op.ownerSessionFile })
101
+ }
102
+
103
+ /** advance → 推进运行末态;对不存在的 taskId 为 no-op(fork 场景安全)。 */
104
+ function applyAdvance(
105
+ tasks: Map<string, ScheduledTask>,
106
+ op: Extract<SchedulerEntryOp, { op: 'advance' }>,
107
+ ): void {
108
+ const task = tasks.get(op.taskId)
109
+ if (!task) return // no-op for unknown taskId(fork 场景安全)
110
+ task.nextRunAt = op.nextRunAt
111
+ task.lastRunAt = op.at
112
+ task.runCount += 1
113
+ task.history.push({ at: op.at, status: op.status })
114
+ if (task.history.length > HISTORY_LIMIT) task.history.shift()
115
+ task.lastStatus = op.status // gap2:advance 必须恢复 lastStatus(不只 nextRunAt/lastRunAt/runCount/history)
116
+ }
117
+
118
+ /** toggle → enabled=新值;携带 nextRunAt 时一并应用(P1,防快照回退到旧过期值)。 */
119
+ function applyToggle(
120
+ tasks: Map<string, ScheduledTask>,
121
+ op: Extract<SchedulerEntryOp, { op: 'toggle' }>,
122
+ ): void {
123
+ const task = tasks.get(op.taskId)
124
+ if (!task) return
125
+ task.enabled = op.enabled
126
+ // P1:enable 重算到未来的 nextRunAt 随 toggle op 持久化;重放时应用,
127
+ // 防 upsert 快照回退到旧过期值导致首个 tick 立即触发
128
+ if (op.nextRunAt !== undefined) task.nextRunAt = op.nextRunAt
129
+ }
130
+
131
+ /** delete → 从 Map 移除;taskId 不存在时 no-op。 */
132
+ function applyDelete(tasks: Map<string, ScheduledTask>, op: Extract<SchedulerEntryOp, { op: 'delete' }>): void {
133
+ tasks.delete(op.taskId)
134
+ }
135
+
136
+ /**
137
+ * 步骤② fork owner 过滤(gap1):折叠完成后整体移除非 owner 任务。
138
+ * currentSessionFile 为 undefined(--no-session 模式)时,所有带 ownerSessionFile 的任务都被移除
139
+ * (无 session 文件可归属,等同空 session)——该模式下 addTask 用 '' 兜底 owner,此处比较仍一致。
140
+ */
141
+ function filterNonOwnerTasks(tasks: Map<string, ScheduledTask>, currentSessionFile: string | undefined): void {
142
+ for (const [id, task] of tasks) {
143
+ if (task.ownerSessionFile !== currentSessionFile) {
144
+ tasks.delete(id)
145
+ }
146
+ }
147
+ }
148
+
111
149
  /**
112
150
  * 类型守卫:判断 entry.data 是否为合法 SchedulerEntryOp——按 op 变体校验必填字段,
113
151
  * 不只校验 op 判别值(MF-2 修复)。仅按判别值收窄时,损坏 entry `{op:'upsert'}`(缺 task)
package/src/runtime.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
2
+ import { guardStaleCtx, toErrorMessage } from '@zhushanwen/pi-ext-guards'
2
3
  import { getLogger } from '@zhushanwen/pi-extension-logger'
3
4
 
4
5
  import type { DeliveryHandle, DeliveryMessage } from '@xyz-agent/session-delivery'
@@ -25,16 +26,13 @@ const TICK_INTERVAL_MS = 30_000
25
26
  const DEFAULT_EXPIRY_DAYS = 7
26
27
  const DEFAULT_EXPIRY_MS = DEFAULT_EXPIRY_DAYS * MS_PER_DAY // 7 days
27
28
  const HISTORY_LIMIT = 20 // 与 replayFoldEntries 的裁剪上限一致(advance 折叠 / dispatch 累积共用)
28
- // pi ExtensionRunner 在 session 替换后访问 stale ctx 时抛出的错误文案片段。
29
- // 兜底通道(防御纵深):G1 模块级代际检测(isCtxStale)为主判,覆盖同模块环境内的 session
30
- // 替换路径(newSession/fork/switchSession:extensionCache 命中,factory 重跑但模块环境共享,
31
- // 模块级代数被新闭包递增);本子串覆盖代际盲区——显式 reload / cwd 变化触发
32
- // clearExtensionCache 后 jiti 重新 import 产生全新模块环境,旧闭包引用的模块级代数冻结
33
- // 不再递增,isCtxStale 恒 false,此时除 session_shutdown teardown 主防线外只剩错误文案
34
- // 能识别 stale。
35
- // 注意:pi 非契约 API(Error message 非稳定接口),pi 升级需回归验证 runtime.test.ts 的
36
- // U1 / G1-d 文案锚定用例;文案变更时此兜底失效,后果为 timer 泄漏 + 每 30s warn(不 crash)。
37
- const STALE_CTX_MARKER = 'stale after session replacement'
29
+ // STALE_CTX_MARKER(文案兜底分诊词)已迁移到 ext-guards 共享守卫(guardStaleCtx 内部
30
+ // 引用,本文件不再直接持有)。语义:G1 模块级代际检测(isCtxStale)为主判;文案子串
31
+ // 覆盖代际盲区——显式 reload / cwd 变化触发 clearExtensionCache 后 jiti 重新 import 产生
32
+ // 全新模块环境,旧闭包引用的模块级代数冻结不再递增,isCtxStale 恒 false,只剩错误文案
33
+ // 能识别 stale。pi 非契约 API(Error message 非稳定接口):文案由 docs/pi-semantics.json
34
+ // PS-30 探针随 pi 版本门禁自动重验;pi 升级仍需回归 runtime.test.ts 的 U1 / G1-d 文案
35
+ // 锚定用例。文案变更时此兜底失效,后果为 timer 泄漏 + 每 30s warn(不 crash)。
38
36
 
39
37
  export class SchedulerRuntime {
40
38
  private tasks: Map<string, ScheduledTask> = new Map()
@@ -201,27 +199,28 @@ export class SchedulerRuntime {
201
199
  startScheduler(): void {
202
200
  if (this.tickTimer) return
203
201
  this.tickTimer = setInterval(() => {
204
- // G1(代际前置检查,S9):本 runtime 所属 session 已被替换 → timer 属泄漏资源,
205
- // 自停退场且不进入本轮 tick(不触碰捕获的 stale ctx)。主防线是 F1(session_start
206
- // 停旧 timer),此处覆盖 F1 未能触达的泄漏路径——且不依赖「stale ctx 访问恰好抛错」
207
- // 或 pi 错误文案,代际一翻转即可静默退场。
208
- if (this.isCtxStale?.()) {
209
- this.retireStaleTimer()
210
- return
211
- }
212
- // F2(防御兜底):fire-and-forget 的 tick 链路必须自带 catch——tick 内任何异常
213
- // (典型:session 替换后泄漏 timer 的 onAfterTick → refreshWidget 访问 stale ctx.ui 抛错)
214
- // 若无人接住即 unhandledRejection,直接崩掉 pi 主进程。分诊:G1 模块级代数比对为主判
215
- // (契约内,不受 pi 文案变更影响),STALE_CTX_MARKER 子串为兜底(覆盖 reload 产生全新
216
- // 模块环境后旧闭包代数冻结、isCtxStale 恒 false 的盲区)。stale 类错误说明本 runtime
217
- // 所属 session 已被替换,timer 属泄漏资源,自停退场;其他错误仅告警,不终止调度。
218
- void this.tickScheduler().catch((err: unknown) => {
219
- const message = err instanceof Error ? err.message : String(err)
220
- if (this.isCtxStale?.() || message.includes(STALE_CTX_MARKER)) {
221
- this.retireStaleTimer()
222
- } else {
223
- logger.warn('tick error', { error: message })
224
- }
202
+ // 三件套语义等价迁移到共享守卫 guardStaleCtx(crash-resilience D1 / u1-ext-guard;
203
+ // 守卫语义 = 本处原地实现的泛化,迁移对照逐条可证):
204
+ // 1. G1 前置检查(S9):守卫的 isCtxStale 命中 → onStale(= retireStaleTimer)且
205
+ // tickScheduler 不执行——与迁移前「前置分支 return」等价:本 runtime 所属 session
206
+ // 已被替换 → timer 属泄漏资源自停退场,不触碰捕获的 stale ctx,不依赖 pi 错误文案。
207
+ // 主防线仍是 F1(session_start 停旧 timer),此处覆盖 F1 未能触达的泄漏路径。
208
+ // 2. F2 catch 分诊(防御兜底):守卫对 tickScheduler 的 rejection 挂同一分诊谓词
209
+ // `isCtxStale() || 文案含 STALE_CTX_MARKER`(字面不变)——stale → retireStaleTimer;
210
+ // 非 stale → 原样 reject,由下方 .catch 仅告警不终止调度('tick error' 文案不变,
211
+ // U2/G1-c 锚定)。fire-and-forget 的 tick 链路必须自带 catch——tick 内任何异常
212
+ // (典型:泄漏 timer 的 onAfterTick → refreshWidget 访问 stale ctx.ui 抛错)无人接住
213
+ // 即 unhandledRejection,直接崩掉 pi 主进程(E1 同机制)。
214
+ // 3. retireStaleTimer 自停:未改('tick stopped' warn 口径与幂等 stopScheduler 原样,
215
+ // U1/G1-b/G1-d 锚定)。
216
+ // `?.catch`:前置检查命中时守卫返回 undefined(fn 未执行、无 Promise、无 rejection
217
+ // 可接——retire 已由 onStale 完成);非 stale 时返回 Promise,非 stale rejection
218
+ // 流到 .catch 仅告警。
219
+ void guardStaleCtx(() => this.tickScheduler(), {
220
+ isCtxStale: this.isCtxStale,
221
+ onStale: () => this.retireStaleTimer(),
222
+ })?.catch((err: unknown) => {
223
+ logger.warn('tick error', { error: toErrorMessage(err) })
225
224
  })
226
225
  }, TICK_INTERVAL_MS)
227
226
  }
package/vitest.config.ts CHANGED
@@ -2,6 +2,8 @@ import { defineConfig } from 'vitest/config'
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ reporters: ['default', 'junit'],
6
+ outputFile: { junit: './test-results/vitest-junit.xml' },
5
7
  include: ['src/__tests__/**/*.test.ts'],
6
8
  },
7
9
  })