@zhushanwen/pi-scheduler 0.3.0 → 0.3.1
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 +1 -1
- package/src/__tests__/index-generation.test.ts +171 -0
- package/src/__tests__/index-session-start.test.ts +107 -0
- package/src/__tests__/runtime.test.ts +266 -0
- package/src/__tests__/service.test.ts +1 -1
- package/src/index.ts +28 -1
- package/src/runtime.ts +80 -5
- package/src/service.ts +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// src/__tests__/index-generation.test.ts
|
|
2
|
+
//
|
|
3
|
+
// G1(S9 review 修复 / R3-M1 模块级化)集成单测:index.ts 装配点为每代 SchedulerRuntime
|
|
4
|
+
// 注入代际检测回调(模块级 sessionGeneration 比对),使 stale 分诊不依赖 pi 错误文案。
|
|
5
|
+
//
|
|
6
|
+
// 两类拓扑:
|
|
7
|
+
// - 同闭包重复 fire session_start(rpc-mode bindExtensions 重调等次要路径)——第一组用例
|
|
8
|
+
// 在同一 factory 闭包上连续触发验证代数接线。
|
|
9
|
+
// - factory 重跑(生产主路径:pi 每次 session 替换 newSession/fork/switchSession 都重跑
|
|
10
|
+
// factory 函数体,loader.ts extensionCache 只缓存 factory 函数对象不缓存执行结果)——
|
|
11
|
+
// 装配级用例模拟两次独立 factory() 调用(各自新闭包、共享模块级代数),实证模块级变量
|
|
12
|
+
// 跨 factory 重跑保留:第二代 session_start 递增模块级计数器后,第一代 runtime 的
|
|
13
|
+
// isCtxStale 翻转为 true 且其泄漏 timer 在下个 tick 前置检查自停。闭包级实现(R3-M1
|
|
14
|
+
// 修复前)在此拓扑下恒 false——正是被 R3 实测证伪的生产失效路径。
|
|
15
|
+
//
|
|
16
|
+
// 本文件用 InstrumentedRuntime(继承真实 SchedulerRuntime,仅记录构造第三参 isCtxStale)
|
|
17
|
+
// 捕获注入回调。InstrumentedRuntime 全部行为继承父类,不影响装配链本身(F1 停旧 timer
|
|
18
|
+
// 等行为由 index-session-start.test.ts U4 锚定,此处不重复)。
|
|
19
|
+
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
22
|
+
|
|
23
|
+
// 与 index-session-start.test.ts 同款(MF-3):mock 掉 importer,装配路径仍被调用、FS 副作用为零。
|
|
24
|
+
vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
|
|
25
|
+
|
|
26
|
+
// vi.mock factory 会被提升,跨模块共享状态必须经 vi.hoisted。
|
|
27
|
+
const { isCtxStaleCaptures, runtimeInstances } = vi.hoisted(() => ({
|
|
28
|
+
isCtxStaleCaptures: [] as Array<(() => boolean) | undefined>,
|
|
29
|
+
runtimeInstances: [] as Array<{ stopScheduler(): void }>,
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
// InstrumentedRuntime 继承真实实现(loadTasks/onAfterTick/startScheduler 均真实执行),
|
|
33
|
+
// 仅捕获构造第三参并登记实例(afterEach 统一停 timer,避免真实 setInterval 残留)。
|
|
34
|
+
vi.mock('../runtime.js', async (importOriginal) => {
|
|
35
|
+
const actual = await importOriginal<typeof import('../runtime.js')>()
|
|
36
|
+
const RealSchedulerRuntime = actual.SchedulerRuntime
|
|
37
|
+
class InstrumentedRuntime extends RealSchedulerRuntime {
|
|
38
|
+
constructor(
|
|
39
|
+
backend: ConstructorParameters<typeof RealSchedulerRuntime>[0],
|
|
40
|
+
ctx: ConstructorParameters<typeof RealSchedulerRuntime>[1],
|
|
41
|
+
isCtxStale?: () => boolean,
|
|
42
|
+
) {
|
|
43
|
+
super(backend, ctx, isCtxStale)
|
|
44
|
+
isCtxStaleCaptures.push(isCtxStale)
|
|
45
|
+
runtimeInstances.push(this)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { ...actual, SchedulerRuntime: InstrumentedRuntime }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
import schedulerExtension from '../index.js'
|
|
52
|
+
|
|
53
|
+
const TICK_INTERVAL_MS = 30_000
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 最小 fake pi:与 index-session-start.test.ts 同款,覆盖 factory 消费的 API 面
|
|
57
|
+
* (on 捕获事件 handler 供手动触发;registerTool/registerCommand/sendMessage/appendEntry 兜底)。
|
|
58
|
+
*/
|
|
59
|
+
function createMockPi(): {
|
|
60
|
+
pi: ExtensionAPI
|
|
61
|
+
events: Map<string, (...args: unknown[]) => void>
|
|
62
|
+
} {
|
|
63
|
+
const events = new Map<string, (...args: unknown[]) => void>()
|
|
64
|
+
const pi = {
|
|
65
|
+
registerTool: vi.fn(),
|
|
66
|
+
registerCommand: vi.fn(),
|
|
67
|
+
on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
|
|
68
|
+
sendMessage: vi.fn(),
|
|
69
|
+
appendEntry: vi.fn(),
|
|
70
|
+
} as unknown as ExtensionAPI
|
|
71
|
+
return { pi, events }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 最小 fake ctx:覆盖 session_start 装配链读到的全部字段(backend 构造 / runtime 构造 / refreshWidget)。 */
|
|
75
|
+
function createFakeCtx(sessionFile: string): ExtensionContext {
|
|
76
|
+
return {
|
|
77
|
+
cwd: '/test-index-generation',
|
|
78
|
+
isIdle: () => true,
|
|
79
|
+
hasPendingMessages: () => false,
|
|
80
|
+
ui: { setWidget: vi.fn() },
|
|
81
|
+
sessionManager: {
|
|
82
|
+
getEntries: () => [],
|
|
83
|
+
getSessionFile: () => sessionFile,
|
|
84
|
+
},
|
|
85
|
+
} as unknown as ExtensionContext
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('G1: index.ts 代际接线(S9)', () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
isCtxStaleCaptures.length = 0
|
|
91
|
+
runtimeInstances.length = 0
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
for (const rt of runtimeInstances) {
|
|
96
|
+
rt.stopScheduler()
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('每次 session_start 注入 isCtxStale:当前代 false、任意前代 true(跨三代)', () => {
|
|
101
|
+
const { pi, events } = createMockPi()
|
|
102
|
+
schedulerExtension(pi)
|
|
103
|
+
const sessionStart = events.get('session_start')
|
|
104
|
+
expect(sessionStart).toBeDefined()
|
|
105
|
+
|
|
106
|
+
// 第 1 代
|
|
107
|
+
sessionStart!({ type: 'session_start', reason: 'startup' }, createFakeCtx('/test/gen-1.json'))
|
|
108
|
+
expect(isCtxStaleCaptures[0]).toBeTypeOf('function')
|
|
109
|
+
expect(isCtxStaleCaptures[0]!()).toBe(false) // 当前代未替换
|
|
110
|
+
|
|
111
|
+
// 第 2 代(session 替换):第 1 代自此 stale,第 2 代为当前代
|
|
112
|
+
sessionStart!({ type: 'session_start', reason: 'new_session' }, createFakeCtx('/test/gen-2.json'))
|
|
113
|
+
expect(isCtxStaleCaptures[0]!()).toBe(true)
|
|
114
|
+
expect(isCtxStaleCaptures[1]!()).toBe(false)
|
|
115
|
+
|
|
116
|
+
// 第 3 代(resume 重入):前两代均 stale
|
|
117
|
+
sessionStart!({ type: 'session_start', reason: 'resume' }, createFakeCtx('/test/gen-3.json'))
|
|
118
|
+
expect(isCtxStaleCaptures[0]!()).toBe(true)
|
|
119
|
+
expect(isCtxStaleCaptures[1]!()).toBe(true)
|
|
120
|
+
expect(isCtxStaleCaptures[2]!()).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// ── R3-M1/R3-S5:factory 重跑装配级用例(生产主路径拓扑)──
|
|
124
|
+
// pi 每次 session 替换都重跑 factory 函数体(新闭包)。本用例模拟两次独立 factory()
|
|
125
|
+
// 调用 + 各 fire 一次 session_start,实证「模块级 sessionGeneration 跨 factory 重跑保留」:
|
|
126
|
+
// 闭包级实现(修复前)在第二代 session_start 后 firstStale() 仍恒 false——测试即失败。
|
|
127
|
+
it('factory 重跑:第二次 factory 执行 + session_start 后,第一代 runtime isCtxStale 为 true 且 tick 前置自停', async () => {
|
|
128
|
+
vi.useFakeTimers()
|
|
129
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
130
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
131
|
+
try {
|
|
132
|
+
// 第一代:独立 factory 执行 + session_start 装配(runtime1 真实 startScheduler)
|
|
133
|
+
const first = createMockPi()
|
|
134
|
+
schedulerExtension(first.pi)
|
|
135
|
+
const firstSessionStart = first.events.get('session_start')
|
|
136
|
+
expect(firstSessionStart).toBeDefined()
|
|
137
|
+
firstSessionStart!({ type: 'session_start', reason: 'startup' }, createFakeCtx('/test/factory-rerun-1.json'))
|
|
138
|
+
const firstStale = isCtxStaleCaptures[0]!
|
|
139
|
+
expect(firstStale).toBeTypeOf('function')
|
|
140
|
+
expect(firstStale()).toBe(false) // 第一代是当前代
|
|
141
|
+
|
|
142
|
+
// 第二代:再次独立执行 factory(模拟 newSession/fork/switchSession 的 factory 重跑,
|
|
143
|
+
// 新闭包的 service 为 null——第一代闭包的 service 对它不可见,F1 停不到第一代 timer,
|
|
144
|
+
// 复现「F1 未能触达、只剩 G1 前置检查」的泄漏路径)+ session_start
|
|
145
|
+
const second = createMockPi()
|
|
146
|
+
schedulerExtension(second.pi)
|
|
147
|
+
const secondSessionStart = second.events.get('session_start')
|
|
148
|
+
expect(secondSessionStart).toBeDefined()
|
|
149
|
+
secondSessionStart!({ type: 'session_start', reason: 'new_session' }, createFakeCtx('/test/factory-rerun-2.json'))
|
|
150
|
+
|
|
151
|
+
// 核心断言:模块级计数器被第二代闭包的 session_start 递增,第一代 runtime 的
|
|
152
|
+
// isCtxStale 生效(闭包级实现在此恒 false——R3-M1 修复的生产失效路径)
|
|
153
|
+
expect(firstStale()).toBe(true)
|
|
154
|
+
expect(isCtxStaleCaptures[1]!()).toBe(false) // 第二代是当前代
|
|
155
|
+
|
|
156
|
+
// tick 前置自停:第一代 runtime 的泄漏 timer 在下个 tick 被代际前置检查拦截
|
|
157
|
+
// (G1-b 的生产路径验证:warn "tick stopped" + timer 自停,后续 tick 不再发生)
|
|
158
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
|
|
159
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
160
|
+
expect(warnText).toContain('tick stopped')
|
|
161
|
+
expect(warnText).not.toContain('tick error')
|
|
162
|
+
|
|
163
|
+
const warnCountAfterSelfStop = warnSpy.mock.calls.length
|
|
164
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 warn
|
|
165
|
+
expect(warnSpy.mock.calls.length).toBe(warnCountAfterSelfStop)
|
|
166
|
+
} finally {
|
|
167
|
+
warnSpy.mockRestore()
|
|
168
|
+
vi.useRealTimers()
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/__tests__/index-session-start.test.ts
|
|
2
|
+
//
|
|
3
|
+
// F1 集成单测(crash-fix U4):session_start 多发/重入时先停上一代 runtime 的 tick interval。
|
|
4
|
+
// 排查结论:dispatch 的 await sendMessage 窗口与 session 替换交错时,旧 session_shutdown 可能
|
|
5
|
+
// 永远等不到 → 旧 30s tick timer 泄漏 → 下一 tick 的 refreshWidget 访问 stale ctx.ui 抛错 →
|
|
6
|
+
// unhandledRejection → pi 主进程 exit 1。F1 在 session_start 开头幂等 stopScheduler,从源头消灭。
|
|
7
|
+
//
|
|
8
|
+
// 行为断言口径(验收 U4):第二次 session_start 后 advance 30s,tick 引起的 widget 刷新
|
|
9
|
+
// 增量恰 +1(只有新 runtime 的 timer 在跑);F1 缺失时两个 timer 都活着,增量为 +2。
|
|
10
|
+
// 观测面说明:session_start 硬编码 new PiSchedulerBackend(now() = Date.now(),无法注入计数),
|
|
11
|
+
// 故用 onAfterTick → refreshWidget → ctx.ui.setWidget 的调用计数作为 tick 发生次数的
|
|
12
|
+
// 行为观测面(每个 tick 恰好一次,与 backend.now 计数等价)。
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
16
|
+
|
|
17
|
+
// 与 sdk-contract.test.ts 同款(MF-3):session_start 会真实执行 importLegacyStore(ctx.cwd, ...),
|
|
18
|
+
// 触碰用户真实 FS(~/.pi/agent/scheduler/... 的 renameSync/existsSync 探测)。mock 掉 importer
|
|
19
|
+
// 模块,装配路径仍被调用、FS 副作用为零。
|
|
20
|
+
vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
|
|
21
|
+
|
|
22
|
+
import schedulerExtension from '../index.js'
|
|
23
|
+
|
|
24
|
+
const TICK_INTERVAL_MS = 30_000
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 最小 fake pi:覆盖 index.ts factory + commands.ts 注册路径消费的 API 面
|
|
28
|
+
* (on / registerTool / registerCommand / sendMessage / appendEntry)。
|
|
29
|
+
* on 捕获事件 handler 供手动触发;sendMessage/appendEntry 为 vi.fn 兜底(本套件无任务 dispatch)。
|
|
30
|
+
*/
|
|
31
|
+
function createMockPi(): {
|
|
32
|
+
pi: ExtensionAPI
|
|
33
|
+
events: Map<string, (...args: unknown[]) => void>
|
|
34
|
+
} {
|
|
35
|
+
const events = new Map<string, (...args: unknown[]) => void>()
|
|
36
|
+
const pi = {
|
|
37
|
+
registerTool: vi.fn(),
|
|
38
|
+
registerCommand: vi.fn(),
|
|
39
|
+
on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
|
|
40
|
+
sendMessage: vi.fn(),
|
|
41
|
+
appendEntry: vi.fn(),
|
|
42
|
+
} as unknown as ExtensionAPI
|
|
43
|
+
return { pi, events }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 最小 fake ctx:覆盖 session_start 装配链读到的全部字段——PiSchedulerBackend 构造
|
|
48
|
+
* (sessionManager)、SchedulerRuntime 构造(isIdle/hasPendingMessages)、importLegacyStore
|
|
49
|
+
* (cwd,已 mock)、refreshWidget(ui.setWidget)。
|
|
50
|
+
* setWidget 以独立引用导出:session_start 初始渲染 + 每次 tick 末 onAfterTick 各调一次,
|
|
51
|
+
* 是「哪个 runtime 的 timer 还在 tick」的行为观测面。
|
|
52
|
+
*/
|
|
53
|
+
function createFakeCtx(sessionFile: string): {
|
|
54
|
+
ctx: ExtensionContext
|
|
55
|
+
setWidget: ReturnType<typeof vi.fn>
|
|
56
|
+
} {
|
|
57
|
+
const setWidget = vi.fn()
|
|
58
|
+
const ctx = {
|
|
59
|
+
cwd: '/test-index-session-start',
|
|
60
|
+
isIdle: () => true,
|
|
61
|
+
hasPendingMessages: () => false,
|
|
62
|
+
ui: { setWidget },
|
|
63
|
+
sessionManager: {
|
|
64
|
+
getEntries: () => [],
|
|
65
|
+
getSessionFile: () => sessionFile,
|
|
66
|
+
},
|
|
67
|
+
} as unknown as ExtensionContext
|
|
68
|
+
return { ctx, setWidget }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.useFakeTimers()
|
|
74
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.useRealTimers()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('U4: 双 session_start 后旧 timer 已停——advance 30s tick 引起的刷新恰 +1 而非 +2', async () => {
|
|
82
|
+
const { pi, events } = createMockPi()
|
|
83
|
+
schedulerExtension(pi)
|
|
84
|
+
const sessionStart = events.get('session_start')
|
|
85
|
+
expect(sessionStart).toBeDefined()
|
|
86
|
+
|
|
87
|
+
// 第一次 session_start:runtime1 + timer1 启动,初始渲染 1 次
|
|
88
|
+
const first = createFakeCtx('/test/session-1.json')
|
|
89
|
+
sessionStart!({ type: 'session_start', reason: 'startup' }, first.ctx)
|
|
90
|
+
expect(first.setWidget).toHaveBeenCalledTimes(1)
|
|
91
|
+
|
|
92
|
+
// 前置因果锚点:advance 30s,timer1 正常 tick 一次(排除「timer 从未启动」的假绿)
|
|
93
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
|
|
94
|
+
expect(first.setWidget).toHaveBeenCalledTimes(2)
|
|
95
|
+
|
|
96
|
+
// 第二次 session_start(session 替换):F1 在装配新 runtime 前停掉 timer1
|
|
97
|
+
const second = createFakeCtx('/test/session-2.json')
|
|
98
|
+
sessionStart!({ type: 'session_start', reason: 'new_session' }, second.ctx)
|
|
99
|
+
expect(second.setWidget).toHaveBeenCalledTimes(1) // runtime2 初始渲染
|
|
100
|
+
|
|
101
|
+
// 行为断言(验收口径):再 advance 30s,只有 runtime2 的 timer 触发一次 tick——
|
|
102
|
+
// F1 缺失时 timer1/timer2 都活着,first 与 second 的 spy 各 +1(合计 +2)
|
|
103
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
|
|
104
|
+
expect(second.setWidget).toHaveBeenCalledTimes(2) // 恰 +1:新 runtime 正常调度
|
|
105
|
+
expect(first.setWidget).toHaveBeenCalledTimes(2) // 旧 runtime 的 tick 不再发生(timer 已停)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -162,6 +162,77 @@ describe('SchedulerRuntime', () => {
|
|
|
162
162
|
})
|
|
163
163
|
})
|
|
164
164
|
|
|
165
|
+
// ── R3-S1:dispatchTask in-flight 守卫 ──
|
|
166
|
+
// tick 为 fire-and-forget:tick1 的 await sendMessage 挂起超过 TICK_INTERVAL_MS(如 pi
|
|
167
|
+
// 卡死)时,tick2 的 step2 再标 pending → step3 对同一 task 并发第二个 dispatch → 同一
|
|
168
|
+
// prompt 双注入(force 任务绕过 isIdle gate 直接受影响)。守卫:同任务在途(Set<taskId>)
|
|
169
|
+
// 时 skip 本轮并 warn;不同任务不受影响。
|
|
170
|
+
describe('dispatchTask in-flight 守卫(R3-S1)', () => {
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
vi.useFakeTimers()
|
|
173
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
afterEach(() => {
|
|
177
|
+
vi.useRealTimers()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('sendMessage 挂起期间下一 tick 同任务被跳过:不双注入、warn in-flight、完成后 runCount=1', async () => {
|
|
181
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
182
|
+
let resolveSend: (() => void) | undefined
|
|
183
|
+
const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
|
|
184
|
+
backend.sendMessage = vi.fn(() => sendPromise)
|
|
185
|
+
|
|
186
|
+
const task = await runtime.addTask('in-flight', { mode: 'interval', intervalMs: 60000 }, { force: true })
|
|
187
|
+
task.nextRunAt = Date.now() - 1000
|
|
188
|
+
|
|
189
|
+
// tick1:dispatch 进入 sendMessage 挂起(同步段已置 in-flight 标记)
|
|
190
|
+
const tick1 = runtime.tickScheduler()
|
|
191
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
192
|
+
expect(task.pending).toBe(true) // dispatch 未完成,pending 未清
|
|
193
|
+
|
|
194
|
+
// tick2(模拟 30s 后 pi 仍卡死):同任务再标 pending → step3 dispatchTask 命中
|
|
195
|
+
// in-flight 守卫 → skip + warn(修复前:同一 prompt 双注入)
|
|
196
|
+
await runtime.tickScheduler()
|
|
197
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
198
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
199
|
+
expect(warnText).toContain('already in flight')
|
|
200
|
+
|
|
201
|
+
// 放行挂起的 sendMessage:tick1 正常收尾(状态推进恰好一次)
|
|
202
|
+
resolveSend!()
|
|
203
|
+
await tick1
|
|
204
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
205
|
+
expect(task.runCount).toBe(1)
|
|
206
|
+
expect(task.pending).toBe(false)
|
|
207
|
+
warnSpy.mockRestore()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('挂起 dispatch 只挡同任务:其他任务在下一 tick 正常 dispatch 不受影响', async () => {
|
|
211
|
+
let resolveSend: (() => void) | undefined
|
|
212
|
+
const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
|
|
213
|
+
backend.sendMessage = vi.fn(() => sendPromise)
|
|
214
|
+
|
|
215
|
+
const task1 = await runtime.addTask('first', { mode: 'interval', intervalMs: 60000 }, { force: true })
|
|
216
|
+
const task2 = await runtime.addTask('second', { mode: 'interval', intervalMs: 60000 }, { force: true })
|
|
217
|
+
task1.nextRunAt = Date.now() - 1000
|
|
218
|
+
task2.nextRunAt = Date.now() - 1000
|
|
219
|
+
|
|
220
|
+
// tick1:task1 dispatch 挂起(step3 顺序 await,task2 尚未轮到)
|
|
221
|
+
const tick1 = runtime.tickScheduler()
|
|
222
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
223
|
+
|
|
224
|
+
// tick2:task1 命中守卫跳过,task2 无在途 → 正常 dispatch(守卫按 taskId 粒度隔离)。
|
|
225
|
+
// 注:tick2 挂起在 task1 的 await dispatchTask(守卫命中即 resolved promise)与 task2
|
|
226
|
+
// 的 await sendMessage 上,先放行再 await,完成态统一断言
|
|
227
|
+
const tick2 = runtime.tickScheduler()
|
|
228
|
+
resolveSend!()
|
|
229
|
+
await Promise.all([tick1, tick2])
|
|
230
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(2)
|
|
231
|
+
expect(task1.runCount).toBe(1)
|
|
232
|
+
expect(task2.runCount).toBe(1)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
165
236
|
// ── M10b:rate-limit ──
|
|
166
237
|
// dispatchTask 受 RATE_LIMIT_PER_MINUTE=6 限制。前 6 次成功(sendMessage 被调),
|
|
167
238
|
// 第 7 次被 hasDispatchCapacity 拒绝(dispatchTimestamps.length 已达 6)。
|
|
@@ -463,4 +534,199 @@ describe('SchedulerRuntime', () => {
|
|
|
463
534
|
expect(task.expiresAt).toBeUndefined()
|
|
464
535
|
})
|
|
465
536
|
})
|
|
537
|
+
|
|
538
|
+
// ── F2:tick 错误分诊(crash-fix)──
|
|
539
|
+
// startScheduler 的 interval 回调对 fire-and-forget 的 tickScheduler() 加 catch:
|
|
540
|
+
// stale 类错误(session 替换后泄漏 timer 访问 stale ctx)→ warn "tick stopped" + stopScheduler
|
|
541
|
+
// 自停;其他错误 → warn "tick error" 继续调度。修复前 tick 内异常无人接住 →
|
|
542
|
+
// unhandledRejection → pi 主进程 exit 1。
|
|
543
|
+
describe('tick 错误分诊(F2)', () => {
|
|
544
|
+
const TICK_INTERVAL_MS = 30_000
|
|
545
|
+
|
|
546
|
+
beforeEach(() => {
|
|
547
|
+
vi.useFakeTimers()
|
|
548
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
afterEach(() => {
|
|
552
|
+
runtime.stopScheduler()
|
|
553
|
+
vi.useRealTimers()
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('U1: stale 错误 → warn "tick stopped" + timer 自停,后续 tick 不再发生', async () => {
|
|
557
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
558
|
+
const nowSpy = vi.spyOn(backend, 'now')
|
|
559
|
+
runtime.onAfterTick(() => {
|
|
560
|
+
throw new Error('This extension ctx is stale after session replacement or reload.')
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
runtime.startScheduler()
|
|
564
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:stale 抛 → catch 分诊 → 自停
|
|
565
|
+
|
|
566
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
567
|
+
expect(warnText).toContain('tick stopped')
|
|
568
|
+
expect(warnText).not.toContain('tick error')
|
|
569
|
+
|
|
570
|
+
const countAfterSelfStop = nowSpy.mock.calls.length
|
|
571
|
+
expect(countAfterSelfStop).toBeGreaterThan(0) // tick1 确实跑过(排除「timer 未启动」假绿)
|
|
572
|
+
|
|
573
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 60s:timer 已停,无新 tick
|
|
574
|
+
expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop) // now 计数不再增长
|
|
575
|
+
warnSpy.mockRestore()
|
|
576
|
+
nowSpy.mockRestore()
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('U2: 非 stale 错误 → warn "tick error" 且调度继续(advance 两次 now 计数 +2)', async () => {
|
|
580
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
581
|
+
const nowSpy = vi.spyOn(backend, 'now')
|
|
582
|
+
runtime.onAfterTick(() => {
|
|
583
|
+
throw new Error('boom')
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
runtime.startScheduler()
|
|
587
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:warn 但不停
|
|
588
|
+
|
|
589
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
590
|
+
expect(warnText).toContain('tick error')
|
|
591
|
+
expect(warnText).not.toContain('tick stopped')
|
|
592
|
+
|
|
593
|
+
const countAfterFirstTick = nowSpy.mock.calls.length
|
|
594
|
+
expect(countAfterFirstTick).toBeGreaterThan(0)
|
|
595
|
+
|
|
596
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 2 个后续 tick 照常
|
|
597
|
+
expect(nowSpy.mock.calls.length).toBe(countAfterFirstTick + 2)
|
|
598
|
+
warnSpy.mockRestore()
|
|
599
|
+
nowSpy.mockRestore()
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('U5: stopScheduler 幂等——连续调用两次不抛、无副作用', () => {
|
|
603
|
+
runtime.startScheduler()
|
|
604
|
+
expect(() => {
|
|
605
|
+
runtime.stopScheduler()
|
|
606
|
+
runtime.stopScheduler()
|
|
607
|
+
}).not.toThrow()
|
|
608
|
+
})
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
// ── G1:代际检测分诊(S9 review 修复 / R3-M1 模块级化)──
|
|
612
|
+
// isCtxStale(index.ts 注入的模块级代数比对)为主判,替代对 pi 错误文案的依赖:
|
|
613
|
+
// - G1-a:in-flight tick 内代际翻转 + 任意非文案错误 → catch 分诊走 stale 自停(不依赖文案)
|
|
614
|
+
// - G1-b:代际翻转后(无任何错误)泄漏 timer 在下个 tick 前置检查自停,不进入 tick
|
|
615
|
+
// - G1-c:显式注入 isCtxStale=false + 非 stale 错误 → 仍 "tick error" 继续调度(不误伤)
|
|
616
|
+
// - G1-d:isCtxStale=false 但错误文案含 stale 片段 → 文案兜底仍自停(覆盖 reload 盲区:
|
|
617
|
+
// clearExtensionCache 后 jiti 重 import 全新模块环境,旧闭包的模块级代数冻结不再递增,
|
|
618
|
+
// 只剩文案能识别 stale;模块级方案的装配级验证见 index-generation.test.ts factory 重跑用例)
|
|
619
|
+
describe('G1: 代际检测分诊(S9)', () => {
|
|
620
|
+
const TICK_INTERVAL_MS = 30_000
|
|
621
|
+
let staleFlag: boolean
|
|
622
|
+
let genRuntime: SchedulerRuntime
|
|
623
|
+
|
|
624
|
+
beforeEach(() => {
|
|
625
|
+
vi.useFakeTimers()
|
|
626
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
627
|
+
staleFlag = false
|
|
628
|
+
genRuntime = new SchedulerRuntime(backend, mockCtx, () => staleFlag)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
afterEach(() => {
|
|
632
|
+
genRuntime.stopScheduler()
|
|
633
|
+
vi.useRealTimers()
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it('G1-a: in-flight tick 期间代际翻转 + 非文案错误 → warn "tick stopped" + 自停(不依赖 pi 错误文案)', async () => {
|
|
637
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
638
|
+
const nowSpy = vi.spyOn(backend, 'now')
|
|
639
|
+
// tick 内先翻世代(模拟 session 替换交错发生在 dispatch await 窗口),再抛与
|
|
640
|
+
// pi 文案完全无关的错误——旧实现按文案分诊会误判为普通错误继续调度(若 pi 改文案)
|
|
641
|
+
genRuntime.onAfterTick(() => {
|
|
642
|
+
staleFlag = true
|
|
643
|
+
throw new Error('some unexpected failure')
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
genRuntime.startScheduler()
|
|
647
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:catch 分诊走 G1 代际 → 自停
|
|
648
|
+
|
|
649
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
650
|
+
expect(warnText).toContain('tick stopped')
|
|
651
|
+
expect(warnText).not.toContain('tick error')
|
|
652
|
+
|
|
653
|
+
const countAfterSelfStop = nowSpy.mock.calls.length
|
|
654
|
+
expect(countAfterSelfStop).toBeGreaterThan(0) // tick1 确实跑过(排除「timer 未启动」假绿)
|
|
655
|
+
|
|
656
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 tick
|
|
657
|
+
expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop)
|
|
658
|
+
warnSpy.mockRestore()
|
|
659
|
+
nowSpy.mockRestore()
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('G1-b: 代际翻转后泄漏 timer 在下个 tick 前置检查自停——不进入 tick(backend.now 零调用)', async () => {
|
|
663
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
664
|
+
const nowSpy = vi.spyOn(backend, 'now')
|
|
665
|
+
genRuntime.startScheduler()
|
|
666
|
+
// session 替换:代际翻转(F1 未能触达的泄漏 timer 场景;无任何错误发生)
|
|
667
|
+
staleFlag = true
|
|
668
|
+
|
|
669
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:前置检查命中 → 自停
|
|
670
|
+
|
|
671
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
672
|
+
expect(warnText).toContain('tick stopped')
|
|
673
|
+
expect(warnText).not.toContain('tick error')
|
|
674
|
+
// 前置检查在 tickScheduler 之前拦截:tick 本体未执行(now 零调用,无 dispatch/append)
|
|
675
|
+
expect(nowSpy).not.toHaveBeenCalled()
|
|
676
|
+
|
|
677
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,仍零调用
|
|
678
|
+
expect(nowSpy).not.toHaveBeenCalled()
|
|
679
|
+
warnSpy.mockRestore()
|
|
680
|
+
nowSpy.mockRestore()
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('G1-c: isCtxStale 注入但返回 false + 非 stale 错误 → warn "tick error" 且调度继续', async () => {
|
|
684
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
685
|
+
const nowSpy = vi.spyOn(backend, 'now')
|
|
686
|
+
// staleFlag 恒 false(beforeEach 初始化):代际未翻转,注入存在不改变分诊结果
|
|
687
|
+
genRuntime.onAfterTick(() => {
|
|
688
|
+
throw new Error('boom')
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
genRuntime.startScheduler()
|
|
692
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:非 stale → warn 继续调度
|
|
693
|
+
|
|
694
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
695
|
+
expect(warnText).toContain('tick error')
|
|
696
|
+
expect(warnText).not.toContain('tick stopped')
|
|
697
|
+
|
|
698
|
+
const countAfterFirstTick = nowSpy.mock.calls.length
|
|
699
|
+
expect(countAfterFirstTick).toBeGreaterThan(0)
|
|
700
|
+
|
|
701
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 2 个后续 tick 照常
|
|
702
|
+
expect(nowSpy.mock.calls.length).toBe(countAfterFirstTick + 2)
|
|
703
|
+
warnSpy.mockRestore()
|
|
704
|
+
nowSpy.mockRestore()
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it('G1-d: isCtxStale 返回 false 但错误文案含 stale 片段 → 文案兜底仍自停(覆盖 reload 盲区)', async () => {
|
|
708
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
709
|
+
const nowSpy = vi.spyOn(backend, 'now')
|
|
710
|
+
// reload 场景模拟:factory 重跑后旧闭包代际计数不再递增(staleFlag 恒 false),
|
|
711
|
+
// 只有错误文案能识别 stale——兜底支必须独立于代际检测生效
|
|
712
|
+
genRuntime.onAfterTick(() => {
|
|
713
|
+
throw new Error('This extension ctx is stale after session replacement or reload.')
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
genRuntime.startScheduler()
|
|
717
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:文案兜底 → 自停
|
|
718
|
+
|
|
719
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
720
|
+
expect(warnText).toContain('tick stopped')
|
|
721
|
+
expect(warnText).not.toContain('tick error')
|
|
722
|
+
|
|
723
|
+
const countAfterSelfStop = nowSpy.mock.calls.length
|
|
724
|
+
expect(countAfterSelfStop).toBeGreaterThan(0)
|
|
725
|
+
|
|
726
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2)
|
|
727
|
+
expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop)
|
|
728
|
+
warnSpy.mockRestore()
|
|
729
|
+
nowSpy.mockRestore()
|
|
730
|
+
})
|
|
731
|
+
})
|
|
466
732
|
})
|
|
@@ -188,7 +188,7 @@ describe('SchedulerService', () => {
|
|
|
188
188
|
expect(result).toEqual({
|
|
189
189
|
success: false,
|
|
190
190
|
errorCode: 'DISPATCH_SKIPPED',
|
|
191
|
-
message: `Task ${id} not dispatched (busy, disabled,
|
|
191
|
+
message: `Task ${id} not dispatched (busy, disabled, rate-limited, or a dispatch already in flight).`,
|
|
192
192
|
})
|
|
193
193
|
})
|
|
194
194
|
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,22 @@ import {
|
|
|
17
17
|
} from './tool.js'
|
|
18
18
|
import { renderSchedulerWidget } from './widget.js'
|
|
19
19
|
|
|
20
|
+
// G1(代际检测,S9/R3-M1):session 代际计数器。必须声明在模块级而非 factory 体内:
|
|
21
|
+
// pi 每次 session 替换(newSession/fork/switchSession)都重跑 extension factory 函数体
|
|
22
|
+
// (loader.ts loadExtension 无条件 `await factory(api)`;extensionCache 只缓存 factory
|
|
23
|
+
// 函数对象、不缓存执行结果)——闭包级声明每次重跑即重置,各代 runtime 的 isCtxStale 恒
|
|
24
|
+
// false(R3 实测证伪的回归)。模块级声明下,extensionCache 命中期间(同 cwd 未 reload)
|
|
25
|
+
// factory 是同一函数对象、共享同一模块环境绑定,计数器跨 factory 重跑保留递增:新闭包的
|
|
26
|
+
// session_start 递增本计数器,各代 runtime 构造时捕获的代数从此小于模块值 → isCtxStale
|
|
27
|
+
// 生效——stale 分诊不依赖 pi 错误文案(Error message 非契约 API,pi 升级改文案即静默失效)。
|
|
28
|
+
//
|
|
29
|
+
// 残余盲区(reload):显式 reload / cwd 变化触发 clearExtensionCache → jiti 重新 import
|
|
30
|
+
// (moduleCache:false)产生全新模块环境,本计数器随新环境重置;旧闭包引用的是旧模块环境的
|
|
31
|
+
// 绑定,永不再递增 → 其 isCtxStale 恒 false。该盲区由两道既有防线覆盖:pi 在替换前 await
|
|
32
|
+
// fire session_shutdown(F1 stopScheduler 主防线,teardownCurrent)+ runtime 侧
|
|
33
|
+
// STALE_CTX_MARKER 文案兜底(F2 catch 分诊)。
|
|
34
|
+
let sessionGeneration = 0
|
|
35
|
+
|
|
20
36
|
/**
|
|
21
37
|
* pi-scheduler extension factory。
|
|
22
38
|
* 注册 schedule + schedule_control 两个 tool、/schedule command、session 事件。
|
|
@@ -41,13 +57,24 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
pi.on('session_start', (_event, ctx: ExtensionContext) => {
|
|
60
|
+
// G1:先递增模块级代数再装配——自此同模块环境内所有前代 runtime 的 isCtxStale 返回
|
|
61
|
+
// true(stale)。myGeneration 是本 handler 的代数,注入的比对闭包读实时模块级
|
|
62
|
+
// sessionGeneration 与之比较(factory 重跑的新闭包与本闭包共享同一模块绑定)。
|
|
63
|
+
sessionGeneration += 1
|
|
64
|
+
const myGeneration = sessionGeneration
|
|
65
|
+
// F1(治本):session 替换/重入时先停上一代 runtime 的 tick interval——dispatch 的 await sendMessage
|
|
66
|
+
// 窗口与 session 替换交错时旧 session_shutdown 可能永远等不到(timer 泄漏源头)。stopScheduler 幂等,
|
|
67
|
+
// shutdown 已停过再停一次无副作用。
|
|
68
|
+
service?.runtime.stopScheduler()
|
|
44
69
|
// 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
|
|
45
70
|
const backend = new PiSchedulerBackend(ctx, pi)
|
|
46
71
|
// 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
|
|
47
72
|
// append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
|
|
48
73
|
// ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
|
|
49
74
|
importCleanup = importLegacyStore(ctx.cwd, pi, ctx.sessionManager.getSessionFile())
|
|
50
|
-
|
|
75
|
+
// G1:注入代际比对(本 runtime 建立时的代数 vs 实时代数),供 tick 前置检查与
|
|
76
|
+
// F2 catch 分诊判定 stale——不依赖 pi 错误文案。
|
|
77
|
+
const runtime = new SchedulerRuntime(backend, ctx, () => sessionGeneration !== myGeneration)
|
|
51
78
|
runtime.loadTasks(backend.loadTasks())
|
|
52
79
|
// W2:tick 后回调刷新 widget(替代独立 widgetTimer + setInterval,节奏对齐 TICK_INTERVAL_MS)
|
|
53
80
|
runtime.onAfterTick(() => refreshWidget(ctx))
|
package/src/runtime.ts
CHANGED
|
@@ -16,6 +16,16 @@ const RATE_LIMIT_PER_MINUTE = 6
|
|
|
16
16
|
const TICK_INTERVAL_MS = 30_000
|
|
17
17
|
const DEFAULT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
18
18
|
const HISTORY_LIMIT = 20 // 与 replayFoldEntries 的裁剪上限一致(advance 折叠 / dispatch 累积共用)
|
|
19
|
+
// pi ExtensionRunner 在 session 替换后访问 stale ctx 时抛出的错误文案片段。
|
|
20
|
+
// 兜底通道(防御纵深):G1 模块级代际检测(isCtxStale)为主判,覆盖同模块环境内的 session
|
|
21
|
+
// 替换路径(newSession/fork/switchSession:extensionCache 命中,factory 重跑但模块环境共享,
|
|
22
|
+
// 模块级代数被新闭包递增);本子串覆盖代际盲区——显式 reload / cwd 变化触发
|
|
23
|
+
// clearExtensionCache 后 jiti 重新 import 产生全新模块环境,旧闭包引用的模块级代数冻结
|
|
24
|
+
// 不再递增,isCtxStale 恒 false,此时除 session_shutdown teardown 主防线外只剩错误文案
|
|
25
|
+
// 能识别 stale。
|
|
26
|
+
// 注意:pi 非契约 API(Error message 非稳定接口),pi 升级需回归验证 runtime.test.ts 的
|
|
27
|
+
// U1 / G1-d 文案锚定用例;文案变更时此兜底失效,后果为 timer 泄漏 + 每 30s warn(不 crash)。
|
|
28
|
+
const STALE_CTX_MARKER = 'stale after session replacement'
|
|
19
29
|
|
|
20
30
|
export class SchedulerRuntime {
|
|
21
31
|
private tasks: Map<string, ScheduledTask> = new Map()
|
|
@@ -24,17 +34,26 @@ export class SchedulerRuntime {
|
|
|
24
34
|
private tickTimer: ReturnType<typeof setInterval> | null = null
|
|
25
35
|
private dispatchTimestamps: number[] = []
|
|
26
36
|
private onAfterTickCallback: (() => void) | null = null
|
|
37
|
+
private readonly isCtxStale: (() => boolean) | undefined
|
|
38
|
+
// R3-S1:同任务 dispatch 在途标记(Set<taskId>),见 dispatchTask 注释
|
|
39
|
+
private readonly dispatchesInFlight = new Set<string>()
|
|
27
40
|
|
|
28
41
|
/**
|
|
29
42
|
* 依赖反转构造:backend 承担 appendEntry/pi.sendMessage/时间源,runtime 只持有内存态。
|
|
30
43
|
* 不触碰任何 FS / session JSONL(测试可用 MockSchedulerBackend 零副作用注入)。
|
|
44
|
+
*
|
|
45
|
+
* isCtxStale(G1 代际检测,S9/R3-M1):返回 true 表示本 runtime 建立时的 session 已被
|
|
46
|
+
* 替换。index.ts 装配点注入(模块级代数比对,R3-M1),使 stale 分诊不依赖 pi 错误文案;
|
|
47
|
+
* 缺省(不注入)恒视为非 stale——纯 runtime 单测与旧装配路径行为不变。
|
|
31
48
|
*/
|
|
32
49
|
constructor(
|
|
33
50
|
backend: SchedulerBackend,
|
|
34
51
|
ctx: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
|
|
52
|
+
isCtxStale?: () => boolean,
|
|
35
53
|
) {
|
|
36
54
|
this.backend = backend
|
|
37
55
|
this.ctx = ctx
|
|
56
|
+
this.isCtxStale = isCtxStale
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
// ── 任务 CRUD ──
|
|
@@ -160,7 +179,30 @@ export class SchedulerRuntime {
|
|
|
160
179
|
|
|
161
180
|
startScheduler(): void {
|
|
162
181
|
if (this.tickTimer) return
|
|
163
|
-
this.tickTimer = setInterval(() =>
|
|
182
|
+
this.tickTimer = setInterval(() => {
|
|
183
|
+
// G1(代际前置检查,S9):本 runtime 所属 session 已被替换 → timer 属泄漏资源,
|
|
184
|
+
// 自停退场且不进入本轮 tick(不触碰捕获的 stale ctx)。主防线是 F1(session_start
|
|
185
|
+
// 停旧 timer),此处覆盖 F1 未能触达的泄漏路径——且不依赖「stale ctx 访问恰好抛错」
|
|
186
|
+
// 或 pi 错误文案,代际一翻转即可静默退场。
|
|
187
|
+
if (this.isCtxStale?.()) {
|
|
188
|
+
this.retireStaleTimer()
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
// F2(防御兜底):fire-and-forget 的 tick 链路必须自带 catch——tick 内任何异常
|
|
192
|
+
// (典型:session 替换后泄漏 timer 的 onAfterTick → refreshWidget 访问 stale ctx.ui 抛错)
|
|
193
|
+
// 若无人接住即 unhandledRejection,直接崩掉 pi 主进程。分诊:G1 模块级代数比对为主判
|
|
194
|
+
// (契约内,不受 pi 文案变更影响),STALE_CTX_MARKER 子串为兜底(覆盖 reload 产生全新
|
|
195
|
+
// 模块环境后旧闭包代数冻结、isCtxStale 恒 false 的盲区)。stale 类错误说明本 runtime
|
|
196
|
+
// 所属 session 已被替换,timer 属泄漏资源,自停退场;其他错误仅告警,不终止调度。
|
|
197
|
+
void this.tickScheduler().catch((err: unknown) => {
|
|
198
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
199
|
+
if (this.isCtxStale?.() || message.includes(STALE_CTX_MARKER)) {
|
|
200
|
+
this.retireStaleTimer()
|
|
201
|
+
} else {
|
|
202
|
+
console.warn(`[scheduler] tick error: ${message}`)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
}, TICK_INTERVAL_MS)
|
|
164
206
|
}
|
|
165
207
|
|
|
166
208
|
stopScheduler(): void {
|
|
@@ -170,6 +212,16 @@ export class SchedulerRuntime {
|
|
|
170
212
|
}
|
|
171
213
|
}
|
|
172
214
|
|
|
215
|
+
/**
|
|
216
|
+
* stale 自停退场(G1 前置检查与 F2 catch 分诊共用):warn 观测口径与 crash-fix 一致
|
|
217
|
+
* (含 "tick stopped",U1 断言锚定)+ stopScheduler(幂等)。timer 自停后调度由
|
|
218
|
+
* session_start 重建的新一代 runtime 接管。
|
|
219
|
+
*/
|
|
220
|
+
private retireStaleTimer(): void {
|
|
221
|
+
console.warn(`[scheduler] tick stopped: stale extension ctx (session replaced); timer self-retired`)
|
|
222
|
+
this.stopScheduler()
|
|
223
|
+
}
|
|
224
|
+
|
|
173
225
|
/**
|
|
174
226
|
* 注册 tick 后回调(W2)。index.ts 注册 refreshWidget 替代独立 widgetTimer——
|
|
175
227
|
* 每次 tickScheduler 末尾调用,对齐 TICK_INTERVAL_MS 刷新 widget。
|
|
@@ -218,15 +270,38 @@ export class SchedulerRuntime {
|
|
|
218
270
|
|
|
219
271
|
/**
|
|
220
272
|
* dispatch 单个任务。返回 true 表示真的发送了 message,false 表示 no-op
|
|
221
|
-
* (task disabled / rate-limited / 非 force 且 busy)。
|
|
222
|
-
* sendMessage 抛错时记录 failed 状态但不 rethrow,让 tick 继续处理其他任务。
|
|
273
|
+
* (task disabled / 已有同任务在途 / rate-limited / 非 force 且 busy)。
|
|
223
274
|
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
275
|
+
* R3-S1 in-flight 守卫:tick 为 fire-and-forget,若 tick1 的 `await backend.sendMessage`
|
|
276
|
+
* 挂起超过 TICK_INTERVAL_MS(如 pi 卡死),tick2 的 step2 会再标 pending、step3 对同一
|
|
277
|
+
* task 并发第二个 dispatch → 同一 prompt 双注入(force 任务绕过 isIdle gate 直接受影响)。
|
|
278
|
+
* 参照 subagent-workflow resumesInFlight 模式:入口同步置位、finally 清除(覆盖 gate /
|
|
279
|
+
* rate-limit / sendMessage 抛错 / 成功推进全部退出路径);命中时 skip 本轮并 warn
|
|
280
|
+
* (不 throw——tick 继续处理其他任务,本任务 pending 保留到下轮重试)。
|
|
226
281
|
*/
|
|
227
282
|
async dispatchTask(task: ScheduledTask): Promise<boolean> {
|
|
228
283
|
if (!task.enabled) return false
|
|
284
|
+
if (this.dispatchesInFlight.has(task.id)) {
|
|
285
|
+
console.warn(`[scheduler] dispatch already in flight for task ${task.id}; skipping this tick`)
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
this.dispatchesInFlight.add(task.id)
|
|
289
|
+
try {
|
|
290
|
+
return await this.dispatchTaskInner(task)
|
|
291
|
+
} finally {
|
|
292
|
+
this.dispatchesInFlight.delete(task.id)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
229
295
|
|
|
296
|
+
/**
|
|
297
|
+
* dispatch 本体(dispatchTask 守卫置位后执行;runTaskNow 与 tick step3 共用入口,
|
|
298
|
+
* 手动 run-now 与挂起中的 tick dispatch 并发时同样被守卫拦截)。
|
|
299
|
+
* sendMessage 抛错时记录 failed 状态但不 rethrow,让 tick 继续处理其他任务。
|
|
300
|
+
*
|
|
301
|
+
* 持久化(append-only):recurring 成功推进 nextRunAt → append advance(status='success' CL8);
|
|
302
|
+
* once 成功 → append delete。失败 dispatch 不 append(CL7 重试语义,transient 失败 nextRunAt 未推进)。
|
|
303
|
+
*/
|
|
304
|
+
private async dispatchTaskInner(task: ScheduledTask): Promise<boolean> {
|
|
230
305
|
// 检查 force 或 idle
|
|
231
306
|
if (!task.force) {
|
|
232
307
|
if (!this.ctx.isIdle() || this.ctx.hasPendingMessages()) {
|
package/src/service.ts
CHANGED
|
@@ -133,7 +133,7 @@ export class SchedulerService {
|
|
|
133
133
|
/**
|
|
134
134
|
* 立即执行任务。语义细分:
|
|
135
135
|
* 任务不存在 → TASK_NOT_FOUND;任务存在但 dispatch no-op
|
|
136
|
-
* (disabled / busy / rate-limited
|
|
136
|
+
* (disabled / busy / rate-limited / 同任务 dispatch 在途)→ DISPATCH_SKIPPED。
|
|
137
137
|
* 修复了旧实现把 no-op 误报为 not found 的混同。
|
|
138
138
|
*/
|
|
139
139
|
async run(id: string | undefined): Promise<ServiceResult> {
|
|
@@ -148,7 +148,7 @@ export class SchedulerService {
|
|
|
148
148
|
return {
|
|
149
149
|
success: false,
|
|
150
150
|
errorCode: 'DISPATCH_SKIPPED',
|
|
151
|
-
message: `Task ${id} not dispatched (busy, disabled,
|
|
151
|
+
message: `Task ${id} not dispatched (busy, disabled, rate-limited, or a dispatch already in flight).`,
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
return { success: true, message: `Task ${id} executed.` }
|