@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 +3 -2
- package/src/__tests__/U4-DISPATCH_INFLIGHT.test.ts +5 -3
- package/src/__tests__/importer.test.ts +23 -9
- package/src/__tests__/index-generation.test.ts +14 -5
- package/src/__tests__/replay.test.ts +19 -12
- package/src/__tests__/runtime.test.ts +26 -24
- package/src/backend.ts +1 -1
- package/src/importer.ts +12 -10
- package/src/replay.ts +7 -7
- package/src/runtime.ts +8 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
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 +
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
56
|
-
|
|
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)
|
|
105
|
-
expect(
|
|
106
|
-
expect.stringContaining(
|
|
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(
|
|
181
|
-
expect.stringContaining('
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
173
|
+
const warnCountAfterSelfStop = loggerMock.warn.mock.calls.length
|
|
164
174
|
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 warn
|
|
165
|
-
expect(
|
|
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: 迭代器抛错时
|
|
145
|
-
|
|
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(
|
|
162
|
-
const msg =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
301
|
-
const msg =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 捕获后
|
|
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
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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 任一异常 →
|
|
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
|
-
|
|
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 解析/迭代异常 →
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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。失败 →
|
|
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
|
-
|
|
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
|
|