@zhushanwen/pi-scheduler 0.1.0 → 0.2.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/README.md +236 -0
- package/package.json +4 -3
- package/src/__tests__/backend.test.ts +120 -0
- package/src/__tests__/commands.test.ts +59 -60
- package/src/__tests__/cron.test.ts +1 -2
- package/src/__tests__/format.test.ts +35 -0
- package/src/__tests__/importer.test.ts +283 -0
- package/src/__tests__/parsing.test.ts +29 -0
- package/src/__tests__/property.test.ts +131 -0
- package/src/__tests__/replay.test.ts +309 -0
- package/src/__tests__/runtime.test.ts +282 -48
- package/src/__tests__/sdk-contract.test.ts +54 -19
- package/src/__tests__/service.test.ts +214 -0
- package/src/__tests__/tool.test.ts +40 -23
- package/src/backend.ts +151 -0
- package/src/commands.ts +28 -34
- package/src/format.ts +6 -4
- package/src/importer.ts +261 -0
- package/src/index.ts +62 -34
- package/src/parsing.ts +19 -1
- package/src/replay.ts +181 -0
- package/src/runtime.ts +152 -63
- package/src/service.ts +156 -0
- package/src/tool.ts +51 -75
- package/src/types.ts +43 -5
- package/src/__tests__/store.test.ts +0 -161
- package/src/store.ts +0 -117
package/src/importer.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
6
|
+
|
|
7
|
+
import type { ScheduledTask, SchedulerEntryOp, SchedulerStore, TaskSnapshot } from './types.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 获取旧 store 文件路径:~/.pi/agent/scheduler/<root>/<segments>/scheduler.json。
|
|
11
|
+
*
|
|
12
|
+
* 内联自 store.ts getStorePath——store.ts 本 wave 删除后此函数是旧路径的唯一推导实现,
|
|
13
|
+
* 不能 import 已删 store。workspace 路径隔离,不同 cwd 存不同文件。
|
|
14
|
+
*
|
|
15
|
+
* export 供测试推导期望路径(断言 renameSync/unlinkSync 参数)。
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ 路径硬编码说明:硬编码 `~/.pi/agent/scheduler/` 是因为已发布版(npm 0.1.1)的 store.ts
|
|
18
|
+
* 即用此路径,旧数据确在此处,必须按此路径探测才能迁移。注意分支 `feat-auto-name-session-refactor`
|
|
19
|
+
* 的 commit 4b5513b5e 把 store 路径改为 getAgentDir()(读 PI_CODING_AGENT_DIR,用于 xyz-agent
|
|
20
|
+
* 数据目录隔离);该分支合并后此处需改为双候选路径探测(先 getAgentDir() 再 fallback ~/.pi/agent),
|
|
21
|
+
* 否则 xyz-agent 隔离目录下的旧任务探测不到。
|
|
22
|
+
*/
|
|
23
|
+
export function getLegacyStorePath(cwd: string): string {
|
|
24
|
+
const home = os.homedir()
|
|
25
|
+
const resolved = path.resolve(cwd)
|
|
26
|
+
const parsed = path.parse(resolved)
|
|
27
|
+
const segments = resolved.slice(parsed.root.length)
|
|
28
|
+
.split(path.sep).filter(Boolean)
|
|
29
|
+
const root = parsed.root
|
|
30
|
+
.replaceAll(/[^a-zA-Z0-9]+/g, '-')
|
|
31
|
+
.replaceAll(/^-+|-+$/g, '')
|
|
32
|
+
.toLowerCase() || 'root'
|
|
33
|
+
return path.join(home, '.pi', 'agent', 'scheduler', root, ...segments, 'scheduler.json')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 旧 store 任务字段补全(参考 store.ts load() 默认值兜底):旧数据可能缺字段,逐字段给默认值。
|
|
38
|
+
* ownerSessionFile/pending 不补全——旧数据本无此二字段,由 toTaskSnapshot 显式剥离。
|
|
39
|
+
*/
|
|
40
|
+
function normalizeLegacyTask(t: Partial<ScheduledTask>): ScheduledTask {
|
|
41
|
+
return {
|
|
42
|
+
id: t.id ?? '',
|
|
43
|
+
name: t.name ?? '',
|
|
44
|
+
prompt: t.prompt ?? '',
|
|
45
|
+
kind: t.kind ?? 'recurring',
|
|
46
|
+
schedule: t.schedule ?? { mode: 'interval', intervalMs: 60000 },
|
|
47
|
+
createdAt: t.createdAt ?? 0,
|
|
48
|
+
nextRunAt: t.nextRunAt ?? 0,
|
|
49
|
+
runCount: t.runCount ?? 0,
|
|
50
|
+
enabled: t.enabled ?? true,
|
|
51
|
+
force: t.force ?? false,
|
|
52
|
+
history: t.history ?? [],
|
|
53
|
+
expiresAt: t.expiresAt,
|
|
54
|
+
lastRunAt: t.lastRunAt,
|
|
55
|
+
lastStatus: t.lastStatus,
|
|
56
|
+
lastError: t.lastError,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* ScheduledTask → TaskSnapshot:显式构造 15 字段
|
|
62
|
+
* (id/name/prompt/kind/schedule/enabled/force/createdAt/nextRunAt/expiresAt?/
|
|
63
|
+
* runCount/lastRunAt?/lastStatus?/lastError?/history),剥离 ownerSessionFile(在 op 顶层)
|
|
64
|
+
* 与 pending(运行时标记),history 用 slice() 深拷贝。
|
|
65
|
+
*
|
|
66
|
+
* 显式构造而非复用 runtime.toSnapshot 的解构写法(T-C3-explicit):旧 store 数据本无
|
|
67
|
+
* ownerSessionFile/pending 运行时字段,显式构造更安全。两处构造逻辑需保持形状一致
|
|
68
|
+
* (对照 types.ts TaskSnapshot 15 字段)。
|
|
69
|
+
*/
|
|
70
|
+
function toTaskSnapshot(task: ScheduledTask): TaskSnapshot {
|
|
71
|
+
return {
|
|
72
|
+
id: task.id,
|
|
73
|
+
name: task.name,
|
|
74
|
+
prompt: task.prompt,
|
|
75
|
+
kind: task.kind,
|
|
76
|
+
schedule: task.schedule,
|
|
77
|
+
enabled: task.enabled,
|
|
78
|
+
force: task.force,
|
|
79
|
+
createdAt: task.createdAt,
|
|
80
|
+
nextRunAt: task.nextRunAt,
|
|
81
|
+
expiresAt: task.expiresAt,
|
|
82
|
+
runCount: task.runCount,
|
|
83
|
+
lastRunAt: task.lastRunAt,
|
|
84
|
+
lastStatus: task.lastStatus,
|
|
85
|
+
lastError: task.lastError,
|
|
86
|
+
history: task.history.slice(),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 判断 err 是否为 ENOENT(fs.renameSync 源文件不存在)。
|
|
92
|
+
* Record 守卫模式与 replay.ts isSchedulerEntryOp 同款(避免 taste/no-unsafe-cast)。
|
|
93
|
+
*/
|
|
94
|
+
function isENOENT(err: unknown): boolean {
|
|
95
|
+
if (!(err instanceof Error)) return false
|
|
96
|
+
if (!('code' in err)) return false
|
|
97
|
+
return (err as Record<'code', unknown>).code === 'ENOENT'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 从 .imported 路径读取旧 store JSON、逐任务 pi.appendEntry upsert、按 flush 状态删除 .imported。
|
|
102
|
+
* read/parse/append 任一异常由外层 importLegacyStore 的 try/catch 兜底(C1 整体降级)。
|
|
103
|
+
*
|
|
104
|
+
* 返回值为延迟删除 cleanup(未 flush 的新 session 场景),由 index.ts 在首个 turn_end 与
|
|
105
|
+
* session_shutdown 时调用(cleanup 幂等,可重复调用);已安全删除或无需导入时返回 undefined。
|
|
106
|
+
*
|
|
107
|
+
* ⚠️ unlink 时机(IMPORT-FLUSH-GUARD,MF-1 修复):pi SessionManager._persist 延迟写入——
|
|
108
|
+
* 新 session(sessionFile 尚不存在 → flushed=false)的 appendEntry 只进内存 fileEntries,
|
|
109
|
+
* 首个 assistant message 到达才 flush 落盘(openSync "wx" 全量写 fileEntries)。若紧随 append
|
|
110
|
+
* 循环 unlink,未 flush 即退出(打开未发消息即关闭、xyz-agent 自动打开/恢复的 session)→
|
|
111
|
+
* 全部旧任务永久丢失且源文件已销毁,崩溃恢复路径(.imported 残留)同时失效。
|
|
112
|
+
* resumed session(sessionFile 已存在 → _setSessionFile 载入时 flushed=true)每次 append
|
|
113
|
+
* 即时写盘,unlink 安全。
|
|
114
|
+
*
|
|
115
|
+
* 判定依据(pi SDK 源码,非推理):dist/core/session-manager.js `_persist` 的 !hasAssistant
|
|
116
|
+
* 分支——flushed=true 时 appendFileSync 直写盘,flushed=false 时仅内存;flushed 仅在
|
|
117
|
+
* _setSessionFile 载入已存在文件(或零字节文件重写)时置 true,新 session 恒 false。
|
|
118
|
+
* sessionFile 进程内只可能由首次 flush 创建(openSync "wx" 全量写),故
|
|
119
|
+
* fs.existsSync(currentSessionFile) 在 session_start 时点与 flushed 状态等价。
|
|
120
|
+
*/
|
|
121
|
+
function importFromFile(
|
|
122
|
+
importedPath: string,
|
|
123
|
+
pi: Pick<ExtensionAPI, 'appendEntry'>,
|
|
124
|
+
currentSessionFile: string,
|
|
125
|
+
): (() => void) | undefined {
|
|
126
|
+
const content = fs.readFileSync(importedPath, 'utf-8')
|
|
127
|
+
const data = JSON.parse(content) as Partial<SchedulerStore>
|
|
128
|
+
const tasks = (data.tasks ?? []).map(normalizeLegacyTask)
|
|
129
|
+
|
|
130
|
+
for (const task of tasks) {
|
|
131
|
+
const op: SchedulerEntryOp = {
|
|
132
|
+
op: 'upsert',
|
|
133
|
+
taskId: task.id,
|
|
134
|
+
ownerSessionFile: currentSessionFile,
|
|
135
|
+
task: toTaskSnapshot(task),
|
|
136
|
+
}
|
|
137
|
+
// customType 与 backend.ts PiSchedulerBackend.appendEntry 同源,改一处必改两处
|
|
138
|
+
pi.appendEntry('pi-scheduler:task', op)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 空 store(0 任务)无持久化依赖,直接删;resumed session(sessionFile 已存在)已即时落盘,删
|
|
142
|
+
if (tasks.length === 0 || fs.existsSync(currentSessionFile)) {
|
|
143
|
+
fs.unlinkSync(importedPath)
|
|
144
|
+
// 内部诊断日志(非用户可见消息):用 console.warn 而非 console.log(项目 convention:
|
|
145
|
+
// extensions 禁 console.log/info 防泄漏到 TUI;诊断输出统一 console.warn)
|
|
146
|
+
console.warn(`[scheduler] imported ${tasks.length} legacy tasks from ${importedPath}`)
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 新 session(未 flush):延迟删除,cleanup 由 index.ts 在首个 turn_end(该轮 message_end 已全部
|
|
151
|
+
// 持久化,flush 必已发生——agent-session.js _handleAgentEvent 在 message_end 处理中调
|
|
152
|
+
// appendMessage 触发 _persist 全量落盘)与 session_shutdown 时重复调用——flush 后 sessionFile
|
|
153
|
+
// 出现 → 删;仍未 flush → 静默保留 .imported 供下次 session_start 的 handleImportedResidue
|
|
154
|
+
// 崩溃恢复重导入。跨 session 重导入窗口 = session_start → 首个 turn_end(秒级):turn_end 后
|
|
155
|
+
// .imported 已删,后续 session 启动看不到残留 → 双导入窗口闭合(R-CONCURRENT-IMPORT 已更新)
|
|
156
|
+
console.warn(
|
|
157
|
+
`[scheduler] imported ${tasks.length} legacy tasks (session not flushed; deferring ${importedPath} removal)`,
|
|
158
|
+
)
|
|
159
|
+
return () => {
|
|
160
|
+
// 幂等:已删(本进程或并发另一进程已处理)→ no-op。cleanup 在每次 turn_end 都会调用,重复调用安全
|
|
161
|
+
if (!fs.existsSync(importedPath)) return
|
|
162
|
+
if (fs.existsSync(currentSessionFile)) {
|
|
163
|
+
// flush 已发生(sessionFile 由首次 flush 创建,fileEntries 全量落盘)→ 安全删除
|
|
164
|
+
try {
|
|
165
|
+
fs.unlinkSync(importedPath)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// MF-2:并发——另一进程(resumed B)已 unlink → ENOENT 静默;其他 fs 错误(EACCES)不吞
|
|
168
|
+
if (!isENOENT(err)) throw err
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// 从未 flush:保留 .imported,下次 session_start 崩溃恢复重导入(不 unlink;不告警——
|
|
172
|
+
// 每次 turn_end 都会走到这里,避免刷屏;保留依据见上方 IMPORT-FLUSH-GUARD 注释)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 残留恢复:rename 抛 ENOENT(别人已把 scheduler.json rename 走)后处理 .imported 残留。
|
|
178
|
+
*
|
|
179
|
+
* 并发 + 崩溃恢复交叉窗口(R-CONCURRENT-IMPORT):若 .imported 存在,可能是
|
|
180
|
+
* a) 本进程上次崩溃留下的 .imported(崩溃恢复)→ 导入正确
|
|
181
|
+
* b) 另一进程 winner rename 后、unlinkSync 前的 .imported(rename 竞态)→ 本进程也会导入
|
|
182
|
+
* c) 另一进程新 session 延迟删除窗口内的 .imported(IMPORT-FLUSH-GUARD:session_start →
|
|
183
|
+
* 首个 turn_end 之间)→ 本进程也会导入
|
|
184
|
+
* 窗口:b 为 rename→unlinkSync 毫秒级(需两进程同时 session_start 同 cwd,罕见);c 为秒级
|
|
185
|
+
* (A 存活且首 turn 未完成时 B 启动)。c 由 turn_end 触发的清理收敛:A 首个 turn 完成后
|
|
186
|
+
* .imported 删除,此后 B 启动 → 双不存在 → skip。
|
|
187
|
+
*
|
|
188
|
+
* ⚠️ 双导入后果(如实记录,非「已被消除」):情况 b/c 下 A/B 各自导入一份副本、owner 各为自己,
|
|
189
|
+
* 同一个逻辑任务会在两个 session 各触发一次(跨 session 双触发,正是 G5 要防的)。owner 过滤
|
|
190
|
+
* (按 ownerSessionFile)只保证「单一 session 内不重复」,并不能消除此跨 session 双触发——
|
|
191
|
+
* owner 过滤针对的是 fork 继承的「owner=他者」任务,而此处两副本的 owner 各自匹配本 session。
|
|
192
|
+
* 窗口(毫秒级竞态 + 秒级延迟删除窗口)远小于「.imported 全程驻留」,可接受,不引入锁机制(过度工程)。
|
|
193
|
+
*/
|
|
194
|
+
function handleImportedResidue(
|
|
195
|
+
importedPath: string,
|
|
196
|
+
pi: Pick<ExtensionAPI, 'appendEntry'>,
|
|
197
|
+
currentSessionFile: string,
|
|
198
|
+
): (() => void) | undefined {
|
|
199
|
+
if (fs.existsSync(importedPath)) {
|
|
200
|
+
return importFromFile(importedPath, pi, currentSessionFile)
|
|
201
|
+
}
|
|
202
|
+
// else: 双不存在(TC3),no-op
|
|
203
|
+
return undefined
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 导入旧 scheduler store 到当前 session(append-only event sourcing 迁移,IF-IMPORT-LEGACY)。
|
|
208
|
+
*
|
|
209
|
+
* 策略:原子 rename scheduler.json → scheduler.json.imported 独占迁移;成功者读取 .imported
|
|
210
|
+
* 逐任务 pi.appendEntry('pi-scheduler:task', upsert) 后删除 .imported;rename 抛 ENOENT
|
|
211
|
+
* 说明并发场景下别人已 rename 走,走 handleImportedResidue 幂等恢复。
|
|
212
|
+
*
|
|
213
|
+
* 时序(CL3 方案A):必须在 backend.loadTasks() 之前执行——append 的 upsert entry 进入 pi
|
|
214
|
+
* 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务(pi _appendEntry 同步 push
|
|
215
|
+
* fileEntries,design-review 已实测验证)。
|
|
216
|
+
*
|
|
217
|
+
* 整体降级(C1):read/parse/appendEntry 任一异常 → console.warn + 不 rethrow,不让
|
|
218
|
+
* session_start 崩溃(与 replay gap4 / ER-APPEND-FAIL 同款降级语义)。append 中途失败时
|
|
219
|
+
* .imported 保留(不 unlink)——下次 session 的 handleImportedResidue 会重导入全部任务,
|
|
220
|
+
* 已成功 append 的子集可能跨 session 双触发;取舍:删除则失败任务永久丢失(更糟),
|
|
221
|
+
* 保留则与 R-CONCURRENT-IMPORT / IMPORT-FLUSH-GUARD 同属 at-least-once 已接受窗口(SG-1)。
|
|
222
|
+
*
|
|
223
|
+
* nextRunAt 原样保留不重算、不 gc 过滤(CL4):导入后首个 tick 立即 dispatch(D3 立即触发语义)。
|
|
224
|
+
*
|
|
225
|
+
* 返回值:延迟删除 cleanup(新 session 未 flush 时保留 .imported 待首个 turn_end /
|
|
226
|
+
* session_shutdown 确认 flush 后删除;cleanup 幂等,可重复调用),已安全处理时为 undefined。
|
|
227
|
+
* index.ts 在 turn_end 与 session_shutdown 调用它。
|
|
228
|
+
*/
|
|
229
|
+
export function importLegacyStore(
|
|
230
|
+
cwd: string,
|
|
231
|
+
pi: Pick<ExtensionAPI, 'appendEntry'>,
|
|
232
|
+
currentSessionFile: string | undefined,
|
|
233
|
+
): (() => void) | undefined {
|
|
234
|
+
// TC5:--no-session 模式无 owner session 可归属,导入无意义,早 return 不碰 fs
|
|
235
|
+
if (currentSessionFile === undefined) return undefined
|
|
236
|
+
|
|
237
|
+
const storePath = getLegacyStorePath(cwd)
|
|
238
|
+
const importedPath = storePath + '.imported'
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
try {
|
|
242
|
+
fs.renameSync(storePath, importedPath)
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (isENOENT(err)) {
|
|
245
|
+
// 并发 S10 / 崩溃恢复:scheduler.json 已不在(被别人 rename 走或上次崩溃)→ 残留恢复
|
|
246
|
+
return handleImportedResidue(importedPath, pi, currentSessionFile)
|
|
247
|
+
}
|
|
248
|
+
// 其他 fs 错误走整体降级 warn
|
|
249
|
+
throw err
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 成功 rename → 导入 .imported
|
|
253
|
+
return importFromFile(importedPath, pi, currentSessionFile)
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// C1 整体降级:read/parse/appendEntry 任一异常不崩 session_start
|
|
256
|
+
console.warn(
|
|
257
|
+
`[scheduler] import failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
258
|
+
)
|
|
259
|
+
return undefined
|
|
260
|
+
}
|
|
261
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
|
|
3
|
+
import { PiSchedulerBackend } from './backend.js'
|
|
3
4
|
import { registerScheduleCommand } from './commands.js'
|
|
5
|
+
import { importLegacyStore } from './importer.js'
|
|
4
6
|
import { SchedulerRuntime } from './runtime.js'
|
|
7
|
+
import { SchedulerService } from './service.js'
|
|
5
8
|
import {
|
|
6
9
|
controlGuidelines,
|
|
7
10
|
createScheduleControlHandler,
|
|
@@ -14,54 +17,77 @@ import {
|
|
|
14
17
|
} from './tool.js'
|
|
15
18
|
import { renderSchedulerWidget } from './widget.js'
|
|
16
19
|
|
|
17
|
-
/** widget 刷新节奏:对齐 runtime 的 TICK_INTERVAL_MS(30s),保证 nextRunAt 倒计时随 tick 更新。 */
|
|
18
|
-
const WIDGET_REFRESH_MS = 30_000
|
|
19
|
-
|
|
20
20
|
/**
|
|
21
21
|
* pi-scheduler extension factory。
|
|
22
22
|
* 注册 schedule + schedule_control 两个 tool、/schedule command、session 事件。
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
* tool/command 的 execute/handler 通过
|
|
26
|
-
* 捕获 null——那时 session_start 尚未触发,
|
|
24
|
+
* service 生命周期:在 session_start 中创建(依赖 ctx),factory 顶层只声明为 null。
|
|
25
|
+
* tool/command 的 execute/handler 通过 getService() 延迟读取,避免在 factory 顶层
|
|
26
|
+
* 捕获 null——那时 session_start 尚未触发,service! 非空断言会骗过编译器但运行时是 null。
|
|
27
27
|
*/
|
|
28
28
|
export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
29
|
-
let
|
|
30
|
-
//
|
|
31
|
-
|
|
29
|
+
let service: SchedulerService | null = null
|
|
30
|
+
// IMPORT-FLUSH-GUARD(MF-1):importLegacyStore 对未 flush 的新 session 返回延迟删除 .imported
|
|
31
|
+
// 的 cleanup——turn_end / session_shutdown 时执行:确认 flush(sessionFile 已出现)则删,
|
|
32
|
+
// 未 flush 保留供崩溃恢复重导入(否则未 flush 即退出 → 全部旧任务丢失且源文件已销毁)。
|
|
33
|
+
// 触发点用 turn_end(而非仅 session_shutdown):turn_end 时该轮 message_end 已全部持久化
|
|
34
|
+
// (flush 必已发生),把跨 session 双导入窗口从「session 整个生命周期」缩回
|
|
35
|
+
// 「session_start → 首个 turn_end」秒级。cleanup 幂等(importer.ts importFromFile),重复调用安全。
|
|
36
|
+
let importCleanup: (() => void) | undefined
|
|
32
37
|
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
35
|
-
return
|
|
38
|
+
const getService = (): SchedulerService => {
|
|
39
|
+
if (!service) throw new Error('Scheduler not initialized: session not started')
|
|
40
|
+
return service
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
pi.on('session_start', (_event, ctx: ExtensionContext) => {
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
// 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
|
|
45
|
+
const backend = new PiSchedulerBackend(ctx, pi)
|
|
46
|
+
// 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
|
|
47
|
+
// append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
|
|
48
|
+
// ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
|
|
49
|
+
importCleanup = importLegacyStore(ctx.cwd, pi, ctx.sessionManager.getSessionFile())
|
|
50
|
+
const runtime = new SchedulerRuntime(backend, ctx)
|
|
51
|
+
runtime.loadTasks(backend.loadTasks())
|
|
52
|
+
// W2:tick 后回调刷新 widget(替代独立 widgetTimer + setInterval,节奏对齐 TICK_INTERVAL_MS)
|
|
53
|
+
runtime.onAfterTick(() => refreshWidget(ctx))
|
|
41
54
|
runtime.startScheduler()
|
|
55
|
+
service = new SchedulerService(runtime, () => backend.now())
|
|
42
56
|
|
|
43
|
-
// 注册 widget(SDK setWidget 第一重载:直接传 string[]
|
|
44
|
-
//
|
|
45
|
-
// 因此用一个对齐 TICK_INTERVAL_MS 的 interval 周期性重发 string[],
|
|
46
|
-
// 等价于 plan 扩展的 push 模式(plan/src/widget.ts:13)。
|
|
57
|
+
// 注册 widget(SDK setWidget 第一重载:直接传 string[])。初始渲染一次,
|
|
58
|
+
// 后续随每次 tickScheduler 末尾的 onAfterTick 回调刷新(nextRunAt 倒计时 + task 状态)。
|
|
47
59
|
refreshWidget(ctx)
|
|
48
|
-
widgetTimer = setInterval(() => refreshWidget(ctx), WIDGET_REFRESH_MS)
|
|
49
60
|
})
|
|
50
61
|
|
|
51
|
-
pi.on('
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
pi.on('turn_end', () => {
|
|
63
|
+
// IMPORT-FLUSH-GUARD(MF-1):延迟删除的主触发点——turn_end 前该轮所有 message_end 已持久化
|
|
64
|
+
// (agent-session.js _handleAgentEvent 在 message_end 处理中调 appendMessage 触发 flush),
|
|
65
|
+
// sessionFile 已出现 → cleanup 删 .imported;仍未 flush(无 assistant 消息的轮次)→ 静默保留,
|
|
66
|
+
// 下次 turn_end / session_shutdown 重试。cleanup 幂等(importer.ts importFromFile)。
|
|
67
|
+
importCleanup?.()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
pi.on('session_shutdown', async () => {
|
|
71
|
+
// append-only 模型无 persistSync(runtime 已按 op appendEntry 落盘到 owner session JSONL);
|
|
72
|
+
// widgetTimer 已移除(由 runtime.onAfterTick 替代)。仅停止 scheduler tick。
|
|
73
|
+
if (service) {
|
|
74
|
+
service.runtime.stopScheduler()
|
|
55
75
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
76
|
+
// IMPORT-FLUSH-GUARD(MF-1):兜底清理——正常路径已由首个 turn_end 完成;此处覆盖
|
|
77
|
+
// 从未产生 turn 的 session(打开未发消息即关闭)。cleanup 确认 flush(sessionFile 已出现)
|
|
78
|
+
// 则删 .imported,未 flush 保留供崩溃恢复重导入
|
|
79
|
+
try {
|
|
80
|
+
importCleanup?.()
|
|
81
|
+
} finally {
|
|
82
|
+
// MF-2:cleanup 抛非 ENOENT 错误(如 EACCES)也必须复位,避免残留闭包
|
|
83
|
+
importCleanup = undefined
|
|
59
84
|
}
|
|
60
85
|
})
|
|
61
86
|
|
|
62
87
|
// 注册 schedule tool
|
|
63
88
|
// execute 内联闭包:从 SDK 全签名 (toolCallId, params, signal, onUpdate, ctx) 提取 params 转调
|
|
64
|
-
// handler
|
|
89
|
+
// handler。catch 兜底 INTERNAL + 未初始化异常(R3:handler 不 catch getService(),
|
|
90
|
+
// 初始化异常穿透到这里,保持 'Error: Scheduler not initialized' 格式)。
|
|
65
91
|
pi.registerTool({
|
|
66
92
|
name: 'schedule',
|
|
67
93
|
label: 'Schedule',
|
|
@@ -76,7 +102,7 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
76
102
|
_ctx: ExtensionContext,
|
|
77
103
|
) {
|
|
78
104
|
try {
|
|
79
|
-
return await createScheduleHandler(
|
|
105
|
+
return await createScheduleHandler(getService())(params)
|
|
80
106
|
} catch (err) {
|
|
81
107
|
return {
|
|
82
108
|
content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
@@ -102,7 +128,7 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
102
128
|
_ctx: ExtensionContext,
|
|
103
129
|
) {
|
|
104
130
|
try {
|
|
105
|
-
return await createScheduleControlHandler(
|
|
131
|
+
return await createScheduleControlHandler(getService())(params)
|
|
106
132
|
} catch (err) {
|
|
107
133
|
return {
|
|
108
134
|
content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
@@ -113,15 +139,17 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
113
139
|
},
|
|
114
140
|
})
|
|
115
141
|
|
|
116
|
-
// 注册 /schedule command。传 getter 而非
|
|
117
|
-
registerScheduleCommand(pi, () =>
|
|
142
|
+
// 注册 /schedule command。传 getter 而非 service 实例:factory 执行时 service 还是 null。
|
|
143
|
+
registerScheduleCommand(pi, () => service)
|
|
118
144
|
|
|
119
145
|
/**
|
|
120
146
|
* 重新计算并推送 scheduler widget(string[] 重载)。
|
|
121
|
-
* 读外层
|
|
147
|
+
* 读外层 service 变量而非 getService():session_start 尚未触发时刷新不应报错,直接跳过。
|
|
122
148
|
*/
|
|
123
149
|
function refreshWidget(ctx: ExtensionContext): void {
|
|
124
|
-
if (!
|
|
125
|
-
|
|
150
|
+
if (!service) return
|
|
151
|
+
const result = service.list()
|
|
152
|
+
if (!result.success || !result.data) return
|
|
153
|
+
ctx.ui.setWidget('scheduler', renderSchedulerWidget(result.data.tasks))
|
|
126
154
|
}
|
|
127
155
|
}
|
package/src/parsing.ts
CHANGED
|
@@ -180,7 +180,6 @@ export async function parseSchedule(
|
|
|
180
180
|
if (nextRun !== undefined) {
|
|
181
181
|
return {
|
|
182
182
|
spec: { mode: 'cron', cronExpression: normalized.expression },
|
|
183
|
-
note: normalized.note,
|
|
184
183
|
}
|
|
185
184
|
}
|
|
186
185
|
}
|
|
@@ -188,6 +187,25 @@ export async function parseSchedule(
|
|
|
188
187
|
return undefined
|
|
189
188
|
}
|
|
190
189
|
|
|
190
|
+
/**
|
|
191
|
+
* 统一计算 schedule 的下次执行时间(IF-5:runtime 内唯一 nextRunAt 计算入口)。
|
|
192
|
+
* interval → from + intervalMs(from 缺省 Date.now());
|
|
193
|
+
* cron → 下次命中时间戳(> from);cron 无效或 croner 不可用 → undefined。
|
|
194
|
+
* undefined 语义按调用方决策:addTask → 创建即抛错;
|
|
195
|
+
* toggleTask/dispatchTask 重算 → ERR-2 fallback(停用 + failed)。
|
|
196
|
+
*/
|
|
197
|
+
export async function computeNextRunAt(
|
|
198
|
+
spec: ScheduleSpec,
|
|
199
|
+
from?: number,
|
|
200
|
+
): Promise<number | undefined> {
|
|
201
|
+
if (spec.mode === 'interval') {
|
|
202
|
+
const start = from ?? Date.now()
|
|
203
|
+
return start + spec.intervalMs
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return computeNextCronRunAt(spec.cronExpression, from)
|
|
207
|
+
}
|
|
208
|
+
|
|
191
209
|
// ── Next Runs 计算 ──
|
|
192
210
|
|
|
193
211
|
/**
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { ScheduledTask, SchedulerEntryOp, TaskSnapshot } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CustomEntry 的最小可识别形状(duck-typed,不依赖 @earendil-works/pi-coding-agent 的
|
|
5
|
+
* 具体 SessionEntry 类型)。与 pending-notifications/state.ts、goal/ports.ts 同款:
|
|
6
|
+
* 纯函数可独立单测,调用方传入真实 SessionEntry[] 时按结构兼容。
|
|
7
|
+
*/
|
|
8
|
+
export interface SchedulerEntryLike {
|
|
9
|
+
type: string
|
|
10
|
+
customType?: string
|
|
11
|
+
data?: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const HISTORY_LIMIT = 20
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 从 CustomEntry 序列折叠恢复 per-task 末态(event sourcing)。
|
|
18
|
+
*
|
|
19
|
+
* session_start 重放入口:PiSchedulerBackend.loadTasks() 委托本函数,把当前 session 的
|
|
20
|
+
* pi-scheduler:task custom entries 折叠成运行时任务 Map。
|
|
21
|
+
*
|
|
22
|
+
* 两步:
|
|
23
|
+
* ① 全量折叠——遍历 customType==='pi-scheduler:task' 的 entries,按 op 类型折叠:
|
|
24
|
+
* - upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)
|
|
25
|
+
* - advance → nextRunAt=新值 / lastRunAt=at / runCount++ / history.push(裁20) / lastStatus=status(gap2)
|
|
26
|
+
* - toggle → enabled=新值
|
|
27
|
+
* - delete → 从 Map 移除(后续同 taskId 的 advance/toggle 对不存在 taskId 为 no-op,安全)
|
|
28
|
+
* advance/toggle/delete 对不存在的 taskId 为 no-op,因此 fork 继承的完整序列必须先全量折叠,
|
|
29
|
+
* 不能在处理 upsert 时按 owner 跳过(否则后续无 owner 字段的 op 会作用于「不存在的 taskId」丢失)。
|
|
30
|
+
* ② 折叠完成后按 task.ownerSessionFile !== currentSessionFile 整体过滤(gap1):移除 fork
|
|
31
|
+
* 继承的非 owner 任务——fork 出的 session B 继承了 owner=A 的 upsert+advance 序列,但 B
|
|
32
|
+
* 不应拥有 A 的任务,重放后整体过滤掉。
|
|
33
|
+
*
|
|
34
|
+
* append-only 时序保证 nextRunAt 不回退(D1):advance entry 按写入顺序折叠,自然取到最后推进值。
|
|
35
|
+
*
|
|
36
|
+
* getEntries 解析/迭代异常 → console.warn + 返回空 Map(gap4):降级为无任务而非崩溃 session_start。
|
|
37
|
+
*/
|
|
38
|
+
export function replayFoldEntries(
|
|
39
|
+
entries: Iterable<SchedulerEntryLike>,
|
|
40
|
+
currentSessionFile: string | undefined,
|
|
41
|
+
): Map<string, ScheduledTask> {
|
|
42
|
+
try {
|
|
43
|
+
const tasks = new Map<string, ScheduledTask>()
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (entry.type !== 'custom' || entry.customType !== 'pi-scheduler:task') continue
|
|
47
|
+
if (!isSchedulerEntryOp(entry.data)) continue
|
|
48
|
+
const op = entry.data // SchedulerEntryOp(守卫 isSchedulerEntryOp 收窄)
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
switch (op.op) {
|
|
52
|
+
case 'upsert': {
|
|
53
|
+
tasks.set(op.taskId, { ...snapshotToTask(op.task), ownerSessionFile: op.ownerSessionFile })
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
case 'advance': {
|
|
57
|
+
const task = tasks.get(op.taskId)
|
|
58
|
+
if (!task) break // no-op for unknown taskId(fork 场景安全)
|
|
59
|
+
task.nextRunAt = op.nextRunAt
|
|
60
|
+
task.lastRunAt = op.at
|
|
61
|
+
task.runCount += 1
|
|
62
|
+
task.history.push({ at: op.at, status: op.status })
|
|
63
|
+
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
64
|
+
task.lastStatus = op.status // gap2:advance 必须恢复 lastStatus(不只 nextRunAt/lastRunAt/runCount/history)
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
case 'toggle': {
|
|
68
|
+
const task = tasks.get(op.taskId)
|
|
69
|
+
if (!task) break
|
|
70
|
+
task.enabled = op.enabled
|
|
71
|
+
// P1:enable 重算到未来的 nextRunAt 随 toggle op 持久化;重放时应用,
|
|
72
|
+
// 防 upsert 快照回退到旧过期值导致首个 tick 立即触发
|
|
73
|
+
if (op.nextRunAt !== undefined) task.nextRunAt = op.nextRunAt
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
case 'delete': {
|
|
77
|
+
tasks.delete(op.taskId)
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
|
|
83
|
+
// snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
|
|
84
|
+
// 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
|
|
85
|
+
console.warn(
|
|
86
|
+
`[scheduler] skipping corrupted scheduler entry: ${err instanceof Error ? err.message : String(err)}`,
|
|
87
|
+
)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 步骤② fork owner 过滤(gap1):折叠完成后整体移除非 owner 任务。
|
|
93
|
+
// currentSessionFile 为 undefined(--no-session 模式)时,所有带 ownerSessionFile 的任务都被移除
|
|
94
|
+
// (无 session 文件可归属,等同空 session)——该模式下 addTask 用 '' 兜底 owner,此处比较仍一致。
|
|
95
|
+
for (const [id, task] of tasks) {
|
|
96
|
+
if (task.ownerSessionFile !== currentSessionFile) {
|
|
97
|
+
tasks.delete(id)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return tasks
|
|
102
|
+
} catch (err) {
|
|
103
|
+
// gap4:session JSONL 损坏 / 迭代器抛错时降级为无任务,不让 session_start 崩溃。
|
|
104
|
+
console.warn(
|
|
105
|
+
`[scheduler] replayFoldEntries failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
106
|
+
)
|
|
107
|
+
return new Map()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 类型守卫:判断 entry.data 是否为合法 SchedulerEntryOp——按 op 变体校验必填字段,
|
|
113
|
+
* 不只校验 op 判别值(MF-2 修复)。仅按判别值收窄时,损坏 entry `{op:'upsert'}`(缺 task)
|
|
114
|
+
* 会通过守卫后在 snapshotToTask(undefined) 抛 TypeError,被外层整体 catch 捕获 → 返回空 Map
|
|
115
|
+
* → 一条损坏 entry 清空全部任务(违反「只忽略该条」的设计意图)。
|
|
116
|
+
*
|
|
117
|
+
* 变体必填校验:
|
|
118
|
+
* upsert → taskId: string + task: 非 null 对象(嵌套字段损坏由重放循环内逐条 try/catch 兜底跳过)
|
|
119
|
+
* advance → taskId: string + nextRunAt: number + at: number + status: 'success'
|
|
120
|
+
* toggle → taskId: string + enabled: boolean(nextRunAt?: number 可选——合法 toggle 可缺省,不能误拒)
|
|
121
|
+
* delete → taskId: string
|
|
122
|
+
* 校验不过 → 返回 false,该条被跳过(不触发整体 catch)。
|
|
123
|
+
*
|
|
124
|
+
* 替代 `as { op?: unknown }` 结构断言(taste/no-unsafe-cast):守卫函数内做真实校验,
|
|
125
|
+
* Record<string, unknown> 配合 typeof/值校验,不是「无校验断言」。
|
|
126
|
+
*/
|
|
127
|
+
function isSchedulerEntryOp(data: unknown): data is SchedulerEntryOp {
|
|
128
|
+
if (!data || typeof data !== 'object') return false
|
|
129
|
+
const record = data as Record<string, unknown>
|
|
130
|
+
const op = record.op
|
|
131
|
+
if (op === 'upsert') {
|
|
132
|
+
return (
|
|
133
|
+
typeof record.taskId === 'string' &&
|
|
134
|
+
typeof record.task === 'object' &&
|
|
135
|
+
record.task !== null
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
if (op === 'advance') {
|
|
139
|
+
return (
|
|
140
|
+
typeof record.taskId === 'string' &&
|
|
141
|
+
typeof record.nextRunAt === 'number' &&
|
|
142
|
+
typeof record.at === 'number' &&
|
|
143
|
+
record.status === 'success'
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
if (op === 'toggle') {
|
|
147
|
+
return (
|
|
148
|
+
typeof record.taskId === 'string' &&
|
|
149
|
+
typeof record.enabled === 'boolean' &&
|
|
150
|
+
(record.nextRunAt === undefined || typeof record.nextRunAt === 'number')
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (op === 'delete') {
|
|
154
|
+
return typeof record.taskId === 'string'
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 从 TaskSnapshot 重建 ScheduledTask(剥离 pending——运行时标记不持久化)。
|
|
161
|
+
* history 深拷贝:避免快照与运行时 task 共享同一数组引用(upsert 后 task 继续被 mutate)。
|
|
162
|
+
*/
|
|
163
|
+
function snapshotToTask(snapshot: TaskSnapshot): ScheduledTask {
|
|
164
|
+
return {
|
|
165
|
+
id: snapshot.id,
|
|
166
|
+
name: snapshot.name,
|
|
167
|
+
prompt: snapshot.prompt,
|
|
168
|
+
kind: snapshot.kind,
|
|
169
|
+
schedule: snapshot.schedule,
|
|
170
|
+
enabled: snapshot.enabled,
|
|
171
|
+
force: snapshot.force,
|
|
172
|
+
createdAt: snapshot.createdAt,
|
|
173
|
+
nextRunAt: snapshot.nextRunAt,
|
|
174
|
+
expiresAt: snapshot.expiresAt,
|
|
175
|
+
runCount: snapshot.runCount,
|
|
176
|
+
lastRunAt: snapshot.lastRunAt,
|
|
177
|
+
lastStatus: snapshot.lastStatus,
|
|
178
|
+
lastError: snapshot.lastError,
|
|
179
|
+
history: snapshot.history.map(h => ({ ...h })),
|
|
180
|
+
}
|
|
181
|
+
}
|