@zhushanwen/pi-scheduler 0.9.2 → 0.10.0
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 +11 -10
- package/src/__tests__/U4-MODEL-SWITCH.test.ts +62 -196
- package/src/__tests__/active-path.test.ts +253 -0
- package/src/__tests__/index-generation.test.ts +24 -23
- package/src/__tests__/runtime.test.ts +6 -14
- package/src/__tests__/session-tree-registration.test.ts +152 -0
- package/src/__tests__/widget-push.test.ts +15 -55
- package/src/ack-turn.ts +6 -6
- package/src/backend.ts +53 -3
- package/src/index.ts +48 -53
- package/src/runtime.ts +79 -183
- package/src/types.ts +1 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U6c:backend.loadTasks 折叠接活跃路径裁剪 + session_tree handler 重折叠。
|
|
3
|
+
*
|
|
4
|
+
* 从 leafId 沿 parentId 回溯得活跃路径,被撤子树的任务 op entry 不再恢复 pending
|
|
5
|
+
* 定时任务——到点不触发真实 turn(A14 可观察验收锚)。
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
9
|
+
|
|
10
|
+
import { PiSchedulerBackend, type SchedulerBackendCtx } from '../backend.js'
|
|
11
|
+
import { SchedulerRuntime, TICK_INTERVAL_MS } from '../runtime.js'
|
|
12
|
+
import { TASK_ENTRY_TYPE } from '../types.js'
|
|
13
|
+
import type { ScheduledTask, SchedulerEntryOp, TaskSnapshot } from '../types.js'
|
|
14
|
+
|
|
15
|
+
const SESSION_FILE = '/test/session.json'
|
|
16
|
+
|
|
17
|
+
// ── fixture ─────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
interface BranchEntry {
|
|
20
|
+
id: string
|
|
21
|
+
parentId: string | null
|
|
22
|
+
type: string
|
|
23
|
+
customType?: string
|
|
24
|
+
data?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 构造 base task 快照(upsert op 用,字段集对齐 replay.test.ts 惯例) */
|
|
28
|
+
function snapshot(overrides: Partial<TaskSnapshot> = {}): TaskSnapshot {
|
|
29
|
+
return {
|
|
30
|
+
id: 't1',
|
|
31
|
+
name: 'test',
|
|
32
|
+
prompt: 't1 prompt',
|
|
33
|
+
kind: 'recurring',
|
|
34
|
+
schedule: { mode: 'interval', intervalMs: 60000 },
|
|
35
|
+
enabled: true,
|
|
36
|
+
createdAt: 0,
|
|
37
|
+
nextRunAt: 100,
|
|
38
|
+
runCount: 0,
|
|
39
|
+
history: [],
|
|
40
|
+
...overrides,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function upsertOp(taskId: string, overrides: Partial<TaskSnapshot> = {}): SchedulerEntryOp {
|
|
45
|
+
return {
|
|
46
|
+
op: 'upsert',
|
|
47
|
+
taskId,
|
|
48
|
+
ownerSessionFile: SESSION_FILE,
|
|
49
|
+
task: snapshot({ id: taskId, prompt: `${taskId} prompt`, ...overrides }),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function taskOpEntry(id: string, parentId: string | null, op: SchedulerEntryOp): BranchEntry {
|
|
54
|
+
return { type: 'custom', id, parentId, customType: TASK_ENTRY_TYPE, data: op }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 撤回后真实形态:label entry 落文件尾(parentId = 回退后叶子),leafId 指向它 */
|
|
58
|
+
function labelEntry(id: string, parentId: string | null): BranchEntry {
|
|
59
|
+
return { type: 'label', id, parentId, targetId: 'u-x', label: 'taiji:revoked' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 可变树状态的 ctx(模拟撤回前后 swap:entries + leafId 经闭包读取) */
|
|
63
|
+
function makeCtx(initialEntries: BranchEntry[], initialLeafId: string | null) {
|
|
64
|
+
let entries = initialEntries
|
|
65
|
+
let leafId = initialLeafId
|
|
66
|
+
const ctx: SchedulerBackendCtx = {
|
|
67
|
+
sessionManager: {
|
|
68
|
+
getEntries: () => entries.slice(),
|
|
69
|
+
getSessionFile: () => SESSION_FILE,
|
|
70
|
+
getLeafId: () => leafId,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
ctx,
|
|
75
|
+
setTree(nextEntries: BranchEntry[], nextLeafId: string | null): void {
|
|
76
|
+
entries = nextEntries
|
|
77
|
+
leafId = nextLeafId
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 捕获 pi.on 注册的 handler + 副作用记录面(sendMessage / appendEntry vi.fn)。
|
|
83
|
+
* on 字段仅满足 ExtensionAPI 结构完整性:session_tree 注册面已迁至 factory 顶层
|
|
84
|
+
* (index.ts),backend 构造不再调 pi.on(注册面契约见 session-tree-registration.test.ts)。 */
|
|
85
|
+
function makePi() {
|
|
86
|
+
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
|
87
|
+
const sendMessage = vi.fn(async () => {})
|
|
88
|
+
const appendEntry = vi.fn()
|
|
89
|
+
const registerProvider = vi.fn()
|
|
90
|
+
const unregisterProvider = vi.fn()
|
|
91
|
+
const pi = {
|
|
92
|
+
on: (event: string, handler: (...args: unknown[]) => unknown) => {
|
|
93
|
+
handlers.set(event, handler)
|
|
94
|
+
},
|
|
95
|
+
sendMessage,
|
|
96
|
+
appendEntry,
|
|
97
|
+
registerProvider,
|
|
98
|
+
unregisterProvider,
|
|
99
|
+
}
|
|
100
|
+
return { pi: pi as unknown as ExtensionAPI, handlers, sendMessage, appendEntry }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── loadTasks 活跃路径裁剪(U6c)─────────────────────
|
|
104
|
+
|
|
105
|
+
describe('PiSchedulerBackend.loadTasks 活跃路径裁剪(U6c)', () => {
|
|
106
|
+
it('有分支 fixture:被撤子树的任务 op 不进重建态', () => {
|
|
107
|
+
// 树:e1(t1 upsert,active);e1 → e3(t2 upsert,revoked-branch 物理后写);
|
|
108
|
+
// 撤回落 label 锚 L(parentId = 回退后叶子 e1),leafId = L → 活跃路径 {L, e1}
|
|
109
|
+
const entries = [
|
|
110
|
+
taskOpEntry('e1', null, upsertOp('t1')),
|
|
111
|
+
taskOpEntry('e3', 'e1', upsertOp('t2')),
|
|
112
|
+
labelEntry('L', 'e1'),
|
|
113
|
+
]
|
|
114
|
+
const { ctx } = makeCtx(entries, 'L')
|
|
115
|
+
|
|
116
|
+
const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
|
|
117
|
+
|
|
118
|
+
const ids = tasks.map(t => t.id)
|
|
119
|
+
expect(ids).toContain('t1')
|
|
120
|
+
// 反向断言:被撤子树任务不恢复 pending(无裁剪时会从 e3 折叠复活)
|
|
121
|
+
expect(ids).not.toContain('t2')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('同任务跨分支:取活跃路径内最新 upsert(分支上被撤的后续推进不参与折叠)', () => {
|
|
125
|
+
const entries = [
|
|
126
|
+
taskOpEntry('e1', null, upsertOp('t1', { prompt: 'old prompt' })),
|
|
127
|
+
taskOpEntry('e2', 'e1', upsertOp('t1', { prompt: 'active-branch prompt' })),
|
|
128
|
+
taskOpEntry('e3', 'e1', upsertOp('t1', { prompt: 'revoked-branch prompt' })),
|
|
129
|
+
labelEntry('L', 'e2'),
|
|
130
|
+
]
|
|
131
|
+
const { ctx } = makeCtx(entries, 'L')
|
|
132
|
+
|
|
133
|
+
const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
|
|
134
|
+
|
|
135
|
+
expect(tasks).toHaveLength(1)
|
|
136
|
+
expect(tasks[0]!.prompt).toBe('active-branch prompt')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('无分支回归:leafId = 文件尾 → 线性折叠最新(现行为不变)', () => {
|
|
140
|
+
const entries = [
|
|
141
|
+
taskOpEntry('e1', null, upsertOp('t1', { prompt: 'v1' })),
|
|
142
|
+
taskOpEntry('e2', 'e1', upsertOp('t1', { prompt: 'v2' })),
|
|
143
|
+
]
|
|
144
|
+
const { ctx } = makeCtx(entries, 'e2')
|
|
145
|
+
|
|
146
|
+
const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
|
|
147
|
+
|
|
148
|
+
expect(tasks).toHaveLength(1)
|
|
149
|
+
expect(tasks[0]!.prompt).toBe('v2')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('legacy fixture 回归:entry 无 id/parentId(duck-typed 最小形状)→ 线性全量折叠,行为与裁剪前一致', () => {
|
|
153
|
+
const entries = [
|
|
154
|
+
{ type: 'custom', customType: TASK_ENTRY_TYPE, data: upsertOp('t1') },
|
|
155
|
+
{ type: 'custom', customType: TASK_ENTRY_TYPE, data: upsertOp('t2') },
|
|
156
|
+
]
|
|
157
|
+
const ctx: SchedulerBackendCtx = {
|
|
158
|
+
sessionManager: {
|
|
159
|
+
getEntries: () => entries.slice(),
|
|
160
|
+
getSessionFile: () => SESSION_FILE,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
|
|
165
|
+
|
|
166
|
+
expect(tasks.map(t => t.id).sort()).toEqual(['t1', 't2'])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('leafId 防御:null / 指向不存在 entry → 回退文件尾(撤回落 label 锚在尾,等价命中活跃路径)', () => {
|
|
170
|
+
const entries = [
|
|
171
|
+
taskOpEntry('e1', null, upsertOp('t1')),
|
|
172
|
+
taskOpEntry('e3', 'e1', upsertOp('t2')),
|
|
173
|
+
labelEntry('L', 'e1'),
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
for (const leafId of [null, 'missing-id']) {
|
|
177
|
+
const { ctx } = makeCtx(entries, leafId)
|
|
178
|
+
const tasks = new PiSchedulerBackend(ctx, makePi().pi).loadTasks()
|
|
179
|
+
// 尾部锚 = label L(parentId=e1)→ 回溯 {L, e1},被撤子树 t2 仍被裁掉
|
|
180
|
+
expect(tasks.map(t => t.id)).toEqual(['t1'])
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// ── session_tree handler 重折叠 + 到点不触发(U6c / A14)──
|
|
186
|
+
|
|
187
|
+
describe('session_tree 重折叠(U6c)', () => {
|
|
188
|
+
it('撤回后 session_tree → runtime 任务集重折叠(被撤任务移除);handler 本身零 dispatch 零 append', () => {
|
|
189
|
+
// 撤回前:线性文件(leaf=尾=e2),t1/t2 均在
|
|
190
|
+
const preRevoke = [
|
|
191
|
+
taskOpEntry('e1', null, upsertOp('t1')),
|
|
192
|
+
taskOpEntry('e2', 'e1', upsertOp('t2')),
|
|
193
|
+
]
|
|
194
|
+
const { ctx, setTree } = makeCtx(preRevoke, null)
|
|
195
|
+
const { pi, sendMessage, appendEntry } = makePi()
|
|
196
|
+
|
|
197
|
+
const backend = new PiSchedulerBackend(ctx, pi)
|
|
198
|
+
const runtime = new SchedulerRuntime(backend)
|
|
199
|
+
runtime.loadTasks(backend.loadTasks())
|
|
200
|
+
expect(runtime.getTask('t1')).toBeDefined()
|
|
201
|
+
expect(runtime.getTask('t2')).toBeDefined()
|
|
202
|
+
|
|
203
|
+
// 撤回:树回退(label 锚落尾 + leafId 指向它)→ session_tree 事件 → 重折叠
|
|
204
|
+
// (生产注册面在 factory 顶层 index.ts,此处直接驱动委托体)
|
|
205
|
+
setTree([...preRevoke, labelEntry('L', 'e1')], 'L')
|
|
206
|
+
backend.refoldSessionTree()
|
|
207
|
+
|
|
208
|
+
expect(runtime.getTask('t1')).toBeDefined()
|
|
209
|
+
expect(runtime.getTask('t2')).toBeUndefined()
|
|
210
|
+
|
|
211
|
+
// 纯重建断言:handler 自身不 dispatch、不 append op(不污染回退后的新分支)
|
|
212
|
+
expect(sendMessage).not.toHaveBeenCalled()
|
|
213
|
+
expect(appendEntry).not.toHaveBeenCalled()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('到点不触发(A14):重折叠后被撤子树任务 interval 到点无 turn;活跃路径任务照常 dispatch', async () => {
|
|
217
|
+
vi.useFakeTimers()
|
|
218
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
219
|
+
try {
|
|
220
|
+
// 两任务同在撤回前文件上,nextRunAt = now + 1s(首个 30s tick 即到期)
|
|
221
|
+
const dueAt = Date.now() + 1000
|
|
222
|
+
const preRevoke = [
|
|
223
|
+
taskOpEntry('e1', null, upsertOp('t1', { nextRunAt: dueAt })),
|
|
224
|
+
taskOpEntry('e2', 'e1', upsertOp('t2', { nextRunAt: dueAt })),
|
|
225
|
+
]
|
|
226
|
+
const { ctx, setTree } = makeCtx(preRevoke, null)
|
|
227
|
+
const { pi, sendMessage } = makePi()
|
|
228
|
+
|
|
229
|
+
const backend = new PiSchedulerBackend(ctx, pi)
|
|
230
|
+
const runtime = new SchedulerRuntime(backend)
|
|
231
|
+
runtime.loadTasks(backend.loadTasks())
|
|
232
|
+
|
|
233
|
+
// 撤回回退到 e1(t2 所在子树被移出活跃路径)
|
|
234
|
+
setTree([...preRevoke, labelEntry('L', 'e1')], 'L')
|
|
235
|
+
backend.refoldSessionTree()
|
|
236
|
+
expect(runtime.getTask('t2')).toBeUndefined()
|
|
237
|
+
|
|
238
|
+
// tick 常驻循环不动启停逻辑:重折叠后照常消费新任务集
|
|
239
|
+
runtime.startScheduler()
|
|
240
|
+
await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
|
|
241
|
+
|
|
242
|
+
const prompts = sendMessage.mock.calls.map(call => (call[0] as { content: string }).content)
|
|
243
|
+
expect(prompts).toContain('t1 prompt')
|
|
244
|
+
// A14 验收锚:被撤子树任务到点不触发真实 turn
|
|
245
|
+
expect(prompts).not.toContain('t2 prompt')
|
|
246
|
+
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
247
|
+
} finally {
|
|
248
|
+
// tick interval 在 fake timers 域内清理,防泄漏到后续用例
|
|
249
|
+
vi.clearAllTimers()
|
|
250
|
+
vi.useRealTimers()
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -47,13 +47,15 @@ vi.mock('../widget.js', async (importOriginal) => {
|
|
|
47
47
|
})
|
|
48
48
|
|
|
49
49
|
// vi.mock factory 会被提升,跨模块共享状态必须经 vi.hoisted。
|
|
50
|
-
const { isCtxStaleCaptures, runtimeInstances } = vi.hoisted(() => ({
|
|
50
|
+
const { isCtxStaleCaptures, runtimeInstances, stopSchedulerCalls } = vi.hoisted(() => ({
|
|
51
51
|
isCtxStaleCaptures: [] as Array<(() => boolean) | undefined>,
|
|
52
52
|
runtimeInstances: [] as Array<{ stopScheduler(): void }>,
|
|
53
|
+
stopSchedulerCalls: [] as Array<{ stopScheduler(): void }>,
|
|
53
54
|
}))
|
|
54
55
|
|
|
55
56
|
// InstrumentedRuntime 继承真实实现(loadTasks/onAfterTick/startScheduler 均真实执行),
|
|
56
|
-
// 仅捕获构造参(isCtxStale)并登记实例(afterEach 统一停 timer,避免真实 setInterval
|
|
57
|
+
// 仅捕获构造参(isCtxStale)并登记实例(afterEach 统一停 timer,避免真实 setInterval 残留);
|
|
58
|
+
// stopScheduler 调用按序记录(F1 验收断言面:新装配前旧实例必须被停)。
|
|
57
59
|
vi.mock('../runtime.js', async (importOriginal) => {
|
|
58
60
|
const actual = await importOriginal<typeof import('../runtime.js')>()
|
|
59
61
|
const RealSchedulerRuntime = actual.SchedulerRuntime
|
|
@@ -66,17 +68,18 @@ vi.mock('../runtime.js', async (importOriginal) => {
|
|
|
66
68
|
isCtxStaleCaptures.push(isCtxStale)
|
|
67
69
|
runtimeInstances.push(this)
|
|
68
70
|
}
|
|
71
|
+
override stopScheduler(): void {
|
|
72
|
+
stopSchedulerCalls.push(this)
|
|
73
|
+
super.stopScheduler()
|
|
74
|
+
}
|
|
69
75
|
}
|
|
70
76
|
return { ...actual, SchedulerRuntime: InstrumentedRuntime }
|
|
71
77
|
})
|
|
72
78
|
|
|
73
79
|
import schedulerExtension from '../index.js'
|
|
80
|
+
import { TICK_INTERVAL_MS } from '../runtime.js'
|
|
74
81
|
import { TASK_ENTRY_TYPE } from '../types.js'
|
|
75
82
|
|
|
76
|
-
const TICK_INTERVAL_MS = 30_000
|
|
77
|
-
/** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(常量不导出,测试锚定同值)。 */
|
|
78
|
-
const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
|
|
79
|
-
|
|
80
83
|
/**
|
|
81
84
|
* 最小 fake pi:覆盖 factory 消费的 API 面
|
|
82
85
|
* (on 捕获事件 handler 供手动触发;registerTool/registerCommand/sendMessage/appendEntry 兜底)。
|
|
@@ -207,12 +210,9 @@ describe('G1: index.ts 代际接线(S9)', () => {
|
|
|
207
210
|
// 下一 tick 的 refreshWidget 访问 stale ctx.ui 抛错 → unhandledRejection → pi 主进程 exit 1。
|
|
208
211
|
// F1 在 session_start 开头幂等 stopScheduler,从源头消灭。
|
|
209
212
|
//
|
|
210
|
-
// 行为断言口径(验收 U4):观测面 =
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// 空任务集恒零推送,故注入非空任务集、以「跨 10min 保活窗口必推一帧」作为「该 runtime 的
|
|
214
|
-
// timer 是否还在 tick」的行为观测面(保活帧间隔与 index.ts WIDGET_KEEPALIVE_INTERVAL_MS
|
|
215
|
-
// 同值 10min)。F1 缺失时旧 timer 跨窗口必多推一帧,即被捕获。
|
|
213
|
+
// 行为断言口径(验收 U4):观测面 = InstrumentedRuntime 的 stopScheduler 调用序列
|
|
214
|
+
// (widget 推送是 D1 指纹跳推语义——任务集静态期间零推送,不能靠推送计数区分新旧 timer;
|
|
215
|
+
// stopScheduler 白盒断言直接锁定「新装配前旧实例必须被停」的 F1 语义本身)。
|
|
216
216
|
describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
217
217
|
const BASE = Date.parse('2026-01-01T00:00:00Z')
|
|
218
218
|
|
|
@@ -244,7 +244,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
/** 同 createFakeCtx
|
|
247
|
+
/** 同 createFakeCtx,但注入非空任务集(首帧渲染需要非空任务集可断言)。 */
|
|
248
248
|
function createFakeCtxWithTasks(sessionFile: string): ExtensionContext {
|
|
249
249
|
return {
|
|
250
250
|
cwd: '/test-index-session-start',
|
|
@@ -261,6 +261,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
|
261
261
|
beforeEach(() => {
|
|
262
262
|
isCtxStaleCaptures.length = 0
|
|
263
263
|
runtimeInstances.length = 0
|
|
264
|
+
stopSchedulerCalls.length = 0
|
|
264
265
|
setSchedulerWidgetMock.mockClear()
|
|
265
266
|
vi.useFakeTimers()
|
|
266
267
|
vi.setSystemTime(new Date(BASE))
|
|
@@ -273,7 +274,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
|
273
274
|
vi.useRealTimers()
|
|
274
275
|
})
|
|
275
276
|
|
|
276
|
-
it('U4: 双 session_start 后旧 timer
|
|
277
|
+
it('U4: 双 session_start 后旧 timer 已停(stopScheduler 恰命中首个实例)', async () => {
|
|
277
278
|
const { pi, events } = createMockPi()
|
|
278
279
|
schedulerExtension(pi)
|
|
279
280
|
const sessionStart = events.get('session_start')
|
|
@@ -282,19 +283,19 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
|
282
283
|
// 第一次 session_start:runtime1 + timer1 启动,初始渲染 1 次
|
|
283
284
|
sessionStart!({ type: 'session_start', reason: 'startup' }, createFakeCtxWithTasks('/test/session-1.json'))
|
|
284
285
|
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(1)
|
|
286
|
+
expect(runtimeInstances).toHaveLength(1)
|
|
285
287
|
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2)
|
|
288
|
+
// 前置因果锚点:静置数个 tick——timer1 在 tick(无异常),任务集静态零推送(D1 跳推)
|
|
289
|
+
await vi.advanceTimersByTimeAsync(2 * TICK_INTERVAL_MS)
|
|
290
|
+
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(1)
|
|
290
291
|
|
|
291
292
|
// 第二次 session_start(session 替换):F1 在装配新 runtime 前停掉 timer1
|
|
292
293
|
sessionStart!({ type: 'session_start', reason: 'new_session' }, createFakeCtxWithTasks('/test/session-2.json'))
|
|
293
|
-
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(
|
|
294
|
+
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2) // runtime2 初始渲染(实例态重置,首帧必推)
|
|
295
|
+
expect(runtimeInstances).toHaveLength(2)
|
|
294
296
|
|
|
295
|
-
//
|
|
296
|
-
// F1
|
|
297
|
-
|
|
298
|
-
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(4)
|
|
297
|
+
// 行为断言(验收口径):stopScheduler 恰被调一次且命中 runtime1;
|
|
298
|
+
// F1 缺失时旧 timer 不被停(数组为空或不含 runtime1)
|
|
299
|
+
expect(stopSchedulerCalls).toEqual([runtimeInstances[0]])
|
|
299
300
|
})
|
|
300
301
|
})
|
|
@@ -11,7 +11,7 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
|
11
11
|
}))
|
|
12
12
|
|
|
13
13
|
import { MockSchedulerBackend } from './mock-backend.js'
|
|
14
|
-
import { SchedulerRuntime } from '../runtime.js'
|
|
14
|
+
import { TICK_INTERVAL_MS, SchedulerRuntime } from '../runtime.js'
|
|
15
15
|
|
|
16
16
|
// MockSchedulerBackend 零 FS 副作用:runtime 不再触碰 store,无需 mock store.js。
|
|
17
17
|
|
|
@@ -48,16 +48,8 @@ describe('SchedulerRuntime', () => {
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
describe('listTasks', () => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
await runtime.addTask('task 2', { mode: 'interval', intervalMs: 30000 })
|
|
54
|
-
const tasks = runtime.listTasks()
|
|
55
|
-
expect(tasks).toHaveLength(2)
|
|
56
|
-
// 30s interval 的 nextRunAt 早于 60s 的,应排前
|
|
57
|
-
expect(tasks[0]!.nextRunAt).toBeLessThan(tasks[1]!.nextRunAt)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
// 强化断言:30s 任务 nextRunAt 更小(更早),应是 listTasks()[0]
|
|
51
|
+
// 排序契约:30s 任务 nextRunAt 更小(更早),应是 listTasks()[0]
|
|
52
|
+
//(含排序断言全集:tasks[0].id 定位 + nextRunAt 严格小于)
|
|
61
53
|
it('orders shorter-interval task first', async () => {
|
|
62
54
|
const t60 = await runtime.addTask('60s', { mode: 'interval', intervalMs: 60000 })
|
|
63
55
|
const t30 = await runtime.addTask('30s', { mode: 'interval', intervalMs: 30000 })
|
|
@@ -115,7 +107,9 @@ describe('SchedulerRuntime', () => {
|
|
|
115
107
|
const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
116
108
|
await runtime.dispatchTask(task)
|
|
117
109
|
expect(backend.sentMessages).toHaveLength(1)
|
|
118
|
-
expect(backend.sentMessages[0]!.msg).toEqual(expect.objectContaining({ content: 'test' }))
|
|
110
|
+
expect(backend.sentMessages[0]!.msg).toEqual(expect.objectContaining({ content: 'test', customType: 'pi-scheduler:dispatched' }))
|
|
111
|
+
// steer 直投契约(runtime.ts sendMessage 第二参):丢 opts = pi idle 时定时任务静默不开轮次
|
|
112
|
+
expect(backend.sentMessages[0]!.opts).toEqual({ deliverAs: 'steer', triggerTurn: true })
|
|
119
113
|
})
|
|
120
114
|
|
|
121
115
|
it('skips disabled task', async () => {
|
|
@@ -565,7 +559,6 @@ describe('SchedulerRuntime', () => {
|
|
|
565
559
|
// 自停;其他错误 → warn "tick error" 继续调度。修复前 tick 内异常无人接住 →
|
|
566
560
|
// unhandledRejection → pi 主进程 exit 1。
|
|
567
561
|
describe('tick 错误分诊(F2)', () => {
|
|
568
|
-
const TICK_INTERVAL_MS = 30_000
|
|
569
562
|
|
|
570
563
|
beforeEach(() => {
|
|
571
564
|
vi.useFakeTimers()
|
|
@@ -639,7 +632,6 @@ describe('SchedulerRuntime', () => {
|
|
|
639
632
|
// clearExtensionCache 后 jiti 重 import 全新模块环境,旧闭包的模块级代数冻结不再递增,
|
|
640
633
|
// 只剩文案能识别 stale;模块级方案的装配级验证见 index-generation.test.ts factory 重跑用例)
|
|
641
634
|
describe('G1: 代际检测分诊(S9)', () => {
|
|
642
|
-
const TICK_INTERVAL_MS = 30_000
|
|
643
635
|
let staleFlag: boolean
|
|
644
636
|
let genRuntime: SchedulerRuntime
|
|
645
637
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/__tests__/session-tree-registration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// U6c session_tree 注册面契约:注册在 extension factory 顶层(每代恰一 handler),
|
|
4
|
+
// 同代 session_start 复发不累积。回归背景:注册曾在 PiSchedulerBackend 构造函数内,
|
|
5
|
+
// 而 pi RPC 模式下每次 session 替换 session_start 在同一代内触发两次
|
|
6
|
+
// (agent-session-runtime finishSessionReplacement 内部 rebindSession + RPC handler
|
|
7
|
+
// 再 rebindSession,0.84.4 实装核对),pi 的 on 是追加语义(loader.js list.push,
|
|
8
|
+
// 无去重无 off)——handler 随替换线性累积,残留旧代 backend 在旧 ctx 上重复执行。
|
|
9
|
+
//
|
|
10
|
+
// mock pi 的 on 必须是追加语义(list.push,对齐 pi 实装):覆盖式 Map.set 会掩盖
|
|
11
|
+
// 累积回归,正是本套件要防的形态。
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
14
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
15
|
+
|
|
16
|
+
// MF-3(与 sdk-contract.test.ts 同款):mock importer,session_start 装配零 FS 副作用。
|
|
17
|
+
vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
|
|
18
|
+
|
|
19
|
+
// mock 共享 logger(与 widget-push.test.ts 同款):防测试触碰真实日志目录。
|
|
20
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
21
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
22
|
+
}))
|
|
23
|
+
vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
24
|
+
getLogger: () => loggerMock,
|
|
25
|
+
createLogger: () => loggerMock,
|
|
26
|
+
setPiHandle: vi.fn(),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
import schedulerExtension from '../index.js'
|
|
30
|
+
import { TASK_ENTRY_TYPE } from '../types.js'
|
|
31
|
+
|
|
32
|
+
type Handler = (...args: unknown[]) => unknown
|
|
33
|
+
|
|
34
|
+
/** 追加语义 mock pi:on = list.push(对齐 pi loader.js on 实装,无去重无 off)。 */
|
|
35
|
+
function createAppendPi(): {
|
|
36
|
+
pi: ExtensionAPI
|
|
37
|
+
handlers: Map<string, Handler[]>
|
|
38
|
+
tools: { name: string; execute: (...args: unknown[]) => Promise<{ content: { text: string }[] }> }[]
|
|
39
|
+
} {
|
|
40
|
+
const handlers = new Map<string, Handler[]>()
|
|
41
|
+
const tools: { name: string; execute: (...args: unknown[]) => Promise<{ content: { text: string }[] }> }[] = []
|
|
42
|
+
const pi = {
|
|
43
|
+
registerTool: (tool: { name: string; execute: (...args: unknown[]) => Promise<{ content: { text: string }[] }> }) =>
|
|
44
|
+
tools.push(tool),
|
|
45
|
+
registerCommand: vi.fn(),
|
|
46
|
+
on: (event: string, handler: Handler) => {
|
|
47
|
+
const list = handlers.get(event) ?? []
|
|
48
|
+
list.push(handler)
|
|
49
|
+
handlers.set(event, list)
|
|
50
|
+
},
|
|
51
|
+
sendMessage: vi.fn(),
|
|
52
|
+
appendEntry: vi.fn(),
|
|
53
|
+
} as unknown as ExtensionAPI
|
|
54
|
+
return { pi, handlers, tools }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 远期到期的启用任务 upsert entry(owner = sessionFile;测试窗内无 dispatch 噪音)。 */
|
|
58
|
+
function taskEntry(sessionFile: string, taskId: string) {
|
|
59
|
+
return {
|
|
60
|
+
type: 'custom',
|
|
61
|
+
customType: TASK_ENTRY_TYPE,
|
|
62
|
+
data: {
|
|
63
|
+
op: 'upsert',
|
|
64
|
+
taskId,
|
|
65
|
+
ownerSessionFile: sessionFile,
|
|
66
|
+
task: {
|
|
67
|
+
id: taskId,
|
|
68
|
+
name: taskId,
|
|
69
|
+
prompt: `${taskId} prompt`,
|
|
70
|
+
kind: 'recurring',
|
|
71
|
+
schedule: { mode: 'interval', intervalMs: 3_600_000 },
|
|
72
|
+
enabled: true,
|
|
73
|
+
createdAt: 0,
|
|
74
|
+
nextRunAt: Date.now() + 3_600_000,
|
|
75
|
+
runCount: 0,
|
|
76
|
+
history: [],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** fake ctx:entries 经容器可变(模拟撤回前后 swap);ui.setWidget 观测面。 */
|
|
83
|
+
function createFakeCtx(sessionFile: string, entriesBox: { entries: unknown[] }): ExtensionContext {
|
|
84
|
+
return {
|
|
85
|
+
cwd: '/test-tree-reg',
|
|
86
|
+
isIdle: () => true,
|
|
87
|
+
hasPendingMessages: () => false,
|
|
88
|
+
ui: { setWidget: vi.fn() },
|
|
89
|
+
sessionManager: {
|
|
90
|
+
getEntries: () => entriesBox.entries,
|
|
91
|
+
getSessionFile: () => sessionFile,
|
|
92
|
+
},
|
|
93
|
+
} as unknown as ExtensionContext
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('session_tree 注册面(factory 顶层恰一,同代 session_start 复发不累积)', () => {
|
|
97
|
+
it('factory 运行恰注册 1 个 session_tree handler', () => {
|
|
98
|
+
const { pi, handlers } = createAppendPi()
|
|
99
|
+
schedulerExtension(pi)
|
|
100
|
+
expect(handlers.get('session_tree')).toHaveLength(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('同代 session_start 复发(RPC 模式每次替换触发 2 次)→ session_tree handler 恒恰 1', () => {
|
|
104
|
+
const { pi, handlers } = createAppendPi()
|
|
105
|
+
schedulerExtension(pi)
|
|
106
|
+
const entriesBox = { entries: [] as unknown[] }
|
|
107
|
+
const ctx = createFakeCtx('/test/tree-reg.json', entriesBox)
|
|
108
|
+
const sessionStart = handlers.get('session_start')![0]!
|
|
109
|
+
|
|
110
|
+
sessionStart({ type: 'session_start', reason: 'new' }, ctx)
|
|
111
|
+
sessionStart({ type: 'session_start', reason: 'resume' }, ctx)
|
|
112
|
+
|
|
113
|
+
expect(handlers.get('session_tree')).toHaveLength(1)
|
|
114
|
+
|
|
115
|
+
// 收尾停当代 runtime 的 tick interval(fake timers 未启用,interval 需显式清)
|
|
116
|
+
void handlers.get('session_shutdown')![0]()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('复发后 session_tree 委托当代 backend:撤回后重折叠反映到任务查询', () => {
|
|
120
|
+
const file = '/test/tree-reg-delegate.json'
|
|
121
|
+
const entriesBox = { entries: [taskEntry(file, 't1')] as unknown[] }
|
|
122
|
+
const { pi, handlers, tools } = createAppendPi()
|
|
123
|
+
schedulerExtension(pi)
|
|
124
|
+
const ctx = createFakeCtx(file, entriesBox)
|
|
125
|
+
const sessionStart = handlers.get('session_start')![0]!
|
|
126
|
+
|
|
127
|
+
// 同代复发两次:第 2 个 backend 为当代(旧代已被 stopScheduler + 代数判 stale)
|
|
128
|
+
sessionStart({ type: 'session_start', reason: 'new' }, ctx)
|
|
129
|
+
sessionStart({ type: 'session_start', reason: 'resume' }, ctx)
|
|
130
|
+
|
|
131
|
+
// 基线:t1 在当代任务集内
|
|
132
|
+
const control = tools.find(t => t.name === 'schedule_control')!
|
|
133
|
+
|
|
134
|
+
return control
|
|
135
|
+
.execute('call-base', { action: 'list' }, undefined, undefined, ctx)
|
|
136
|
+
.then(result => {
|
|
137
|
+
expect(result.content[0]!.text).toContain('t1')
|
|
138
|
+
|
|
139
|
+
// 撤回:entry 移出(getEntries 变化)→ session_tree 事件 → 当代 backend 重折叠
|
|
140
|
+
entriesBox.entries = []
|
|
141
|
+
handlers.get('session_tree')![0]!({ type: 'session_tree' }, ctx)
|
|
142
|
+
|
|
143
|
+
return control.execute('call-after', { action: 'list' }, undefined, undefined, ctx)
|
|
144
|
+
})
|
|
145
|
+
.then(result => {
|
|
146
|
+
expect(result.content[0]!.text).not.toContain('t1')
|
|
147
|
+
})
|
|
148
|
+
.finally(() => {
|
|
149
|
+
void handlers.get('session_shutdown')![0]()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
})
|