@zhushanwen/pi-scheduler 0.4.0 → 0.4.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.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "xyz-agent": {
@@ -45,7 +45,8 @@
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
- "@xyz-agent/session-delivery": "0.2.0"
48
+ "@xyz-agent/session-delivery": "0.3.0",
49
+ "@zhushanwen/pi-extension-logger": "0.3.0"
49
50
  },
50
51
  "scripts": {
51
52
  "test": "vitest run",
@@ -2,19 +2,21 @@
2
2
  * U4_DISPATCH_INFLIGHT:调用方 in-flight 守卫验收
3
3
  *
4
4
  * 两个子用例:
5
- * (1) 同一 taskId 的 dispatchTask 并发调用 → 第二次立即返回 false + console.warn
5
+ * (1) 同一 taskId 的 dispatchTask 并发调用 → 第二次立即返回 false + logger.warn
6
6
  * (2) 第一次 dispatchTask 完成后(finally 清除)→ 第二次正常执行
7
7
  *
8
8
  * 断言 delivery send 调用总次数为 1(拦截场景)或 2(串行场景)。
9
9
  */
10
10
  import { describe, expect, it, vi } from 'vitest'
11
11
 
12
+ import { getLogger } from '@zhushanwen/pi-extension-logger'
13
+
12
14
  import { MockSchedulerBackend } from '../backend.js'
13
15
  import { SchedulerRuntime } from '../runtime.js'
14
16
 
15
17
  describe('U4_DISPATCH_INFLIGHT: 调用方 in-flight 守卫', () => {
16
18
  it('(1) 同一 taskId 并发 dispatch → 第二次被拦截(send 只调 1 次 + warn)', async () => {
17
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
19
+ const warnSpy = vi.spyOn(getLogger('scheduler'), 'warn').mockImplementation(() => {})
18
20
 
19
21
  // 可控延迟的 sendMessage:让第一次 dispatch 挂起
20
22
  let resolveSend: (() => void) | undefined
@@ -80,7 +82,7 @@ describe('U4_DISPATCH_INFLIGHT: 调用方 in-flight 守卫', () => {
80
82
  })
81
83
 
82
84
  it('(3) 非 force + 有 delivery handle 时,in-flight 守卫同样拦截并发 dispatch', async () => {
83
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
85
+ const warnSpy = vi.spyOn(getLogger('scheduler'), 'warn').mockImplementation(() => {})
84
86
  const backend = new MockSchedulerBackend()
85
87
  backend.deliveryHandle = {
86
88
  send: vi.fn(),
@@ -4,6 +4,16 @@ import * as path from 'node:path'
4
4
 
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
6
 
7
+ // Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
8
+ const { loggerMock } = vi.hoisted(() => ({
9
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
10
+ }))
11
+ vi.mock('@zhushanwen/pi-extension-logger', () => ({
12
+ getLogger: () => loggerMock,
13
+ createLogger: () => loggerMock,
14
+ setPiHandle: vi.fn(),
15
+ }))
16
+
7
17
  import { getLegacyStorePath, importLegacyStore } from '../importer.js'
8
18
  import type { ScheduledTask } from '../types.js'
9
19
 
@@ -52,9 +62,8 @@ describe('importLegacyStore', () => {
52
62
  vi.mocked(fs.existsSync).mockReturnValue(false)
53
63
  vi.mocked(fs.readFileSync).mockReturnValue('{}')
54
64
  vi.mocked(fs.unlinkSync).mockImplementation(() => {})
55
- // 抑制 importer console.log/warn 输出(断言用 spy)
56
- vi.spyOn(console, 'log').mockImplementation(() => {})
57
- vi.spyOn(console, 'warn').mockImplementation(() => {})
65
+ // 清理 logger mock(logger 默认 no-op,无需抑制输出)
66
+ loggerMock.warn.mockClear()
58
67
  })
59
68
 
60
69
  afterEach(() => {
@@ -101,9 +110,10 @@ describe('importLegacyStore', () => {
101
110
  expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
102
111
  expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
103
112
 
104
- // 5) 进度日志(console.warn,项目 convention 禁 console.log)
105
- expect(console.warn).toHaveBeenCalledWith(
106
- expect.stringContaining(`imported 2 legacy tasks from ${importedPath}`),
113
+ // 5) 进度日志
114
+ expect(loggerMock.warn).toHaveBeenCalledWith(
115
+ expect.stringContaining('imported legacy tasks'),
116
+ expect.objectContaining({ count: 2 }),
107
117
  )
108
118
  })
109
119
 
@@ -177,8 +187,9 @@ describe('importLegacyStore', () => {
177
187
  const appendEntry = vi.fn()
178
188
 
179
189
  expect(() => importLegacyStore(cwd, { appendEntry }, sessionFile)).not.toThrow()
180
- expect(console.warn).toHaveBeenCalledWith(
181
- expect.stringContaining('[scheduler] import failed'),
190
+ expect(loggerMock.warn).toHaveBeenCalledWith(
191
+ expect.stringContaining('import failed'),
192
+ expect.objectContaining({ error: expect.stringContaining('not valid JSON') }),
182
193
  )
183
194
  expect(appendEntry).toHaveBeenCalledTimes(0) // parse 失败,无 append
184
195
  })
@@ -215,7 +226,10 @@ describe('importLegacyStore', () => {
215
226
  // append 已发生,但 unlink 未执行——数据可能仅内存,销毁源文件 = 永久丢失
216
227
  expect(appendEntry).toHaveBeenCalledTimes(1)
217
228
  expect(fs.unlinkSync).not.toHaveBeenCalled()
218
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('deferring'))
229
+ expect(loggerMock.warn).toHaveBeenCalledWith(
230
+ expect.stringContaining('deferring'),
231
+ expect.objectContaining({ count: 1 }),
232
+ )
219
233
 
220
234
  // 情形1:flush 已发生(sessionFile 出现,.imported 仍在)→ cleanup 删除 .imported
221
235
  vi.mocked(fs.existsSync).mockImplementation(p => p === sessionFile || p === importedPath)
@@ -20,6 +20,16 @@
20
20
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
21
21
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22
22
 
23
+ // Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
24
+ const { loggerMock } = vi.hoisted(() => ({
25
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
26
+ }))
27
+ vi.mock('@zhushanwen/pi-extension-logger', () => ({
28
+ getLogger: () => loggerMock,
29
+ createLogger: () => loggerMock,
30
+ setPiHandle: vi.fn(),
31
+ }))
32
+
23
33
  // 与 index-session-start.test.ts 同款(MF-3):mock 掉 importer,装配路径仍被调用、FS 副作用为零。
24
34
  vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
25
35
 
@@ -127,7 +137,7 @@ describe('G1: index.ts 代际接线(S9)', () => {
127
137
  it('factory 重跑:第二次 factory 执行 + session_start 后,第一代 runtime isCtxStale 为 true 且 tick 前置自停', async () => {
128
138
  vi.useFakeTimers()
129
139
  vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
130
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
140
+ loggerMock.warn.mockClear()
131
141
  try {
132
142
  // 第一代:独立 factory 执行 + session_start 装配(runtime1 真实 startScheduler)
133
143
  const first = createMockPi()
@@ -156,15 +166,14 @@ describe('G1: index.ts 代际接线(S9)', () => {
156
166
  // tick 前置自停:第一代 runtime 的泄漏 timer 在下个 tick 被代际前置检查拦截
157
167
  // (G1-b 的生产路径验证:warn "tick stopped" + timer 自停,后续 tick 不再发生)
158
168
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
159
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
169
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
160
170
  expect(warnText).toContain('tick stopped')
161
171
  expect(warnText).not.toContain('tick error')
162
172
 
163
- const warnCountAfterSelfStop = warnSpy.mock.calls.length
173
+ const warnCountAfterSelfStop = loggerMock.warn.mock.calls.length
164
174
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 warn
165
- expect(warnSpy.mock.calls.length).toBe(warnCountAfterSelfStop)
175
+ expect(loggerMock.warn.mock.calls.length).toBe(warnCountAfterSelfStop)
166
176
  } finally {
167
- warnSpy.mockRestore()
168
177
  vi.useRealTimers()
169
178
  }
170
179
  })
@@ -1,5 +1,15 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
+ // Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
4
+ const { loggerMock } = vi.hoisted(() => ({
5
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
+ }))
7
+ vi.mock('@zhushanwen/pi-extension-logger', () => ({
8
+ getLogger: () => loggerMock,
9
+ createLogger: () => loggerMock,
10
+ setPiHandle: vi.fn(),
11
+ }))
12
+
3
13
  import { replayFoldEntries, type SchedulerEntryLike } from '../replay.js'
4
14
  import type { SchedulerEntryOp, TaskSnapshot } from '../types.js'
5
15
 
@@ -141,8 +151,8 @@ describe('replayFoldEntries', () => {
141
151
  })
142
152
 
143
153
  // ── TC-W-GETENTRIES-FALLBACK:getEntries 异常兜底(gap4)──
144
- it('TC-W-GETENTRIES-FALLBACK: 迭代器抛错时 console.warn + 返回空 Map,不崩溃', () => {
145
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
154
+ it('TC-W-GETENTRIES-FALLBACK: 迭代器抛错时 logger.warn + 返回空 Map,不崩溃', () => {
155
+ loggerMock.warn.mockClear()
146
156
  // 构造迭代时抛错的 iterable(模拟 session JSONL 损坏)
147
157
  const throwingIterable: Iterable<SchedulerEntryLike> = {
148
158
  [Symbol.iterator]() {
@@ -158,11 +168,10 @@ describe('replayFoldEntries', () => {
158
168
 
159
169
  const result = replayFoldEntries(throwingIterable, '/s.json')
160
170
  expect(result.size).toBe(0)
161
- expect(warnSpy).toHaveBeenCalled()
162
- const msg = warnSpy.mock.calls[0]![0] as string
171
+ expect(loggerMock.warn).toHaveBeenCalled()
172
+ const msg = loggerMock.warn.mock.calls[0]![0] as string
163
173
  expect(msg).toContain('replayFoldEntries failed')
164
- expect(msg).toContain('JSONL corrupted')
165
- warnSpy.mockRestore()
174
+ expect(loggerMock.warn.mock.calls[0]![1]).toEqual(expect.objectContaining({ error: expect.stringContaining('JSONL corrupted') }))
166
175
  })
167
176
 
168
177
  // ── gap2 补强:advance 后 lastStatus/lastRunAt/runCount/history 全恢复 ──
@@ -258,7 +267,7 @@ describe('replayFoldEntries', () => {
258
267
  // ── MF-2:守卫按变体校验必填字段——损坏 entry 只跳过该条,不清空全部任务 ──
259
268
  it('MF-2: op 合法但缺必填字段的损坏 entry 被跳过,其余任务保留', () => {
260
269
  const session = '/s.json'
261
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
270
+ loggerMock.warn.mockClear()
262
271
  const entries: SchedulerEntryLike[] = [
263
272
  { type: 'custom', customType: 'pi-scheduler:task', data: { op: 'upsert' } }, // 缺 taskId+task
264
273
  { type: 'custom', customType: 'pi-scheduler:task', data: { op: 'upsert', taskId: 'A' } }, // 缺 task
@@ -272,12 +281,11 @@ describe('replayFoldEntries', () => {
272
281
  const result = replayFoldEntries(entries, session)
273
282
  expect(result.size).toBe(1)
274
283
  expect(result.get('A')).toBeDefined()
275
- warnSpy.mockRestore()
276
284
  })
277
285
 
278
286
  it('MF-2: upsert task 嵌套数据损坏(history 非数组)→ 逐条跳过该 entry,其余任务保留', () => {
279
287
  const session = '/s.json'
280
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
288
+ loggerMock.warn.mockClear()
281
289
  const entries: SchedulerEntryLike[] = [
282
290
  {
283
291
  type: 'custom',
@@ -297,10 +305,9 @@ describe('replayFoldEntries', () => {
297
305
  expect(result.get('GOOD')).toBeDefined()
298
306
  expect(result.has('BAD')).toBe(false)
299
307
  // 逐条跳过 warn(非外层整体 catch 的 replayFoldEntries failed warn)
300
- expect(warnSpy).toHaveBeenCalled()
301
- const msg = warnSpy.mock.calls[0]![0] as string
308
+ expect(loggerMock.warn).toHaveBeenCalled()
309
+ const msg = loggerMock.warn.mock.calls[0]![0] as string
302
310
  expect(msg).toContain('skipping corrupted scheduler entry')
303
- warnSpy.mockRestore()
304
311
  })
305
312
 
306
313
  afterEach(() => {
@@ -1,6 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import type { DeliveryMessage } from '@xyz-agent/session-delivery'
3
3
 
4
+ // Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
5
+ const { loggerMock } = vi.hoisted(() => ({
6
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
7
+ }))
8
+ vi.mock('@zhushanwen/pi-extension-logger', () => ({
9
+ getLogger: () => loggerMock,
10
+ createLogger: () => loggerMock,
11
+ setPiHandle: vi.fn(),
12
+ }))
13
+
4
14
  import { MockSchedulerBackend } from '../backend.js'
5
15
  import { SchedulerRuntime } from '../runtime.js'
6
16
 
@@ -237,7 +247,7 @@ describe('SchedulerRuntime', () => {
237
247
  })
238
248
 
239
249
  it('sendMessage 挂起期间下一 tick 同任务被跳过:不双注入、warn in-flight、完成后 runCount=1', async () => {
240
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
250
+ loggerMock.warn.mockClear()
241
251
  let resolveSend: (() => void) | undefined
242
252
  const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
243
253
  backend.sendMessage = vi.fn(() => sendPromise)
@@ -254,7 +264,7 @@ describe('SchedulerRuntime', () => {
254
264
  // in-flight 守卫 → skip + warn(修复前:同一 prompt 双注入)
255
265
  await runtime.tickScheduler()
256
266
  expect(backend.sendMessage).toHaveBeenCalledTimes(1)
257
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
267
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
258
268
  expect(warnText).toContain('already in flight')
259
269
 
260
270
  // 放行挂起的 sendMessage:tick1 正常收尾(状态推进恰好一次)
@@ -263,7 +273,6 @@ describe('SchedulerRuntime', () => {
263
273
  expect(backend.sendMessage).toHaveBeenCalledTimes(1)
264
274
  expect(task.runCount).toBe(1)
265
275
  expect(task.pending).toBe(false)
266
- warnSpy.mockRestore()
267
276
  })
268
277
 
269
278
  it('挂起 dispatch 只挡同任务:其他任务在下一 tick 正常 dispatch 不受影响', async () => {
@@ -412,7 +421,7 @@ describe('SchedulerRuntime', () => {
412
421
 
413
422
  // ── TC-W-APPEND-FAIL:appendEntry 失败捕获(ER-APPEND-FAIL)──
414
423
  it('TC-W-APPEND-FAIL: appendEntry 失败不抛、保留内存态、不污染 lastError', async () => {
415
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
424
+ loggerMock.warn.mockClear()
416
425
  backend.appendError = new Error('pi internal')
417
426
 
418
427
  // addTask 内 appendEntry 抛错 → 被捕获(console.warn),不 rethrow;内存态已更新(task 仍在)
@@ -421,7 +430,7 @@ describe('SchedulerRuntime', () => {
421
430
  expect(task.enabled).toBe(true)
422
431
  // appendEntrySafe 不污染业务态(append 失败是 transient,不设 lastError)
423
432
  expect(task.lastError).toBeUndefined()
424
- expect(warnSpy).toHaveBeenCalled()
433
+ expect(loggerMock.warn).toHaveBeenCalled()
425
434
 
426
435
  // tickScheduler 同样不抛:dispatch 成功后 append advance 抛错被捕获,nextRunAt 已推进(内存态正确)
427
436
  task.nextRunAt = Date.now() - 1000
@@ -432,7 +441,6 @@ describe('SchedulerRuntime', () => {
432
441
  // nextRunAt 已推进到未来(内存态正确,append 失败只丢持久化)
433
442
  expect(updated.nextRunAt).toBe(Date.now() + 60000)
434
443
  expect(updated.lastError).toBeUndefined() // 不被 append 失败污染
435
- warnSpy.mockRestore()
436
444
  })
437
445
 
438
446
  // ── TC-W-ON-AFTER-TICK:onAfterTick 回调(W2)──
@@ -750,7 +758,7 @@ describe('SchedulerRuntime', () => {
750
758
  })
751
759
 
752
760
  it('U1: stale 错误 → warn "tick stopped" + timer 自停,后续 tick 不再发生', async () => {
753
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
761
+ loggerMock.warn.mockClear()
754
762
  const nowSpy = vi.spyOn(backend, 'now')
755
763
  runtime.onAfterTick(() => {
756
764
  throw new Error('This extension ctx is stale after session replacement or reload.')
@@ -759,7 +767,7 @@ describe('SchedulerRuntime', () => {
759
767
  runtime.startScheduler()
760
768
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:stale 抛 → catch 分诊 → 自停
761
769
 
762
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
770
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
763
771
  expect(warnText).toContain('tick stopped')
764
772
  expect(warnText).not.toContain('tick error')
765
773
 
@@ -768,12 +776,11 @@ describe('SchedulerRuntime', () => {
768
776
 
769
777
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 60s:timer 已停,无新 tick
770
778
  expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop) // now 计数不再增长
771
- warnSpy.mockRestore()
772
779
  nowSpy.mockRestore()
773
780
  })
774
781
 
775
782
  it('U2: 非 stale 错误 → warn "tick error" 且调度继续(advance 两次 now 计数 +2)', async () => {
776
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
783
+ loggerMock.warn.mockClear()
777
784
  const nowSpy = vi.spyOn(backend, 'now')
778
785
  runtime.onAfterTick(() => {
779
786
  throw new Error('boom')
@@ -782,7 +789,7 @@ describe('SchedulerRuntime', () => {
782
789
  runtime.startScheduler()
783
790
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:warn 但不停
784
791
 
785
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
792
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
786
793
  expect(warnText).toContain('tick error')
787
794
  expect(warnText).not.toContain('tick stopped')
788
795
 
@@ -791,7 +798,6 @@ describe('SchedulerRuntime', () => {
791
798
 
792
799
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 2 个后续 tick 照常
793
800
  expect(nowSpy.mock.calls.length).toBe(countAfterFirstTick + 2)
794
- warnSpy.mockRestore()
795
801
  nowSpy.mockRestore()
796
802
  })
797
803
 
@@ -830,7 +836,7 @@ describe('SchedulerRuntime', () => {
830
836
  })
831
837
 
832
838
  it('G1-a: in-flight tick 期间代际翻转 + 非文案错误 → warn "tick stopped" + 自停(不依赖 pi 错误文案)', async () => {
833
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
839
+ loggerMock.warn.mockClear()
834
840
  const nowSpy = vi.spyOn(backend, 'now')
835
841
  // tick 内先翻世代(模拟 session 替换交错发生在 dispatch await 窗口),再抛与
836
842
  // pi 文案完全无关的错误——旧实现按文案分诊会误判为普通错误继续调度(若 pi 改文案)
@@ -842,7 +848,7 @@ describe('SchedulerRuntime', () => {
842
848
  genRuntime.startScheduler()
843
849
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:catch 分诊走 G1 代际 → 自停
844
850
 
845
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
851
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
846
852
  expect(warnText).toContain('tick stopped')
847
853
  expect(warnText).not.toContain('tick error')
848
854
 
@@ -851,12 +857,11 @@ describe('SchedulerRuntime', () => {
851
857
 
852
858
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 tick
853
859
  expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop)
854
- warnSpy.mockRestore()
855
860
  nowSpy.mockRestore()
856
861
  })
857
862
 
858
863
  it('G1-b: 代际翻转后泄漏 timer 在下个 tick 前置检查自停——不进入 tick(backend.now 零调用)', async () => {
859
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
864
+ loggerMock.warn.mockClear()
860
865
  const nowSpy = vi.spyOn(backend, 'now')
861
866
  genRuntime.startScheduler()
862
867
  // session 替换:代际翻转(F1 未能触达的泄漏 timer 场景;无任何错误发生)
@@ -864,7 +869,7 @@ describe('SchedulerRuntime', () => {
864
869
 
865
870
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:前置检查命中 → 自停
866
871
 
867
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
872
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
868
873
  expect(warnText).toContain('tick stopped')
869
874
  expect(warnText).not.toContain('tick error')
870
875
  // 前置检查在 tickScheduler 之前拦截:tick 本体未执行(now 零调用,无 dispatch/append)
@@ -872,12 +877,11 @@ describe('SchedulerRuntime', () => {
872
877
 
873
878
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,仍零调用
874
879
  expect(nowSpy).not.toHaveBeenCalled()
875
- warnSpy.mockRestore()
876
880
  nowSpy.mockRestore()
877
881
  })
878
882
 
879
883
  it('G1-c: isCtxStale 注入但返回 false + 非 stale 错误 → warn "tick error" 且调度继续', async () => {
880
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
884
+ loggerMock.warn.mockClear()
881
885
  const nowSpy = vi.spyOn(backend, 'now')
882
886
  // staleFlag 恒 false(beforeEach 初始化):代际未翻转,注入存在不改变分诊结果
883
887
  genRuntime.onAfterTick(() => {
@@ -887,7 +891,7 @@ describe('SchedulerRuntime', () => {
887
891
  genRuntime.startScheduler()
888
892
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:非 stale → warn 继续调度
889
893
 
890
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
894
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
891
895
  expect(warnText).toContain('tick error')
892
896
  expect(warnText).not.toContain('tick stopped')
893
897
 
@@ -896,12 +900,11 @@ describe('SchedulerRuntime', () => {
896
900
 
897
901
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 2 个后续 tick 照常
898
902
  expect(nowSpy.mock.calls.length).toBe(countAfterFirstTick + 2)
899
- warnSpy.mockRestore()
900
903
  nowSpy.mockRestore()
901
904
  })
902
905
 
903
906
  it('G1-d: isCtxStale 返回 false 但错误文案含 stale 片段 → 文案兜底仍自停(覆盖 reload 盲区)', async () => {
904
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
907
+ loggerMock.warn.mockClear()
905
908
  const nowSpy = vi.spyOn(backend, 'now')
906
909
  // reload 场景模拟:factory 重跑后旧闭包代际计数不再递增(staleFlag 恒 false),
907
910
  // 只有错误文案能识别 stale——兜底支必须独立于代际检测生效
@@ -912,7 +915,7 @@ describe('SchedulerRuntime', () => {
912
915
  genRuntime.startScheduler()
913
916
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:文案兜底 → 自停
914
917
 
915
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
918
+ const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
916
919
  expect(warnText).toContain('tick stopped')
917
920
  expect(warnText).not.toContain('tick error')
918
921
 
@@ -921,7 +924,6 @@ describe('SchedulerRuntime', () => {
921
924
 
922
925
  await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2)
923
926
  expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop)
924
- warnSpy.mockRestore()
925
927
  nowSpy.mockRestore()
926
928
  })
927
929
  })
package/src/backend.ts CHANGED
@@ -13,7 +13,7 @@ import type { ScheduledTask, SchedulerEntryOp } from './types.js'
13
13
  * - sendMessage: 到期 dispatch 的消息注入(生产实现委托 pi.sendMessage)
14
14
  * - appendEntry: 按 op 写 pi-scheduler:task custom entry(event sourcing)。
15
15
  * 生产实现委托 pi.appendEntry(同步落盘)。失败必须被调用方 try-catch(ER-APPEND-FAIL:
16
- * runtime 捕获后 console.warn + 不 rethrow,内存态已更新,at-least-once 已知恶化窗口)
16
+ * runtime 捕获后 logger.warn + 不 rethrow,内存态已更新,at-least-once 已知恶化窗口)
17
17
  * - getSessionFile: 当前 session JSONL 路径(addTask 构建 upsert op 的 ownerSessionFile 用;
18
18
  * --no-session 模式返回 undefined,调用方 ?? '' 兜底)
19
19
  * - now: 时间源(测试可注入固定值)
package/src/importer.ts CHANGED
@@ -4,9 +4,12 @@ import * as path from 'node:path'
4
4
 
5
5
  import { getAgentDir } from '@earendil-works/pi-coding-agent'
6
6
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
7
+ import { getLogger } from '@zhushanwen/pi-extension-logger'
7
8
 
8
9
  import type { ScheduledTask, SchedulerEntryOp, SchedulerStore, TaskSnapshot } from './types.js'
9
10
 
11
+ const logger = getLogger('scheduler')
12
+
10
13
  /**
11
14
  * 获取旧 store 文件路径:scheduler/<root>/<segments>/scheduler.json,双候选探测。
12
15
  *
@@ -143,9 +146,9 @@ function importFromFile(
143
146
  // 空 store(0 任务)无持久化依赖,直接删;resumed session(sessionFile 已存在)已即时落盘,删
144
147
  if (tasks.length === 0 || fs.existsSync(currentSessionFile)) {
145
148
  fs.unlinkSync(importedPath)
146
- // 内部诊断日志(非用户可见消息):用 console.warn 而非 console.log(项目 convention:
147
- // extensions console.log/info 防泄漏到 TUI;诊断输出统一 console.warn)
148
- console.warn(`[scheduler] imported ${tasks.length} legacy tasks from ${importedPath}`)
149
+ // 内部诊断日志(非用户可见消息):经共享 logger.warn appendEntry 持久化为 custom entry,
150
+ // 不污染 TUI(logging-conventions SSOT:诊断走 logger.warn,禁裸 console)。
151
+ logger.warn('imported legacy tasks', { count: tasks.length, path: importedPath })
149
152
  return undefined
150
153
  }
151
154
 
@@ -155,9 +158,10 @@ function importFromFile(
155
158
  // 出现 → 删;仍未 flush → 静默保留 .imported 供下次 session_start 的 handleImportedResidue
156
159
  // 崩溃恢复重导入。跨 session 重导入窗口 = session_start → 首个 turn_end(秒级):turn_end 后
157
160
  // .imported 已删,后续 session 启动看不到残留 → 双导入窗口闭合(R-CONCURRENT-IMPORT 已更新)
158
- console.warn(
159
- `[scheduler] imported ${tasks.length} legacy tasks (session not flushed; deferring ${importedPath} removal)`,
160
- )
161
+ logger.warn('imported legacy tasks (session not flushed; deferring removal)', {
162
+ count: tasks.length,
163
+ path: importedPath,
164
+ })
161
165
  return () => {
162
166
  // 幂等:已删(本进程或并发另一进程已处理)→ no-op。cleanup 在每次 turn_end 都会调用,重复调用安全
163
167
  if (!fs.existsSync(importedPath)) return
@@ -216,7 +220,7 @@ function handleImportedResidue(
216
220
  * 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务(pi _appendEntry 同步 push
217
221
  * fileEntries,design-review 已实测验证)。
218
222
  *
219
- * 整体降级(C1):read/parse/appendEntry 任一异常 → console.warn + 不 rethrow,不让
223
+ * 整体降级(C1):read/parse/appendEntry 任一异常 → logger.warn + 不 rethrow,不让
220
224
  * session_start 崩溃(与 replay gap4 / ER-APPEND-FAIL 同款降级语义)。append 中途失败时
221
225
  * .imported 保留(不 unlink)——下次 session 的 handleImportedResidue 会重导入全部任务,
222
226
  * 已成功 append 的子集可能跨 session 双触发;取舍:删除则失败任务永久丢失(更糟),
@@ -255,9 +259,7 @@ export function importLegacyStore(
255
259
  return importFromFile(importedPath, pi, currentSessionFile)
256
260
  } catch (err) {
257
261
  // C1 整体降级:read/parse/appendEntry 任一异常不崩 session_start
258
- console.warn(
259
- `[scheduler] import failed: ${err instanceof Error ? err.message : String(err)}`,
260
- )
262
+ logger.warn('import failed', { error: err instanceof Error ? err.message : String(err) })
261
263
  return undefined
262
264
  }
263
265
  }
package/src/replay.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import { getLogger } from '@zhushanwen/pi-extension-logger'
2
+
1
3
  import type { ScheduledTask, SchedulerEntryOp, TaskSnapshot } from './types.js'
2
4
 
5
+ const logger = getLogger('scheduler')
6
+
3
7
  /**
4
8
  * CustomEntry 的最小可识别形状(duck-typed,不依赖 @earendil-works/pi-coding-agent 的
5
9
  * 具体 SessionEntry 类型)。与 pending-notifications/state.ts、goal/ports.ts 同款:
@@ -33,7 +37,7 @@ const HISTORY_LIMIT = 20
33
37
  *
34
38
  * append-only 时序保证 nextRunAt 不回退(D1):advance entry 按写入顺序折叠,自然取到最后推进值。
35
39
  *
36
- * getEntries 解析/迭代异常 → console.warn + 返回空 Map(gap4):降级为无任务而非崩溃 session_start。
40
+ * getEntries 解析/迭代异常 → logger.warn + 返回空 Map(gap4):降级为无任务而非崩溃 session_start。
37
41
  */
38
42
  export function replayFoldEntries(
39
43
  entries: Iterable<SchedulerEntryLike>,
@@ -82,9 +86,7 @@ export function replayFoldEntries(
82
86
  // MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
83
87
  // snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
84
88
  // 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
85
- console.warn(
86
- `[scheduler] skipping corrupted scheduler entry: ${err instanceof Error ? err.message : String(err)}`,
87
- )
89
+ logger.warn('skipping corrupted scheduler entry', { error: err instanceof Error ? err.message : String(err) })
88
90
  continue
89
91
  }
90
92
  }
@@ -101,9 +103,7 @@ export function replayFoldEntries(
101
103
  return tasks
102
104
  } catch (err) {
103
105
  // gap4:session JSONL 损坏 / 迭代器抛错时降级为无任务,不让 session_start 崩溃。
104
- console.warn(
105
- `[scheduler] replayFoldEntries failed: ${err instanceof Error ? err.message : String(err)}`,
106
- )
106
+ logger.warn('replayFoldEntries failed', { error: err instanceof Error ? err.message : String(err) })
107
107
  return new Map()
108
108
  }
109
109
  }
package/src/runtime.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
2
+ import { getLogger } from '@zhushanwen/pi-extension-logger'
2
3
 
3
4
  import type { DeliveryHandle, DeliveryMessage } from '@xyz-agent/session-delivery'
4
5
 
@@ -13,6 +14,8 @@ import type {
13
14
  TaskSnapshot,
14
15
  } from './types.js'
15
16
 
17
+ const logger = getLogger('scheduler')
18
+
16
19
  const MAX_TASKS = 50
17
20
  // 入队防重标记 TTL(合批非首条任务无终态回调,过期后放行重投;10 min >> 合批窗口)
18
21
  const QUEUE_DEDUPE_TTL_MS = 10 * 60 * 1000
@@ -215,7 +218,7 @@ export class SchedulerRuntime {
215
218
  if (this.isCtxStale?.() || message.includes(STALE_CTX_MARKER)) {
216
219
  this.retireStaleTimer()
217
220
  } else {
218
- console.warn(`[scheduler] tick error: ${message}`)
221
+ logger.warn('tick error', { error: message })
219
222
  }
220
223
  })
221
224
  }, TICK_INTERVAL_MS)
@@ -234,7 +237,7 @@ export class SchedulerRuntime {
234
237
  * session_start 重建的新一代 runtime 接管。
235
238
  */
236
239
  private retireStaleTimer(): void {
237
- console.warn(`[scheduler] tick stopped: stale extension ctx (session replaced); timer self-retired`)
240
+ logger.warn('tick stopped: stale extension ctx (session replaced); timer self-retired')
238
241
  this.stopScheduler()
239
242
  }
240
243
 
@@ -303,7 +306,7 @@ export class SchedulerRuntime {
303
306
  async dispatchTask(task: ScheduledTask): Promise<boolean> {
304
307
  if (!task.enabled) return false
305
308
  if (this.dispatchesInFlight.has(task.id)) {
306
- console.warn(`[scheduler] dispatch already in flight for task ${task.id}; skipping this tick`)
309
+ logger.warn('dispatch already in flight, skipping this tick', { taskId: task.id })
307
310
  return false
308
311
  }
309
312
  this.dispatchesInFlight.add(task.id)
@@ -478,7 +481,7 @@ export class SchedulerRuntime {
478
481
  // ── append-only 持久化辅助 ──
479
482
 
480
483
  /**
481
- * 委托 backend.appendEntry。失败 → console.warn + 不 rethrow(ER-APPEND-FAIL)。
484
+ * 委托 backend.appendEntry。失败 → logger.warn + 不 rethrow(ER-APPEND-FAIL)。
482
485
  * 内存态已先行更新(at-least-once 已知恶化窗口:append 失败则该 op 丢失,resume 重放回退)。
483
486
  * 不再设 task.lastError='persist failed'(append 失败是 transient,不应污染业务态)。
484
487
  */
@@ -488,9 +491,7 @@ export class SchedulerRuntime {
488
491
  } catch (err) {
489
492
  // best-effort 降级(ER-APPEND-FAIL):append-only 模型下 append 失败仅丢失该 op 的持久化,
490
493
  // 内存态已先行更新、不 rethrow,业务流程继续。at-least-once 已知恶化窗口(resume 重放回退)。
491
- console.warn(
492
- `[scheduler] appendEntry failed: ${err instanceof Error ? err.message : String(err)}`,
493
- )
494
+ logger.warn('appendEntry failed', { error: err instanceof Error ? err.message : String(err) })
494
495
  }
495
496
  }
496
497