@zhushanwen/pi-scheduler 0.5.2 → 0.7.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 +1 -1
- package/package.json +4 -8
- package/skills/scheduler-ext-config/SKILL.md +2 -2
- package/src/__tests__/U4-DISPATCH_INFLIGHT.test.ts +6 -40
- package/src/__tests__/backend.test.ts +3 -6
- package/src/__tests__/commands.test.ts +3 -2
- package/src/__tests__/cron.test.ts +5 -7
- package/src/__tests__/importer.test.ts +0 -1
- package/src/__tests__/index-generation.test.ts +7 -6
- package/src/__tests__/index-session-start.test.ts +2 -2
- package/src/__tests__/mock-backend.ts +58 -0
- package/src/__tests__/parsing.test.ts +2 -4
- package/src/__tests__/property.test.ts +1 -1
- package/src/__tests__/replay.test.ts +0 -1
- package/src/__tests__/runtime.test.ts +21 -251
- package/src/__tests__/service.test.ts +19 -33
- package/src/__tests__/tool.test.ts +16 -22
- package/src/__tests__/types.test.ts +92 -0
- package/src/__tests__/widget.test.ts +0 -1
- package/src/backend.ts +27 -87
- package/src/importer.ts +9 -49
- package/src/index.ts +10 -54
- package/src/parsing.ts +25 -45
- package/src/replay.ts +9 -34
- package/src/runtime.ts +42 -155
- package/src/service.ts +28 -37
- package/src/tool.ts +29 -35
- package/src/types.ts +70 -11
- package/src/__tests__/U4-AFTER_RUN_INTENT.test.ts +0 -99
- package/src/__tests__/U4-ONSETTLED.test.ts +0 -124
- package/src/__tests__/U4-PARK_GATE.test.ts +0 -206
package/src/replay.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
|
|
1
2
|
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
2
3
|
|
|
3
|
-
import
|
|
4
|
+
import { appendExecutionRecord, snapshotToTask, TASK_ENTRY_TYPE } from './types.js'
|
|
5
|
+
import type { ScheduledTask, SchedulerEntryOp } from './types.js'
|
|
4
6
|
|
|
5
7
|
const logger = getLogger('scheduler')
|
|
6
8
|
|
|
@@ -15,16 +17,14 @@ export interface SchedulerEntryLike {
|
|
|
15
17
|
data?: unknown
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
const HISTORY_LIMIT = 20
|
|
19
|
-
|
|
20
20
|
/**
|
|
21
21
|
* 从 CustomEntry 序列折叠恢复 per-task 末态(event sourcing)。
|
|
22
22
|
*
|
|
23
23
|
* session_start 重放入口:PiSchedulerBackend.loadTasks() 委托本函数,把当前 session 的
|
|
24
|
-
*
|
|
24
|
+
* TASK_ENTRY_TYPE custom entries 折叠成运行时任务 Map。
|
|
25
25
|
*
|
|
26
26
|
* 两步:
|
|
27
|
-
* ① 全量折叠(foldEntries)——遍历 customType===
|
|
27
|
+
* ① 全量折叠(foldEntries)——遍历 customType===TASK_ENTRY_TYPE 的 entries,按 op 类型折叠:
|
|
28
28
|
* - upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)
|
|
29
29
|
* - advance → nextRunAt=新值 / lastRunAt=at / runCount++ / history.push(裁20) / lastStatus=status(gap2)
|
|
30
30
|
* - toggle → enabled=新值
|
|
@@ -50,7 +50,7 @@ export function replayFoldEntries(
|
|
|
50
50
|
return tasks
|
|
51
51
|
} catch (err) {
|
|
52
52
|
// gap4:session JSONL 损坏 / 迭代器抛错时降级为无任务,不让 session_start 崩溃。
|
|
53
|
-
logger.warn('replayFoldEntries failed', { error:
|
|
53
|
+
logger.warn('replayFoldEntries failed', { error: toErrorMessage(err) })
|
|
54
54
|
return new Map()
|
|
55
55
|
}
|
|
56
56
|
}
|
|
@@ -58,7 +58,7 @@ export function replayFoldEntries(
|
|
|
58
58
|
/** 步骤① 全量折叠:跳过非本扩展 entry 与守卫不过的损坏 entry;单条折叠抛错只跳过该条(MF-2)。 */
|
|
59
59
|
function foldEntries(entries: Iterable<SchedulerEntryLike>, tasks: Map<string, ScheduledTask>): void {
|
|
60
60
|
for (const entry of entries) {
|
|
61
|
-
if (entry.type !== 'custom' || entry.customType !==
|
|
61
|
+
if (entry.type !== 'custom' || entry.customType !== TASK_ENTRY_TYPE) continue
|
|
62
62
|
if (!isSchedulerEntryOp(entry.data)) continue
|
|
63
63
|
const op = entry.data // SchedulerEntryOp(守卫 isSchedulerEntryOp 收窄)
|
|
64
64
|
|
|
@@ -68,7 +68,7 @@ function foldEntries(entries: Iterable<SchedulerEntryLike>, tasks: Map<string, S
|
|
|
68
68
|
// MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
|
|
69
69
|
// snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
|
|
70
70
|
// 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
|
|
71
|
-
logger.warn('skipping corrupted scheduler entry', { error:
|
|
71
|
+
logger.warn('skipping corrupted scheduler entry', { error: toErrorMessage(err) })
|
|
72
72
|
continue
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -110,8 +110,7 @@ function applyAdvance(
|
|
|
110
110
|
task.nextRunAt = op.nextRunAt
|
|
111
111
|
task.lastRunAt = op.at
|
|
112
112
|
task.runCount += 1
|
|
113
|
-
task
|
|
114
|
-
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
113
|
+
appendExecutionRecord(task, op.at, op.status)
|
|
115
114
|
task.lastStatus = op.status // gap2:advance 必须恢复 lastStatus(不只 nextRunAt/lastRunAt/runCount/history)
|
|
116
115
|
}
|
|
117
116
|
|
|
@@ -193,27 +192,3 @@ function isSchedulerEntryOp(data: unknown): data is SchedulerEntryOp {
|
|
|
193
192
|
}
|
|
194
193
|
return false
|
|
195
194
|
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* 从 TaskSnapshot 重建 ScheduledTask(剥离 pending——运行时标记不持久化)。
|
|
199
|
-
* history 深拷贝:避免快照与运行时 task 共享同一数组引用(upsert 后 task 继续被 mutate)。
|
|
200
|
-
*/
|
|
201
|
-
function snapshotToTask(snapshot: TaskSnapshot): ScheduledTask {
|
|
202
|
-
return {
|
|
203
|
-
id: snapshot.id,
|
|
204
|
-
name: snapshot.name,
|
|
205
|
-
prompt: snapshot.prompt,
|
|
206
|
-
kind: snapshot.kind,
|
|
207
|
-
schedule: snapshot.schedule,
|
|
208
|
-
enabled: snapshot.enabled,
|
|
209
|
-
force: snapshot.force,
|
|
210
|
-
createdAt: snapshot.createdAt,
|
|
211
|
-
nextRunAt: snapshot.nextRunAt,
|
|
212
|
-
expiresAt: snapshot.expiresAt,
|
|
213
|
-
runCount: snapshot.runCount,
|
|
214
|
-
lastRunAt: snapshot.lastRunAt,
|
|
215
|
-
lastStatus: snapshot.lastStatus,
|
|
216
|
-
lastError: snapshot.lastError,
|
|
217
|
-
history: snapshot.history.map(h => ({ ...h })),
|
|
218
|
-
}
|
|
219
|
-
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,31 +1,25 @@
|
|
|
1
|
-
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
1
|
import { guardStaleCtx, toErrorMessage } from '@zhushanwen/pi-ext-guards'
|
|
3
2
|
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
4
3
|
|
|
5
|
-
import type { DeliveryHandle, DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
6
|
-
|
|
7
4
|
import type { SchedulerBackend } from './backend.js'
|
|
8
5
|
import { autoName, generateTaskId } from './format.js'
|
|
9
6
|
import { computeNextRunAt, MS_PER_DAY, MS_PER_MINUTE, parseDuration } from './parsing.js'
|
|
7
|
+
import { appendExecutionRecord, toTaskSnapshot } from './types.js'
|
|
10
8
|
import type {
|
|
11
9
|
AddOptions,
|
|
12
10
|
ScheduledTask,
|
|
13
11
|
SchedulerEntryOp,
|
|
14
12
|
ScheduleSpec,
|
|
15
|
-
TaskSnapshot,
|
|
16
13
|
} from './types.js'
|
|
17
14
|
|
|
18
15
|
const logger = getLogger('scheduler')
|
|
19
16
|
|
|
20
17
|
const MAX_TASKS = 50
|
|
21
|
-
// 入队防重标记 TTL 分钟数(合批非首条任务无终态回调,过期后放行重投;10 min >> 合批窗口)
|
|
22
|
-
const QUEUE_DEDUPE_TTL_MINUTES = 10
|
|
23
|
-
const QUEUE_DEDUPE_TTL_MS = QUEUE_DEDUPE_TTL_MINUTES * MS_PER_MINUTE
|
|
24
18
|
const RATE_LIMIT_PER_MINUTE = 6
|
|
25
19
|
const TICK_INTERVAL_MS = 30_000
|
|
26
20
|
const DEFAULT_EXPIRY_DAYS = 7
|
|
27
21
|
const DEFAULT_EXPIRY_MS = DEFAULT_EXPIRY_DAYS * MS_PER_DAY // 7 days
|
|
28
|
-
|
|
22
|
+
// HISTORY_LIMIT 单点在 types.ts(ext-simplify-08 L5)——与 replay.ts 的 advance 折叠共用
|
|
29
23
|
// STALE_CTX_MARKER(文案兜底分诊词)已迁移到 ext-guards 共享守卫(guardStaleCtx 内部
|
|
30
24
|
// 引用,本文件不再直接持有)。语义:G1 模块级代际检测(isCtxStale)为主判;文案子串
|
|
31
25
|
// 覆盖代际盲区——显式 reload / cwd 变化触发 clearExtensionCache 后 jiti 重新 import 产生
|
|
@@ -43,15 +37,6 @@ export class SchedulerRuntime {
|
|
|
43
37
|
private readonly isCtxStale: (() => boolean) | undefined
|
|
44
38
|
// R3-S1:同任务 dispatch 在途标记(Set<taskId>),见 dispatchTask 注释
|
|
45
39
|
private readonly dispatchesInFlight = new Set<string>()
|
|
46
|
-
// 入队防重标记(Map<taskId, enqueuedAt>):非 force 任务 send 进 delivery 内核后、
|
|
47
|
-
// 终态回调(handleSettled)前,nextRunAt 未推进——tick step2 会按 `now >= nextRunAt`
|
|
48
|
-
// 重新置 pending,若无此标记,agent busy 的每个 tick 都会再压一份同 prompt 副本进队列
|
|
49
|
-
// (合批后重复注入)。入队置位、delivered/rejected 清除;任务删除(delete/过期)同步清除。
|
|
50
|
-
// TTL 兜底:合批投递时 onSettled 只带首条 dedupeKey,非首条任务收不到终态回调——
|
|
51
|
-
// 标记过期后允许重投,保持 at-least-once(与旧「nextRunAt 未推进下 tick 重投」等价)。
|
|
52
|
-
private readonly queuedInDeliveryAt = new Map<string, number>()
|
|
53
|
-
// delivery handle(装配点注入;非 force 任务走内核队列)
|
|
54
|
-
private delivery: DeliveryHandle | undefined
|
|
55
40
|
|
|
56
41
|
/**
|
|
57
42
|
* 依赖反转构造:backend 承担 appendEntry/pi.sendMessage/时间源,runtime 只持有内存态。
|
|
@@ -61,17 +46,9 @@ export class SchedulerRuntime {
|
|
|
61
46
|
* 替换。index.ts 装配点注入(模块级代数比对,R3-M1),使 stale 分诊不依赖 pi 错误文案;
|
|
62
47
|
* 缺省(不注入)恒视为非 stale——纯 runtime 单测与旧装配路径行为不变。
|
|
63
48
|
*/
|
|
64
|
-
constructor(
|
|
65
|
-
backend: SchedulerBackend,
|
|
66
|
-
ctx?: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
|
|
67
|
-
isCtxStale?: () => boolean,
|
|
68
|
-
) {
|
|
49
|
+
constructor(backend: SchedulerBackend, isCtxStale?: () => boolean) {
|
|
69
50
|
this.backend = backend
|
|
70
|
-
// ctx 不再存实例变量(gate 已交内核);isCtxStale 保留用于代际检测
|
|
71
|
-
void ctx
|
|
72
51
|
this.isCtxStale = isCtxStale
|
|
73
|
-
// 从 backend 获取 delivery handle(装配点注入)
|
|
74
|
-
this.delivery = backend.getDeliveryHandle?.()
|
|
75
52
|
}
|
|
76
53
|
|
|
77
54
|
// ── 任务 CRUD ──
|
|
@@ -94,8 +71,8 @@ export class SchedulerRuntime {
|
|
|
94
71
|
expiresAt = now + expiryMs
|
|
95
72
|
}
|
|
96
73
|
|
|
97
|
-
// 统一 nextRunAt 计算:interval → now + intervalMs;cron →
|
|
98
|
-
const nextRunAt =
|
|
74
|
+
// 统一 nextRunAt 计算:interval → now + intervalMs;cron → 下次命中(D2 后同步)
|
|
75
|
+
const nextRunAt = computeNextRunAt(schedule, now)
|
|
99
76
|
if (nextRunAt === undefined) {
|
|
100
77
|
// 创建时校验失败报错给用户(仅 cron 可能 undefined,interval 恒有值)
|
|
101
78
|
const expr = schedule.mode === 'cron' ? schedule.cronExpression : '<unknown>'
|
|
@@ -109,7 +86,6 @@ export class SchedulerRuntime {
|
|
|
109
86
|
kind,
|
|
110
87
|
schedule,
|
|
111
88
|
enabled: true,
|
|
112
|
-
force: options.force ?? false,
|
|
113
89
|
createdAt: now,
|
|
114
90
|
nextRunAt,
|
|
115
91
|
expiresAt,
|
|
@@ -124,7 +100,7 @@ export class SchedulerRuntime {
|
|
|
124
100
|
taskId: id,
|
|
125
101
|
// getSessionFile() 在 --no-session 模式返回 undefined → '' 兜底(该模式 appendEntry 无 owner 不落盘)
|
|
126
102
|
ownerSessionFile: this.backend.getSessionFile() ?? '',
|
|
127
|
-
task:
|
|
103
|
+
task: toTaskSnapshot(task),
|
|
128
104
|
})
|
|
129
105
|
return task
|
|
130
106
|
}
|
|
@@ -146,14 +122,9 @@ export class SchedulerRuntime {
|
|
|
146
122
|
let recalcedNext: number | undefined
|
|
147
123
|
// enable 时若 nextRunAt 已过期,重算,避免 enable 瞬间立即触发
|
|
148
124
|
if (enabled && task.nextRunAt < this.backend.now()) {
|
|
149
|
-
const next =
|
|
125
|
+
const next = computeNextRunAt(task.schedule, this.backend.now())
|
|
150
126
|
if (next === undefined) {
|
|
151
|
-
|
|
152
|
-
// 禁止 `?? now()` 类 fallback(会使 nextRunAt=now,下个 tick 立即重算 → 死循环)
|
|
153
|
-
task.enabled = false
|
|
154
|
-
task.lastStatus = 'failed'
|
|
155
|
-
task.lastError = 'cron expression invalid'
|
|
156
|
-
// nextRunAt 保留原值(enabled=false 后 tick 不再触发)
|
|
127
|
+
this.disableForInvalidCron(task)
|
|
157
128
|
} else {
|
|
158
129
|
task.nextRunAt = next
|
|
159
130
|
recalcedNext = next
|
|
@@ -179,7 +150,6 @@ export class SchedulerRuntime {
|
|
|
179
150
|
|
|
180
151
|
deleteTask(id: string): boolean {
|
|
181
152
|
const deleted = this.tasks.delete(id)
|
|
182
|
-
if (deleted) this.queuedInDeliveryAt.delete(id)
|
|
183
153
|
if (deleted) {
|
|
184
154
|
this.appendEntrySafe({ op: 'delete', taskId: id })
|
|
185
155
|
}
|
|
@@ -259,7 +229,6 @@ export class SchedulerRuntime {
|
|
|
259
229
|
for (const [id, task] of this.tasks) {
|
|
260
230
|
if (task.expiresAt && now >= task.expiresAt) {
|
|
261
231
|
this.tasks.delete(id)
|
|
262
|
-
this.queuedInDeliveryAt.delete(id)
|
|
263
232
|
this.appendEntrySafe({ op: 'delete', taskId: id })
|
|
264
233
|
}
|
|
265
234
|
}
|
|
@@ -285,24 +254,20 @@ export class SchedulerRuntime {
|
|
|
285
254
|
|
|
286
255
|
// W2:tick 完成后刷新 widget(index.ts 注册 refreshWidget)
|
|
287
256
|
this.onAfterTickCallback?.()
|
|
288
|
-
|
|
289
|
-
// delivery flush:park 模式下内核不主动重试,由 scheduler tick 外部触发 flush
|
|
290
|
-
// 让积压在队列中的消息在每个 tick 尝试投递
|
|
291
|
-
this.delivery?.flush()
|
|
292
257
|
}
|
|
293
258
|
|
|
294
259
|
// ── dispatch ──
|
|
295
260
|
|
|
296
261
|
/**
|
|
297
|
-
* dispatch 单个任务。返回 true
|
|
298
|
-
* (task disabled / 已有同任务在途 / rate-limited
|
|
262
|
+
* dispatch 单个任务。返回 true 表示消息已发出(pi.sendMessage 受理),false 表示
|
|
263
|
+
* no-op(task disabled / 已有同任务在途 / rate-limited)。
|
|
299
264
|
*
|
|
300
265
|
* R3-S1 in-flight 守卫:tick 为 fire-and-forget,若 tick1 的 `await backend.sendMessage`
|
|
301
266
|
* 挂起超过 TICK_INTERVAL_MS(如 pi 卡死),tick2 的 step2 会再标 pending、step3 对同一
|
|
302
|
-
* task 并发第二个 dispatch → 同一 prompt
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
267
|
+
* task 并发第二个 dispatch → 同一 prompt 双注入。参照 subagent-workflow resumesInFlight
|
|
268
|
+
* 模式:入口同步置位、finally 清除(覆盖 gate / rate-limit / sendMessage 抛错 / 成功推进
|
|
269
|
+
* 全部退出路径);命中时 skip 本轮并 warn(不 throw——tick 继续处理其他任务,本任务
|
|
270
|
+
* pending 保留到下轮重试)。
|
|
306
271
|
*/
|
|
307
272
|
async dispatchTask(task: ScheduledTask): Promise<boolean> {
|
|
308
273
|
if (!task.enabled) return false
|
|
@@ -321,7 +286,13 @@ export class SchedulerRuntime {
|
|
|
321
286
|
/**
|
|
322
287
|
* dispatch 本体(dispatchTask 守卫置位后执行;runTaskNow 与 tick step3 共用入口,
|
|
323
288
|
* 手动 run-now 与挂起中的 tick dispatch 并发时同样被守卫拦截)。
|
|
324
|
-
*
|
|
289
|
+
* steer 直投(scheduler-steer-direct-dispatch 设计):{deliverAs:'steer', triggerTurn:true}
|
|
290
|
+
* 在 pi 侧的两分支——busy 时 steer 插入当前 turn(立即被模型看到)、idle 时开新 turn。
|
|
291
|
+
* 受理即记账:pi extension API sendMessage 是 fire-and-forget(返回 void,错误走
|
|
292
|
+
* pi 内部 emitError 通道),await 立即通过,无「入队未终态」窗口——nextRunAt 调用即推进,
|
|
293
|
+
* tick 不重标 pending,无需防重标记。
|
|
294
|
+
* sendMessage 抛错(同步异常,session 关闭等)时记录 failed 状态但不 rethrow,
|
|
295
|
+
* 让 tick 继续处理其他任务。
|
|
325
296
|
*
|
|
326
297
|
* 持久化(append-only):recurring 成功推进 nextRunAt → append advance(status='success' CL8);
|
|
327
298
|
* once 成功 → append delete。失败 dispatch 不 append(CL7 重试语义,transient 失败 nextRunAt 未推进)。
|
|
@@ -330,95 +301,39 @@ export class SchedulerRuntime {
|
|
|
330
301
|
// 检查速率限制
|
|
331
302
|
if (!this.hasDispatchCapacity(this.backend.now())) return false
|
|
332
303
|
|
|
333
|
-
if (task.force || !this.delivery) {
|
|
334
|
-
// force 任务或无 delivery handle 时直投(绕过内核队列)
|
|
335
|
-
return this.dispatchDirect(task)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// 已在内核队列中(入队后未终态且未过 TTL)——step2 会按未推进的 nextRunAt 重新置
|
|
339
|
-
// pending,此处拦截防重复入队(见 queuedInDeliveryAt 字段注释)
|
|
340
|
-
const queuedAt = this.queuedInDeliveryAt.get(task.id)
|
|
341
|
-
if (queuedAt !== undefined && this.backend.now() - queuedAt < QUEUE_DEDUPE_TTL_MS) return false
|
|
342
|
-
|
|
343
|
-
// 非 force 任务走 delivery 内核(park 模式:busy 入队等下次 tick flush)
|
|
344
|
-
return this.dispatchViaDelivery(task)
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* force 任务直投:绕过 delivery 内核队列,直接调 backend.sendMessage。
|
|
349
|
-
* 无 delivery handle 时也走此路径(向后兼容)。
|
|
350
|
-
*/
|
|
351
|
-
private async dispatchDirect(task: ScheduledTask): Promise<boolean> {
|
|
352
304
|
try {
|
|
353
305
|
await this.backend.sendMessage(
|
|
354
306
|
{ content: task.prompt, customType: 'pi-scheduler:dispatched', display: true },
|
|
355
|
-
{ deliverAs: '
|
|
307
|
+
{ deliverAs: 'steer', triggerTurn: true },
|
|
356
308
|
)
|
|
357
309
|
} catch {
|
|
358
310
|
task.lastStatus = 'failed'
|
|
359
311
|
task.pending = false
|
|
360
|
-
task
|
|
361
|
-
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
312
|
+
appendExecutionRecord(task, this.backend.now(), 'failed')
|
|
362
313
|
return false
|
|
363
314
|
}
|
|
364
315
|
return this.onDispatchSuccess(task)
|
|
365
316
|
}
|
|
366
317
|
|
|
367
318
|
/**
|
|
368
|
-
*
|
|
369
|
-
* gate(isIdle/hasPendingMessages)由内核管理,busy 时入队不重试(park 模式)。
|
|
370
|
-
* onSettled 回调处理成功/失败记账。
|
|
371
|
-
* 返回 true 表示已入队(非实际发送)。
|
|
372
|
-
*/
|
|
373
|
-
private dispatchViaDelivery(task: ScheduledTask): boolean {
|
|
374
|
-
const delivery = this.delivery!
|
|
375
|
-
|
|
376
|
-
delivery.send({
|
|
377
|
-
payload: {
|
|
378
|
-
kind: 'custom',
|
|
379
|
-
customType: 'pi-scheduler:dispatched',
|
|
380
|
-
content: task.prompt,
|
|
381
|
-
display: true,
|
|
382
|
-
},
|
|
383
|
-
intent: 'after-run',
|
|
384
|
-
// #11:task.id 作为 onSettled 反查键(本 handle 未开 dedupe,dedupeKey 不驱动
|
|
385
|
-
// 去重,仅随消息透传给 onSettled 回调)。content 反查在同 prompt 多任务下错配。
|
|
386
|
-
dedupeKey: task.id,
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
// send() 不 throw(park 模式下入队即返回)。
|
|
390
|
-
// 入队即挂防重标记 + 计入速率限制(nextRunAt 要等 delivered 后才推进,期间 step2
|
|
391
|
-
// 会持续重标 pending——防重标记拦截重复入队,速率记账覆盖入队侧消耗)。
|
|
392
|
-
// 成功/失败记账由 onSettled 回调异步处理。
|
|
393
|
-
this.queuedInDeliveryAt.set(task.id, this.backend.now())
|
|
394
|
-
this.dispatchTimestamps.push(this.backend.now())
|
|
395
|
-
task.pending = false
|
|
396
|
-
return true
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* dispatch 成功后的状态更新与持久化(dispatchDirect 成功后、onSettled delivered 后共用)。
|
|
401
|
-
* countRate=false 时不再计速率(delivery 路径入队时已计入,delivered 再计会双算)。
|
|
319
|
+
* dispatch 成功后的状态更新与持久化(dispatchTaskInner 受理成功后调用)。
|
|
402
320
|
*/
|
|
403
|
-
private async onDispatchSuccess(task: ScheduledTask
|
|
321
|
+
private async onDispatchSuccess(task: ScheduledTask): Promise<boolean> {
|
|
404
322
|
const now = this.backend.now()
|
|
405
323
|
task.runCount++
|
|
406
324
|
task.lastRunAt = now
|
|
407
325
|
task.lastStatus = 'success'
|
|
408
326
|
task.pending = false
|
|
409
327
|
task.lastError = undefined
|
|
410
|
-
task
|
|
411
|
-
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
328
|
+
appendExecutionRecord(task, now, 'success')
|
|
412
329
|
|
|
413
330
|
if (task.kind === 'once') {
|
|
414
331
|
this.tasks.delete(task.id)
|
|
415
332
|
this.appendEntrySafe({ op: 'delete', taskId: task.id })
|
|
416
333
|
} else {
|
|
417
|
-
const next =
|
|
334
|
+
const next = computeNextRunAt(task.schedule, now)
|
|
418
335
|
if (next === undefined) {
|
|
419
|
-
task
|
|
420
|
-
task.lastStatus = 'failed'
|
|
421
|
-
task.lastError = 'cron expression invalid'
|
|
336
|
+
this.disableForInvalidCron(task)
|
|
422
337
|
} else {
|
|
423
338
|
task.nextRunAt = next
|
|
424
339
|
this.appendEntrySafe({
|
|
@@ -431,47 +346,28 @@ export class SchedulerRuntime {
|
|
|
431
346
|
}
|
|
432
347
|
}
|
|
433
348
|
|
|
434
|
-
|
|
349
|
+
this.dispatchTimestamps.push(now)
|
|
435
350
|
return true
|
|
436
351
|
}
|
|
437
352
|
|
|
438
|
-
/**
|
|
439
|
-
* onSettled 回调入口(index.ts 装配点绑定):delivery 内核投递终态时调用。
|
|
440
|
-
* delivered → 成功记账(onDispatchSuccess);rejected → 失败记账。
|
|
441
|
-
* once 任务失败不删持久化(at-least-once 语义)。
|
|
442
|
-
* #11:按 msg.dedupeKey(dispatch 时挂的 task.id,见 dispatchViaDelivery)精确反查
|
|
443
|
-
* tasks Map——旧 content 反查在「同 prompt 多任务」下错配(find 取首个命中),
|
|
444
|
-
* 任务已删除时静默丢弃不误记。反查未命中(once 成功已删 / 批量合投非首条 /
|
|
445
|
-
* 非 scheduler 消息)直接返回。
|
|
446
|
-
* 已知限制:内核 busy 期间多条消息合投为一批时(doSend splice 全队列),onSettled
|
|
447
|
-
* 只收到保留首条 dedupeKey 的 composed 消息——非首条任务不记账,靠下个 tick 的
|
|
448
|
-
* nextRunAt 未推进重投(at-least-once 兜底),与旧 content 反查行为等价不劣化。
|
|
449
|
-
*/
|
|
450
|
-
handleSettled(msg: DeliveryMessage, outcome: 'delivered' | 'rejected'): void {
|
|
451
|
-
const taskId = msg.dedupeKey
|
|
452
|
-
// 防重标记先清(任务可能已被删除,反查未命中也要清)
|
|
453
|
-
if (taskId !== undefined) this.queuedInDeliveryAt.delete(taskId)
|
|
454
|
-
const task = taskId !== undefined ? this.tasks.get(taskId) : undefined
|
|
455
|
-
if (!task) return // 任务已被删除(once 成功后删)或非 scheduler 发出的消息
|
|
456
|
-
|
|
457
|
-
if (outcome === 'delivered') {
|
|
458
|
-
// fire-and-forget:onDispatchSuccess 内部 catch 不 rethrow(countRate=false:入队时已计速率)
|
|
459
|
-
void this.onDispatchSuccess(task, false).catch(() => {})
|
|
460
|
-
} else {
|
|
461
|
-
// 失败记账
|
|
462
|
-
task.lastStatus = 'failed'
|
|
463
|
-
task.history.push({ at: this.backend.now(), status: 'failed' })
|
|
464
|
-
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
465
|
-
// once 任务失败不删持久化(at-least-once 语义)
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
353
|
private hasDispatchCapacity(now: number): boolean {
|
|
470
354
|
const oneMinuteAgo = now - MS_PER_MINUTE
|
|
471
355
|
this.dispatchTimestamps = this.dispatchTimestamps.filter(t => t > oneMinuteAgo)
|
|
472
356
|
return this.dispatchTimestamps.length < RATE_LIMIT_PER_MINUTE
|
|
473
357
|
}
|
|
474
358
|
|
|
359
|
+
/**
|
|
360
|
+
* ERR-2 fallback(ext-simplify-17 B3 抽取):cron 表达式失效 → 停用任务并记录失败原因
|
|
361
|
+
* (toggle enable 重算与 dispatch 成功推进两处共用)。禁止 `?? now()` 类 fallback
|
|
362
|
+
* (会使 nextRunAt=now,下个 tick 立即重算 → 死循环);nextRunAt 保留原值——
|
|
363
|
+
* enabled=false 后 tick 不再触发。
|
|
364
|
+
*/
|
|
365
|
+
private disableForInvalidCron(task: ScheduledTask): void {
|
|
366
|
+
task.enabled = false
|
|
367
|
+
task.lastStatus = 'failed'
|
|
368
|
+
task.lastError = 'cron expression invalid'
|
|
369
|
+
}
|
|
370
|
+
|
|
475
371
|
// ── 装配与回调 ──
|
|
476
372
|
|
|
477
373
|
/** 装配点注入初始任务数组(读盘/重放由 backend 完成,runtime 只持有内存态)。 */
|
|
@@ -492,16 +388,7 @@ export class SchedulerRuntime {
|
|
|
492
388
|
} catch (err) {
|
|
493
389
|
// best-effort 降级(ER-APPEND-FAIL):append-only 模型下 append 失败仅丢失该 op 的持久化,
|
|
494
390
|
// 内存态已先行更新、不 rethrow,业务流程继续。at-least-once 已知恶化窗口(resume 重放回退)。
|
|
495
|
-
logger.warn('appendEntry failed', { error:
|
|
391
|
+
logger.warn('appendEntry failed', { error: toErrorMessage(err) })
|
|
496
392
|
}
|
|
497
393
|
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* ScheduledTask → TaskSnapshot:剥离 ownerSessionFile(在 op 顶层)与 pending(运行时标记),
|
|
501
|
-
* history 深拷贝(避免快照与运行时 task 共享数组引用)。
|
|
502
|
-
*/
|
|
503
|
-
private toSnapshot(task: ScheduledTask): TaskSnapshot {
|
|
504
|
-
const { ownerSessionFile: _o, pending: _p, history, ...rest } = task
|
|
505
|
-
return { ...rest, history: history.slice() }
|
|
506
|
-
}
|
|
507
394
|
}
|
package/src/service.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
|
|
2
|
+
|
|
1
3
|
import { formatRelativeTime, formatSchedule } from './format.js'
|
|
2
4
|
import { computeNextRuns, parseSchedule } from './parsing.js'
|
|
3
5
|
import type { SchedulerRuntime } from './runtime.js'
|
|
@@ -8,18 +10,12 @@ const PREVIEW_RUN_COUNT = 5
|
|
|
8
10
|
|
|
9
11
|
// ── 结构化结果 ──
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
| 'INVALID_SCHEDULE'
|
|
14
|
-
| 'TASK_LIMIT_REACHED'
|
|
15
|
-
| 'DISPATCH_SKIPPED'
|
|
16
|
-
| 'INVALID_PARAMS'
|
|
17
|
-
| 'INTERNAL'
|
|
13
|
+
// L1(ext-simplify-08):errorCode 枚举与字段已删——W4 后失败通道收敛为 throw,
|
|
14
|
+
// tool/command 两层均零消费方;失败语义由 message 文案承载。
|
|
18
15
|
|
|
19
16
|
export interface ServiceResult<T = unknown> {
|
|
20
17
|
success: boolean
|
|
21
18
|
message: string
|
|
22
|
-
errorCode?: ServiceErrorCode
|
|
23
19
|
data?: T
|
|
24
20
|
}
|
|
25
21
|
|
|
@@ -29,7 +25,7 @@ export interface ServiceResult<T = unknown> {
|
|
|
29
25
|
* tool 与 command 的唯一业务入口(IF-4 去双轨):
|
|
30
26
|
* 5 个动作单一实现,返回结构化 ServiceResult。
|
|
31
27
|
* - 成功: { success: true, message, data }
|
|
32
|
-
* - 失败: { success: false,
|
|
28
|
+
* - 失败: { success: false, message }
|
|
33
29
|
*
|
|
34
30
|
* message 为用户可读纯文本(tool 用作 content 文本、command 直接输出),
|
|
35
31
|
* data 供 tool details(create: {task, nextRuns};list: {tasks})。
|
|
@@ -41,39 +37,37 @@ export class SchedulerService {
|
|
|
41
37
|
* 创建任务。
|
|
42
38
|
* 注意:create 接收原始 schedule 字符串、内部 parseSchedule(而非已解析的
|
|
43
39
|
* ScheduleSpec)——这是对 IF-4 草案 create(parseResult) 的有意细化:
|
|
44
|
-
*
|
|
45
|
-
* tool/command 两层都不需要重复 parseSchedule。
|
|
40
|
+
* 解析失败需要结构化失败返回(success=false + 用户可读 message),把解析责任留在
|
|
41
|
+
* service 内,tool/command 两层都不需要重复 parseSchedule。
|
|
46
42
|
*/
|
|
47
43
|
async create(
|
|
48
44
|
prompt: string,
|
|
49
45
|
scheduleInput: string,
|
|
50
46
|
options: AddOptions = {},
|
|
51
47
|
): Promise<ServiceResult<{ task: ScheduledTask; nextRuns: number[] }>> {
|
|
52
|
-
|
|
48
|
+
// parseSchedule 同步化(D2)后返回值即 ScheduleSpec(L7:不再包装 { spec });
|
|
49
|
+
// undefined 唯一语义 = 表达式无效——croner 是 dependencies 恒在盘,不存在解析器缺失
|
|
50
|
+
const parsed = parseSchedule(scheduleInput)
|
|
53
51
|
if (!parsed) {
|
|
54
52
|
return {
|
|
55
53
|
success: false,
|
|
56
|
-
errorCode: 'INVALID_SCHEDULE',
|
|
57
54
|
message: `Invalid schedule: "${scheduleInput}". Use duration (5m/2h/1d) or cron expression (*/10 * * * *).`,
|
|
58
55
|
}
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
let task: ScheduledTask
|
|
62
59
|
try {
|
|
63
|
-
task = await this.runtime.addTask(prompt, parsed
|
|
60
|
+
task = await this.runtime.addTask(prompt, parsed, options)
|
|
64
61
|
} catch (err) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
// 意外错误兜底(正常路径不会到达:parseSchedule 已校验 cron 有效性)
|
|
70
|
-
return { success: false, errorCode: 'INTERNAL', message }
|
|
62
|
+
// 失败归一 message 通道(L1):任务上限 / 意外错误(正常路径不会到达——
|
|
63
|
+
// parseSchedule 已校验 cron 有效性)同样回传原始 message
|
|
64
|
+
return { success: false, message: toErrorMessage(err) }
|
|
71
65
|
}
|
|
72
66
|
|
|
73
67
|
const count = task.kind === 'once' ? 1 : PREVIEW_RUN_COUNT
|
|
74
68
|
// 消息内相对时间与 nextRuns 必须同基准:分开读时钟会在整点边界漂移(in 1h → in 59m)
|
|
75
69
|
const now = this.now()
|
|
76
|
-
const nextRuns =
|
|
70
|
+
const nextRuns = computeNextRuns(task.schedule, now, count)
|
|
77
71
|
// once 单行内联回显(只执行 1 次,编号列表会误导);recurring 保持 5 行编号列表
|
|
78
72
|
const runPreview =
|
|
79
73
|
task.kind === 'once'
|
|
@@ -82,14 +76,13 @@ export class SchedulerService {
|
|
|
82
76
|
'Next 5 runs:',
|
|
83
77
|
...nextRuns.map((t, i) => ` ${i + 1}. ${formatRelativeTime(t, now)}`),
|
|
84
78
|
].join('\n')
|
|
85
|
-
// 一行紧凑:name(id) + schedule(含 kind 信息) + expires
|
|
86
|
-
// 删冗余 Kind 行(formatSchedule 已含 once/every);Expires
|
|
79
|
+
// 一行紧凑:name(id) + schedule(含 kind 信息) + expires。
|
|
80
|
+
// 删冗余 Kind 行(formatSchedule 已含 once/every);Expires 合并(默认 no-expires 显式)。
|
|
87
81
|
const expiresLabel = task.expiresAt
|
|
88
82
|
? `expires ${formatRelativeTime(task.expiresAt, now)}`
|
|
89
83
|
: 'no-expires'
|
|
90
|
-
const forceLabel = task.force ? 'force' : 'no-force'
|
|
91
84
|
const message = [
|
|
92
|
-
`Task "${task.name}" (${task.id}) created. ${formatSchedule(task.schedule, task.kind)}, ${expiresLabel}
|
|
85
|
+
`Task "${task.name}" (${task.id}) created. ${formatSchedule(task.schedule, task.kind)}, ${expiresLabel}`,
|
|
93
86
|
runPreview,
|
|
94
87
|
].join('\n')
|
|
95
88
|
|
|
@@ -111,49 +104,47 @@ export class SchedulerService {
|
|
|
111
104
|
|
|
112
105
|
async toggle(id: string | undefined, enabled: boolean | undefined): Promise<ServiceResult> {
|
|
113
106
|
if (!id) {
|
|
114
|
-
return { success: false,
|
|
107
|
+
return { success: false, message: 'id is required for toggle.' }
|
|
115
108
|
}
|
|
116
109
|
if (enabled === undefined) {
|
|
117
|
-
return { success: false,
|
|
110
|
+
return { success: false, message: 'enabled is required for toggle.' }
|
|
118
111
|
}
|
|
119
112
|
const success = await this.runtime.toggleTask(id, enabled)
|
|
120
113
|
if (!success) {
|
|
121
|
-
return { success: false,
|
|
114
|
+
return { success: false, message: `Task ${id} not found.` }
|
|
122
115
|
}
|
|
123
116
|
return { success: true, message: `Task ${id} ${enabled ? 'enabled' : 'disabled'}.` }
|
|
124
117
|
}
|
|
125
118
|
|
|
126
119
|
delete(id: string | undefined): ServiceResult {
|
|
127
120
|
if (!id) {
|
|
128
|
-
return { success: false,
|
|
121
|
+
return { success: false, message: 'id is required for delete.' }
|
|
129
122
|
}
|
|
130
123
|
const success = this.runtime.deleteTask(id)
|
|
131
124
|
if (!success) {
|
|
132
|
-
return { success: false,
|
|
125
|
+
return { success: false, message: `Task ${id} not found.` }
|
|
133
126
|
}
|
|
134
127
|
return { success: true, message: `Task ${id} deleted.` }
|
|
135
128
|
}
|
|
136
129
|
|
|
137
130
|
/**
|
|
138
131
|
* 立即执行任务。语义细分:
|
|
139
|
-
* 任务不存在 →
|
|
140
|
-
* (disabled / rate-limited /
|
|
141
|
-
* busy 不再是 no-op:非 force 任务入队 delivery 内核即成功(park 等后续投递)。
|
|
132
|
+
* 任务不存在 → not found 文案;任务存在但 dispatch no-op
|
|
133
|
+
* (disabled / rate-limited / 同任务在途)→ not dispatched 文案。
|
|
142
134
|
* 修复了旧实现把 no-op 误报为 not found 的混同。
|
|
143
135
|
*/
|
|
144
136
|
async run(id: string | undefined): Promise<ServiceResult> {
|
|
145
137
|
if (!id) {
|
|
146
|
-
return { success: false,
|
|
138
|
+
return { success: false, message: 'id is required for run.' }
|
|
147
139
|
}
|
|
148
140
|
if (!this.runtime.getTask(id)) {
|
|
149
|
-
return { success: false,
|
|
141
|
+
return { success: false, message: `Task ${id} not found.` }
|
|
150
142
|
}
|
|
151
143
|
const dispatched = await this.runtime.runTaskNow(id)
|
|
152
144
|
if (!dispatched) {
|
|
153
145
|
return {
|
|
154
146
|
success: false,
|
|
155
|
-
|
|
156
|
-
message: `Task ${id} not dispatched (disabled, rate-limited, or already queued for delivery).`,
|
|
147
|
+
message: `Task ${id} not dispatched (disabled, rate-limited, or dispatch in flight).`,
|
|
157
148
|
}
|
|
158
149
|
}
|
|
159
150
|
return { success: true, message: `Task ${id} executed.` }
|