@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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { snapshotToTask, toTaskSnapshot } from '../types.js'
|
|
4
|
+
import type { ScheduledTask, TaskSnapshot } from '../types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* B2 转换器收敛(ext-simplify-17)等价守卫:toTaskSnapshot canonical 取解构式,
|
|
8
|
+
* 替换 importer 旧显式 14 字段式——期望值按旧显式式的输出形状逐字段锚定
|
|
9
|
+
* (toEqual 严格比较键集合,解构式多带/漏带键即红),防字段演进中静默漂移。
|
|
10
|
+
* 既有间接断言(importer/runtime 测试的 objectContaining)不覆盖键集合精确性与
|
|
11
|
+
* 可选字段传递,此处补直接等价证据。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 全字段 ScheduledTask(含全部可选字段 + 两个运行时字段 + 非空 history)。 */
|
|
15
|
+
function fullTask(): ScheduledTask {
|
|
16
|
+
return {
|
|
17
|
+
id: 'abcd1234',
|
|
18
|
+
name: 'test task',
|
|
19
|
+
prompt: 'do something',
|
|
20
|
+
kind: 'recurring',
|
|
21
|
+
schedule: { mode: 'interval', intervalMs: 60000 },
|
|
22
|
+
enabled: true,
|
|
23
|
+
createdAt: 1000,
|
|
24
|
+
nextRunAt: 2000,
|
|
25
|
+
expiresAt: 9999,
|
|
26
|
+
runCount: 3,
|
|
27
|
+
lastRunAt: 1500,
|
|
28
|
+
lastStatus: 'success',
|
|
29
|
+
lastError: 'prev error',
|
|
30
|
+
history: [
|
|
31
|
+
{ at: 1400, status: 'failed' },
|
|
32
|
+
{ at: 1500, status: 'success' },
|
|
33
|
+
],
|
|
34
|
+
ownerSessionFile: '/x/sess.json',
|
|
35
|
+
pending: true,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* fullTask 对应的期望快照——字面量逐字段写出(不用解构从 fullTask 推导,
|
|
41
|
+
* 避免期望值与被测实现共享同一剥离逻辑而失去独立性),形状 = 旧显式式输出。
|
|
42
|
+
*/
|
|
43
|
+
function expectedSnapshot(): TaskSnapshot {
|
|
44
|
+
return {
|
|
45
|
+
id: 'abcd1234',
|
|
46
|
+
name: 'test task',
|
|
47
|
+
prompt: 'do something',
|
|
48
|
+
kind: 'recurring',
|
|
49
|
+
schedule: { mode: 'interval', intervalMs: 60000 },
|
|
50
|
+
enabled: true,
|
|
51
|
+
createdAt: 1000,
|
|
52
|
+
nextRunAt: 2000,
|
|
53
|
+
expiresAt: 9999,
|
|
54
|
+
runCount: 3,
|
|
55
|
+
lastRunAt: 1500,
|
|
56
|
+
lastStatus: 'success',
|
|
57
|
+
lastError: 'prev error',
|
|
58
|
+
history: [
|
|
59
|
+
{ at: 1400, status: 'failed' },
|
|
60
|
+
{ at: 1500, status: 'success' },
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('toTaskSnapshot / snapshotToTask(B2 收敛等价)', () => {
|
|
66
|
+
it('toTaskSnapshot: 全字段 task → 恰好 14 字段快照(剥离 ownerSessionFile/pending,值逐一相等)', () => {
|
|
67
|
+
expect(toTaskSnapshot(fullTask())).toEqual(expectedSnapshot())
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('toTaskSnapshot: history 是数组拷贝——task 后续 push/shift 不污染已产出的快照', () => {
|
|
71
|
+
const task = fullTask()
|
|
72
|
+
const snapshot = toTaskSnapshot(task)
|
|
73
|
+
expect(snapshot.history).not.toBe(task.history)
|
|
74
|
+
task.history.push({ at: 9999, status: 'failed' })
|
|
75
|
+
task.history.shift()
|
|
76
|
+
expect(snapshot.history).toEqual([
|
|
77
|
+
{ at: 1400, status: 'failed' },
|
|
78
|
+
{ at: 1500, status: 'success' },
|
|
79
|
+
])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('snapshotToTask: 快照 → 恢复 task 不含运行时字段、字段逐一相等、history 逐项拷贝', () => {
|
|
83
|
+
const source = expectedSnapshot()
|
|
84
|
+
const task = snapshotToTask(source)
|
|
85
|
+
expect(task).toEqual(source)
|
|
86
|
+
expect('ownerSessionFile' in task).toBe(false)
|
|
87
|
+
expect('pending' in task).toBe(false)
|
|
88
|
+
// 逐项深拷贝:恢复的 task 后续 mutate 不污染快照(数组与元素均独立)
|
|
89
|
+
expect(task.history).not.toBe(source.history)
|
|
90
|
+
expect(task.history[0]).not.toBe(source.history[0])
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -10,7 +10,6 @@ const makeTask = (overrides: Partial<ScheduledTask> = {}): ScheduledTask => ({
|
|
|
10
10
|
kind: 'recurring',
|
|
11
11
|
schedule: { mode: 'interval', intervalMs: 60000 },
|
|
12
12
|
enabled: true,
|
|
13
|
-
force: false,
|
|
14
13
|
createdAt: Date.now(),
|
|
15
14
|
nextRunAt: Date.now() + 60000,
|
|
16
15
|
runCount: 0,
|
package/src/backend.ts
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
-
import type { DeliveryHandle } from '@xyz-agent/session-delivery'
|
|
3
2
|
|
|
4
3
|
import { replayFoldEntries, type SchedulerEntryLike } from './replay.js'
|
|
4
|
+
import { TASK_ENTRY_TYPE } from './types.js'
|
|
5
5
|
import type { ScheduledTask, SchedulerEntryOp } from './types.js'
|
|
6
6
|
|
|
7
7
|
// ── SchedulerBackend 接口 ──
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* sendMessage 的 msg 形状(ext-simplify-17 B5):与 pi 的 CustomMessage 鸭子对齐
|
|
11
|
+
* (customType/display 必填)——pi 根入口不导出 CustomMessage,鸭子解耦只能文件内收敛,
|
|
12
|
+
* 接口与实现两处共用(测试 mock-backend 持结构兼容的内联签名,不引用本 alias)。
|
|
13
|
+
*/
|
|
14
|
+
interface SchedulerMessage {
|
|
15
|
+
content: string
|
|
16
|
+
customType: string
|
|
17
|
+
display: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** sendMessage 的 opts 形状:steer 直投选项(scheduler-steer-direct-dispatch 投递模型)。 */
|
|
21
|
+
interface SchedulerSendOptions {
|
|
22
|
+
deliverAs?: 'steer'
|
|
23
|
+
triggerTurn?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
/**
|
|
10
27
|
* 运行时协作后端抽象(依赖反转)。SchedulerRuntime 只依赖此接口:
|
|
11
28
|
* 不触碰 session JSONL、不持有 pi。
|
|
12
29
|
*
|
|
13
30
|
* - sendMessage: 到期 dispatch 的消息注入(生产实现委托 pi.sendMessage)
|
|
14
|
-
* - appendEntry: 按 op 写
|
|
31
|
+
* - appendEntry: 按 op 写 TASK_ENTRY_TYPE custom entry(event sourcing)。
|
|
15
32
|
* 生产实现委托 pi.appendEntry(同步落盘)。失败必须被调用方 try-catch(ER-APPEND-FAIL:
|
|
16
33
|
* runtime 捕获后 logger.warn + 不 rethrow,内存态已更新,at-least-once 已知恶化窗口)
|
|
17
34
|
* - getSessionFile: 当前 session JSONL 路径(addTask 构建 upsert op 的 ownerSessionFile 用;
|
|
@@ -25,17 +42,16 @@ import type { ScheduledTask, SchedulerEntryOp } from './types.js'
|
|
|
25
42
|
*
|
|
26
43
|
* sendMessage 的 msg 签名与 pi 的 CustomMessage 对齐(customType/display 必填):
|
|
27
44
|
* 调用方必须显式提供,PiSchedulerBackend 直接透传无需兜底默认值。
|
|
45
|
+
*
|
|
46
|
+
* delivery handle 不在本接口(ext-simplify-08 L2):backend 自身不消费,曾以
|
|
47
|
+
* setDeliveryHandle/getDeliveryHandle 中转给 runtime 属穿层传参——现由装配点经
|
|
48
|
+
* SchedulerRuntime 构造器直传。
|
|
28
49
|
*/
|
|
29
50
|
export interface SchedulerBackend {
|
|
30
|
-
sendMessage(
|
|
31
|
-
msg: { content: string; customType: string; display: boolean },
|
|
32
|
-
opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean },
|
|
33
|
-
): Promise<void>
|
|
51
|
+
sendMessage(msg: SchedulerMessage, opts?: SchedulerSendOptions): Promise<void>
|
|
34
52
|
appendEntry(op: SchedulerEntryOp): void
|
|
35
53
|
getSessionFile(): string | undefined
|
|
36
54
|
now(): number
|
|
37
|
-
/** 获取 delivery handle(装配点注入;未注入时 force 路径仍用 sendMessage 直投)。 */
|
|
38
|
-
getDeliveryHandle?(): DeliveryHandle | undefined
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
/**
|
|
@@ -62,7 +78,6 @@ export interface SchedulerBackendCtx {
|
|
|
62
78
|
export class PiSchedulerBackend implements SchedulerBackend {
|
|
63
79
|
private ctx: SchedulerBackendCtx
|
|
64
80
|
private pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>
|
|
65
|
-
private deliveryHandle: DeliveryHandle | undefined
|
|
66
81
|
|
|
67
82
|
constructor(ctx: SchedulerBackendCtx, pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>) {
|
|
68
83
|
this.ctx = ctx
|
|
@@ -70,7 +85,7 @@ export class PiSchedulerBackend implements SchedulerBackend {
|
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
/**
|
|
73
|
-
* 读路径:折叠当前 session 的
|
|
88
|
+
* 读路径:折叠当前 session 的 TASK_ENTRY_TYPE custom entries 恢复任务(非接口成员,
|
|
74
89
|
* 由装配点 session_start 调用)。replayFoldEntries 内部含 fork owner 过滤与异常兜底。
|
|
75
90
|
*/
|
|
76
91
|
loadTasks(): ScheduledTask[] {
|
|
@@ -79,15 +94,12 @@ export class PiSchedulerBackend implements SchedulerBackend {
|
|
|
79
94
|
]
|
|
80
95
|
}
|
|
81
96
|
|
|
82
|
-
async sendMessage(
|
|
83
|
-
msg: { content: string; customType: string; display: boolean },
|
|
84
|
-
opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean },
|
|
85
|
-
): Promise<void> {
|
|
97
|
+
async sendMessage(msg: SchedulerMessage, opts?: SchedulerSendOptions): Promise<void> {
|
|
86
98
|
await this.pi.sendMessage(msg, opts)
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
appendEntry(op: SchedulerEntryOp): void {
|
|
90
|
-
this.pi.appendEntry(
|
|
102
|
+
this.pi.appendEntry(TASK_ENTRY_TYPE, op)
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
getSessionFile(): string | undefined {
|
|
@@ -97,76 +109,4 @@ export class PiSchedulerBackend implements SchedulerBackend {
|
|
|
97
109
|
now(): number {
|
|
98
110
|
return Date.now()
|
|
99
111
|
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* 注入 delivery handle(装配点创建后调用)。
|
|
103
|
-
* runtime 通过 getDeliveryHandle() 获取,非 force 任务走内核队列。
|
|
104
|
-
*/
|
|
105
|
-
setDeliveryHandle(handle: DeliveryHandle): void {
|
|
106
|
-
this.deliveryHandle = handle
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
getDeliveryHandle(): DeliveryHandle | undefined {
|
|
110
|
-
return this.deliveryHandle
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── 测试实现 ──
|
|
115
|
-
|
|
116
|
-
export interface SentMessage {
|
|
117
|
-
msg: { content: string; customType: string; display: boolean }
|
|
118
|
-
opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean }
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Mock 后端:零 session/FS 副作用,记录 sendMessage/appendEntry 调用,支持注入固定时间与 fake entries。
|
|
123
|
-
* 与 SchedulerBackend/PiSchedulerBackend 同文件 export(测试从 './backend.js' import)。
|
|
124
|
-
*
|
|
125
|
-
* 能力:
|
|
126
|
-
* - sentMessages: 每次 sendMessage 的 {msg, opts} 记录
|
|
127
|
-
* - appendedOps: 每次 appendEntry 收到的 SchedulerEntryOp(测 runtime 各 op 断言)
|
|
128
|
-
* - fakeEntries / fakeSessionFile: loadTasks 经 replayFoldEntries 的注入源(测 backend→replay 委托)
|
|
129
|
-
* - nowValue: now() 返回固定值,缺省 Date.now()
|
|
130
|
-
* - appendError: 注入后 appendEntry 抛该错(测 ER-APPEND-FAIL 捕获路径)
|
|
131
|
-
*/
|
|
132
|
-
export class MockSchedulerBackend implements SchedulerBackend {
|
|
133
|
-
sentMessages: SentMessage[] = []
|
|
134
|
-
appendedOps: SchedulerEntryOp[] = []
|
|
135
|
-
fakeEntries: SchedulerEntryLike[] = []
|
|
136
|
-
fakeSessionFile: string | undefined = '/test/session.json'
|
|
137
|
-
nowValue: number | undefined
|
|
138
|
-
appendError: Error | null = null
|
|
139
|
-
deliveryHandle: DeliveryHandle | undefined
|
|
140
|
-
|
|
141
|
-
async sendMessage(
|
|
142
|
-
msg: { content: string; customType: string; display: boolean },
|
|
143
|
-
opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean },
|
|
144
|
-
): Promise<void> {
|
|
145
|
-
this.sentMessages.push({ msg, opts })
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
appendEntry(op: SchedulerEntryOp): void {
|
|
149
|
-
if (this.appendError) throw this.appendError
|
|
150
|
-
this.appendedOps.push(op)
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
getSessionFile(): string | undefined {
|
|
154
|
-
return this.fakeSessionFile
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
now(): number {
|
|
158
|
-
return this.nowValue ?? Date.now()
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
getDeliveryHandle(): DeliveryHandle | undefined {
|
|
162
|
-
return this.deliveryHandle
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* 读路径(非接口成员,与 PiSchedulerBackend.loadTasks 对称):经 replayFoldEntries 折叠
|
|
167
|
-
* fakeEntries + fakeSessionFile 恢复任务。测试用它验证 backend→replay 委托(TC-W-BACKEND-REPLAY)。
|
|
168
|
-
*/
|
|
169
|
-
loadTasks(): ScheduledTask[] {
|
|
170
|
-
return [...replayFoldEntries(this.fakeEntries, this.fakeSessionFile).values()]
|
|
171
|
-
}
|
|
172
112
|
}
|
package/src/importer.ts
CHANGED
|
@@ -4,9 +4,11 @@ import * as path from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
6
6
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import { isEnoentError, toErrorMessage } from '@zhushanwen/pi-ext-guards'
|
|
7
8
|
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
8
9
|
|
|
9
|
-
import
|
|
10
|
+
import { TASK_ENTRY_TYPE, toTaskSnapshot } from './types.js'
|
|
11
|
+
import type { ScheduledTask, SchedulerEntryOp, SchedulerStore } from './types.js'
|
|
10
12
|
|
|
11
13
|
const logger = getLogger('scheduler')
|
|
12
14
|
|
|
@@ -40,7 +42,7 @@ export function getLegacyStorePath(cwd: string): string {
|
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
44
|
* 旧 store 任务字段补全(参考 store.ts load() 默认值兜底):旧数据可能缺字段,逐字段给默认值。
|
|
43
|
-
* ownerSessionFile/pending 不补全——旧数据本无此二字段,由 toTaskSnapshot
|
|
45
|
+
* ownerSessionFile/pending 不补全——旧数据本无此二字段,由 toTaskSnapshot 剥离。
|
|
44
46
|
*/
|
|
45
47
|
function normalizeLegacyTask(t: Partial<ScheduledTask>): ScheduledTask {
|
|
46
48
|
return {
|
|
@@ -53,7 +55,6 @@ function normalizeLegacyTask(t: Partial<ScheduledTask>): ScheduledTask {
|
|
|
53
55
|
nextRunAt: t.nextRunAt ?? 0,
|
|
54
56
|
runCount: t.runCount ?? 0,
|
|
55
57
|
enabled: t.enabled ?? true,
|
|
56
|
-
force: t.force ?? false,
|
|
57
58
|
history: t.history ?? [],
|
|
58
59
|
expiresAt: t.expiresAt,
|
|
59
60
|
lastRunAt: t.lastRunAt,
|
|
@@ -62,46 +63,6 @@ function normalizeLegacyTask(t: Partial<ScheduledTask>): ScheduledTask {
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
/**
|
|
66
|
-
* ScheduledTask → TaskSnapshot:显式构造 15 字段
|
|
67
|
-
* (id/name/prompt/kind/schedule/enabled/force/createdAt/nextRunAt/expiresAt?/
|
|
68
|
-
* runCount/lastRunAt?/lastStatus?/lastError?/history),剥离 ownerSessionFile(在 op 顶层)
|
|
69
|
-
* 与 pending(运行时标记),history 用 slice() 深拷贝。
|
|
70
|
-
*
|
|
71
|
-
* 显式构造而非复用 runtime.toSnapshot 的解构写法(T-C3-explicit):旧 store 数据本无
|
|
72
|
-
* ownerSessionFile/pending 运行时字段,显式构造更安全。两处构造逻辑需保持形状一致
|
|
73
|
-
* (对照 types.ts TaskSnapshot 15 字段)。
|
|
74
|
-
*/
|
|
75
|
-
function toTaskSnapshot(task: ScheduledTask): TaskSnapshot {
|
|
76
|
-
return {
|
|
77
|
-
id: task.id,
|
|
78
|
-
name: task.name,
|
|
79
|
-
prompt: task.prompt,
|
|
80
|
-
kind: task.kind,
|
|
81
|
-
schedule: task.schedule,
|
|
82
|
-
enabled: task.enabled,
|
|
83
|
-
force: task.force,
|
|
84
|
-
createdAt: task.createdAt,
|
|
85
|
-
nextRunAt: task.nextRunAt,
|
|
86
|
-
expiresAt: task.expiresAt,
|
|
87
|
-
runCount: task.runCount,
|
|
88
|
-
lastRunAt: task.lastRunAt,
|
|
89
|
-
lastStatus: task.lastStatus,
|
|
90
|
-
lastError: task.lastError,
|
|
91
|
-
history: task.history.slice(),
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* 判断 err 是否为 ENOENT(fs.renameSync 源文件不存在)。
|
|
97
|
-
* Record 守卫模式与 replay.ts isSchedulerEntryOp 同款(避免 taste/no-unsafe-cast)。
|
|
98
|
-
*/
|
|
99
|
-
function isENOENT(err: unknown): boolean {
|
|
100
|
-
if (!(err instanceof Error)) return false
|
|
101
|
-
if (!('code' in err)) return false
|
|
102
|
-
return (err as Record<'code', unknown>).code === 'ENOENT'
|
|
103
|
-
}
|
|
104
|
-
|
|
105
66
|
/**
|
|
106
67
|
* 从 .imported 路径读取旧 store JSON、逐任务 pi.appendEntry upsert、按 flush 状态删除 .imported。
|
|
107
68
|
* read/parse/append 任一异常由外层 importLegacyStore 的 try/catch 兜底(C1 整体降级)。
|
|
@@ -139,8 +100,7 @@ function importFromFile(
|
|
|
139
100
|
ownerSessionFile: currentSessionFile,
|
|
140
101
|
task: toTaskSnapshot(task),
|
|
141
102
|
}
|
|
142
|
-
|
|
143
|
-
pi.appendEntry('pi-scheduler:task', op)
|
|
103
|
+
pi.appendEntry(TASK_ENTRY_TYPE, op)
|
|
144
104
|
}
|
|
145
105
|
|
|
146
106
|
// 空 store(0 任务)无持久化依赖,直接删;resumed session(sessionFile 已存在)已即时落盘,删
|
|
@@ -171,7 +131,7 @@ function importFromFile(
|
|
|
171
131
|
fs.unlinkSync(importedPath)
|
|
172
132
|
} catch (err) {
|
|
173
133
|
// MF-2:并发——另一进程(resumed B)已 unlink → ENOENT 静默;其他 fs 错误(EACCES)不吞
|
|
174
|
-
if (!
|
|
134
|
+
if (!isEnoentError(err)) throw err
|
|
175
135
|
}
|
|
176
136
|
}
|
|
177
137
|
// 从未 flush:保留 .imported,下次 session_start 崩溃恢复重导入(不 unlink;不告警——
|
|
@@ -213,7 +173,7 @@ function handleImportedResidue(
|
|
|
213
173
|
* 导入旧 scheduler store 到当前 session(append-only event sourcing 迁移,IF-IMPORT-LEGACY)。
|
|
214
174
|
*
|
|
215
175
|
* 策略:原子 rename scheduler.json → scheduler.json.imported 独占迁移;成功者读取 .imported
|
|
216
|
-
* 逐任务 pi.appendEntry(
|
|
176
|
+
* 逐任务 pi.appendEntry(TASK_ENTRY_TYPE, upsert) 后删除 .imported;rename 抛 ENOENT
|
|
217
177
|
* 说明并发场景下别人已 rename 走,走 handleImportedResidue 幂等恢复。
|
|
218
178
|
*
|
|
219
179
|
* 时序(CL3 方案A):必须在 backend.loadTasks() 之前执行——append 的 upsert entry 进入 pi
|
|
@@ -247,7 +207,7 @@ export function importLegacyStore(
|
|
|
247
207
|
try {
|
|
248
208
|
fs.renameSync(storePath, importedPath)
|
|
249
209
|
} catch (err) {
|
|
250
|
-
if (
|
|
210
|
+
if (isEnoentError(err)) {
|
|
251
211
|
// 并发 S10 / 崩溃恢复:scheduler.json 已不在(被别人 rename 走或上次崩溃)→ 残留恢复
|
|
252
212
|
return handleImportedResidue(importedPath, pi, currentSessionFile)
|
|
253
213
|
}
|
|
@@ -259,7 +219,7 @@ export function importLegacyStore(
|
|
|
259
219
|
return importFromFile(importedPath, pi, currentSessionFile)
|
|
260
220
|
} catch (err) {
|
|
261
221
|
// C1 整体降级:read/parse/appendEntry 任一异常不崩 session_start
|
|
262
|
-
logger.warn('import failed', { error:
|
|
222
|
+
logger.warn('import failed', { error: toErrorMessage(err) })
|
|
263
223
|
return undefined
|
|
264
224
|
}
|
|
265
225
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
-
import {
|
|
2
|
+
import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
|
|
3
3
|
|
|
4
4
|
import { PiSchedulerBackend } from './backend.js'
|
|
5
5
|
import { registerScheduleCommand } from './commands.js'
|
|
@@ -8,8 +8,8 @@ import { SchedulerRuntime } from './runtime.js'
|
|
|
8
8
|
import { SchedulerService } from './service.js'
|
|
9
9
|
import {
|
|
10
10
|
controlGuidelines,
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
handleSchedule,
|
|
12
|
+
handleScheduleControl,
|
|
13
13
|
ScheduleControlParams,
|
|
14
14
|
type ScheduleControlParamsT,
|
|
15
15
|
scheduleGuidelines,
|
|
@@ -44,7 +44,6 @@ let sessionGeneration = 0
|
|
|
44
44
|
*/
|
|
45
45
|
export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
46
46
|
let service: SchedulerService | null = null
|
|
47
|
-
let deliveryHandle: DeliveryHandle | undefined
|
|
48
47
|
// IMPORT-FLUSH-GUARD(MF-1):importLegacyStore 对未 flush 的新 session 返回延迟删除 .imported
|
|
49
48
|
// 的 cleanup——turn_end / session_shutdown 时执行:确认 flush(sessionFile 已出现)则删,
|
|
50
49
|
// 未 flush 保留供崩溃恢复重导入(否则未 flush 即退出 → 全部旧任务丢失且源文件已销毁)。
|
|
@@ -68,58 +67,18 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
68
67
|
// 窗口与 session 替换交错时旧 session_shutdown 可能永远等不到(timer 泄漏源头)。stopScheduler 幂等,
|
|
69
68
|
// shutdown 已停过再停一次无副作用。
|
|
70
69
|
service?.runtime.stopScheduler()
|
|
71
|
-
// 销毁旧 delivery handle(清队列 + 清 timer + 退订 settled)
|
|
72
|
-
deliveryHandle?.dispose()
|
|
73
70
|
// 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
|
|
74
71
|
const backend = new PiSchedulerBackend(ctx, pi)
|
|
75
72
|
// 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
|
|
76
73
|
// append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
|
|
77
74
|
// ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
|
|
78
75
|
importCleanup = importLegacyStore(ctx.cwd, pi, ctx.sessionManager.getSessionFile())
|
|
79
|
-
// 创建 delivery handle(U4:scheduler 切换内核)
|
|
80
|
-
// busyPolicy: 'park'(busy 入队不重试,等 tick 外部 flush)
|
|
81
|
-
// intent: 'after-run'(保持 followUp 语义)
|
|
82
|
-
// onSettled:延迟绑定——runtime 创建后填充(闭包变量)
|
|
83
|
-
// #11:整条 msg 透传(runtime 按 msg.dedupeKey=task.id 精确反查,不再 content 匹配)
|
|
84
|
-
const settledHandlerRef: { current: ((msg: DeliveryMessage, outcome: 'delivered' | 'rejected') => void) | undefined } = { current: undefined }
|
|
85
|
-
deliveryHandle = createDelivery({
|
|
86
|
-
supportedPayloads: ['custom'],
|
|
87
|
-
isIdle: () => ctx.isIdle(),
|
|
88
|
-
hasPendingMessages: () => ctx.hasPendingMessages(),
|
|
89
|
-
subscribeSettled: (cb) => {
|
|
90
|
-
// 无退订语义(pi 0.84.1 `pi.on(...)` 全重载返回 void、无 off——实装锚点:
|
|
91
|
-
// node_modules @earendil-works/pi-coding-agent dist/core/extensions/types.d.ts
|
|
92
|
-
// `on()` 系列,0.84.1 实测);disposed 标志包装兑现退订(notifier.ts 同款)
|
|
93
|
-
let disposed = false
|
|
94
|
-
pi.on('agent_settled', () => { if (!disposed) cb() })
|
|
95
|
-
return () => { disposed = true }
|
|
96
|
-
},
|
|
97
|
-
send: (msg, intent) => {
|
|
98
|
-
const piOpts = intent === 'interrupt-at-turn-boundary'
|
|
99
|
-
? { triggerTurn: true, deliverAs: 'steer' as const }
|
|
100
|
-
: { triggerTurn: true, deliverAs: 'followUp' as const }
|
|
101
|
-
// supportedPayloads 已收窄到 'custom',payload.kind 恒为 'custom'
|
|
102
|
-
const content = msg.payload.content
|
|
103
|
-
return pi.sendMessage(
|
|
104
|
-
{ content, customType: 'pi-scheduler:dispatched', display: true },
|
|
105
|
-
piOpts,
|
|
106
|
-
)
|
|
107
|
-
},
|
|
108
|
-
}, {
|
|
109
|
-
intent: 'after-run',
|
|
110
|
-
busyPolicy: 'park',
|
|
111
|
-
onSettled: (msg, outcome) => {
|
|
112
|
-
// 委托给 runtime 的 settledHandler(延迟绑定)
|
|
113
|
-
settledHandlerRef.current?.(msg, outcome)
|
|
114
|
-
},
|
|
115
|
-
})
|
|
116
|
-
backend.setDeliveryHandle(deliveryHandle)
|
|
117
76
|
|
|
118
77
|
// G1:注入代际比对(本 runtime 建立时的代数 vs 实时代数),供 tick 前置检查与
|
|
119
78
|
// F2 catch 分诊判定 stale——不依赖 pi 错误文案。
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
79
|
+
// 投递模型(scheduler-steer-direct-dispatch):到期任务 steer 直投(backend.sendMessage),
|
|
80
|
+
// 受理即记账,不经投递内核。
|
|
81
|
+
const runtime = new SchedulerRuntime(backend, () => sessionGeneration !== myGeneration)
|
|
123
82
|
runtime.loadTasks(backend.loadTasks())
|
|
124
83
|
// W2:tick 后回调刷新 widget(替代独立 widgetTimer + setInterval,节奏对齐 TICK_INTERVAL_MS)
|
|
125
84
|
runtime.onAfterTick(() => refreshWidget(ctx))
|
|
@@ -145,9 +104,6 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
145
104
|
if (service) {
|
|
146
105
|
service.runtime.stopScheduler()
|
|
147
106
|
}
|
|
148
|
-
// 销毁 delivery handle(清队列 + 清 timer + 退订 settled)
|
|
149
|
-
deliveryHandle?.dispose()
|
|
150
|
-
deliveryHandle = undefined
|
|
151
107
|
// IMPORT-FLUSH-GUARD(MF-1):兜底清理——正常路径已由首个 turn_end 完成;此处覆盖
|
|
152
108
|
// 从未产生 turn 的 session(打开未发消息即关闭)。cleanup 确认 flush(sessionFile 已出现)
|
|
153
109
|
// 则删 .imported,未 flush 保留供崩溃恢复重导入
|
|
@@ -178,9 +134,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
178
134
|
_ctx: ExtensionContext,
|
|
179
135
|
) {
|
|
180
136
|
try {
|
|
181
|
-
return await
|
|
137
|
+
return await handleSchedule(getService(), params)
|
|
182
138
|
} catch (err) {
|
|
183
|
-
throw new Error(`Error: ${
|
|
139
|
+
throw new Error(`Error: ${toErrorMessage(err)}`)
|
|
184
140
|
}
|
|
185
141
|
},
|
|
186
142
|
})
|
|
@@ -200,9 +156,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
200
156
|
_ctx: ExtensionContext,
|
|
201
157
|
) {
|
|
202
158
|
try {
|
|
203
|
-
return await
|
|
159
|
+
return await handleScheduleControl(getService(), params)
|
|
204
160
|
} catch (err) {
|
|
205
|
-
throw new Error(`Error: ${
|
|
161
|
+
throw new Error(`Error: ${toErrorMessage(err)}`)
|
|
206
162
|
}
|
|
207
163
|
},
|
|
208
164
|
})
|
package/src/parsing.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
// croner 是 dependencies + 静态 import(ext-simplify-08 D2/M18):npm 语义保证全安装
|
|
2
|
+
// 形态在盘(独立安装 / builtin inline 同构),不存在「解析器缺失」分支——undefined
|
|
3
|
+
// 的唯一语义 = 表达式无效。
|
|
4
|
+
import { Cron } from 'croner'
|
|
5
|
+
|
|
6
|
+
import type { ScheduleSpec } from './types.js'
|
|
2
7
|
|
|
3
8
|
// ── Duration 解析 ──
|
|
4
9
|
|
|
@@ -65,19 +70,6 @@ const CRON_FIELD_COUNT_WITH_SECONDS = 6
|
|
|
65
70
|
/** computeNextCronRuns / computeNextRuns 默认返回的未来执行时间数。 */
|
|
66
71
|
const DEFAULT_NEXT_RUNS_COUNT = 5
|
|
67
72
|
|
|
68
|
-
let cronerModule: typeof import('croner') | null | undefined
|
|
69
|
-
|
|
70
|
-
async function getCroner(): Promise<typeof import('croner') | null> {
|
|
71
|
-
if (cronerModule !== undefined) return cronerModule
|
|
72
|
-
try {
|
|
73
|
-
cronerModule = await import('croner')
|
|
74
|
-
return cronerModule
|
|
75
|
-
} catch {
|
|
76
|
-
cronerModule = null
|
|
77
|
-
return null
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
73
|
/**
|
|
82
74
|
* 规范化 cron 表达式:5 字段自动补秒字段。
|
|
83
75
|
* 返回 undefined 表示无效。
|
|
@@ -103,20 +95,17 @@ export function normalizeCronExpression(input: string): string | undefined {
|
|
|
103
95
|
|
|
104
96
|
/**
|
|
105
97
|
* 计算 cron 表达式的下次执行时间。
|
|
106
|
-
* 返回 undefined
|
|
98
|
+
* 返回 undefined 的唯一语义 = 表达式无效(croner 静态 import 恒在盘,见文件头注释)。
|
|
107
99
|
*/
|
|
108
|
-
export
|
|
100
|
+
export function computeNextCronRunAt(
|
|
109
101
|
expression: string,
|
|
110
102
|
from?: number,
|
|
111
|
-
):
|
|
112
|
-
const croner = await getCroner()
|
|
113
|
-
if (!croner) return undefined
|
|
114
|
-
|
|
103
|
+
): number | undefined {
|
|
115
104
|
try {
|
|
116
105
|
const normalized = normalizeCronExpression(expression)
|
|
117
106
|
if (!normalized) return undefined
|
|
118
107
|
|
|
119
|
-
const job = new
|
|
108
|
+
const job = new Cron(normalized, { startAt: from ? new Date(from) : undefined })
|
|
120
109
|
const next = job.nextRun()
|
|
121
110
|
return next ? next.getTime() : undefined
|
|
122
111
|
} catch {
|
|
@@ -126,21 +115,18 @@ export async function computeNextCronRunAt(
|
|
|
126
115
|
|
|
127
116
|
/**
|
|
128
117
|
* 计算 cron 表达式的多个未来执行时间。
|
|
129
|
-
* count 默认 5
|
|
118
|
+
* count 默认 5;表达式无效 → 空数组。
|
|
130
119
|
*/
|
|
131
|
-
export
|
|
120
|
+
export function computeNextCronRuns(
|
|
132
121
|
expression: string,
|
|
133
122
|
from?: number,
|
|
134
123
|
count = DEFAULT_NEXT_RUNS_COUNT,
|
|
135
|
-
):
|
|
136
|
-
const croner = await getCroner()
|
|
137
|
-
if (!croner) return []
|
|
138
|
-
|
|
124
|
+
): number[] {
|
|
139
125
|
try {
|
|
140
126
|
const normalized = normalizeCronExpression(expression)
|
|
141
127
|
if (!normalized) return []
|
|
142
128
|
|
|
143
|
-
const job = new
|
|
129
|
+
const job = new Cron(normalized, { startAt: from ? new Date(from) : undefined })
|
|
144
130
|
const runs: number[] = []
|
|
145
131
|
let current = from ? new Date(from) : new Date()
|
|
146
132
|
|
|
@@ -160,14 +146,12 @@ export async function computeNextCronRuns(
|
|
|
160
146
|
// ── 统一解析 ──
|
|
161
147
|
|
|
162
148
|
/**
|
|
163
|
-
* 统一解析 schedule
|
|
149
|
+
* 统一解析 schedule 输入为 ScheduleSpec(L7:不包装单字段结果对象)。
|
|
164
150
|
* 不含空格 → duration 解析 → interval mode
|
|
165
151
|
* 含空格 → cron 解析 → cron mode
|
|
166
|
-
* 都失败 → undefined
|
|
152
|
+
* 都失败 → undefined(语义 = 表达式无效,service 层归一为 INVALID_SCHEDULE 单一错误通道)
|
|
167
153
|
*/
|
|
168
|
-
export
|
|
169
|
-
input: string,
|
|
170
|
-
): Promise<ParseScheduleResult | undefined> {
|
|
154
|
+
export function parseSchedule(input: string): ScheduleSpec | undefined {
|
|
171
155
|
const trimmed = input.trim()
|
|
172
156
|
if (!trimmed) return undefined
|
|
173
157
|
|
|
@@ -175,9 +159,7 @@ export async function parseSchedule(
|
|
|
175
159
|
if (!trimmed.includes(' ')) {
|
|
176
160
|
const ms = parseDuration(trimmed)
|
|
177
161
|
if (ms !== undefined) {
|
|
178
|
-
return {
|
|
179
|
-
spec: { mode: 'interval', intervalMs: ms },
|
|
180
|
-
}
|
|
162
|
+
return { mode: 'interval', intervalMs: ms }
|
|
181
163
|
}
|
|
182
164
|
return undefined
|
|
183
165
|
}
|
|
@@ -186,11 +168,9 @@ export async function parseSchedule(
|
|
|
186
168
|
const normalized = normalizeCronExpression(trimmed)
|
|
187
169
|
if (normalized) {
|
|
188
170
|
// 验证 cron 表达式有效
|
|
189
|
-
const nextRun =
|
|
171
|
+
const nextRun = computeNextCronRunAt(trimmed)
|
|
190
172
|
if (nextRun !== undefined) {
|
|
191
|
-
return {
|
|
192
|
-
spec: { mode: 'cron', cronExpression: normalized },
|
|
193
|
-
}
|
|
173
|
+
return { mode: 'cron', cronExpression: normalized }
|
|
194
174
|
}
|
|
195
175
|
}
|
|
196
176
|
|
|
@@ -200,14 +180,14 @@ export async function parseSchedule(
|
|
|
200
180
|
/**
|
|
201
181
|
* 统一计算 schedule 的下次执行时间(IF-5:runtime 内唯一 nextRunAt 计算入口)。
|
|
202
182
|
* interval → from + intervalMs(from 缺省 Date.now());
|
|
203
|
-
* cron → 下次命中时间戳(> from);cron
|
|
183
|
+
* cron → 下次命中时间戳(> from);cron 无效 → undefined。
|
|
204
184
|
* undefined 语义按调用方决策:addTask → 创建即抛错;
|
|
205
185
|
* toggleTask/dispatchTask 重算 → ERR-2 fallback(停用 + failed)。
|
|
206
186
|
*/
|
|
207
|
-
export
|
|
187
|
+
export function computeNextRunAt(
|
|
208
188
|
spec: ScheduleSpec,
|
|
209
189
|
from?: number,
|
|
210
|
-
):
|
|
190
|
+
): number | undefined {
|
|
211
191
|
if (spec.mode === 'interval') {
|
|
212
192
|
const start = from ?? Date.now()
|
|
213
193
|
return start + spec.intervalMs
|
|
@@ -222,11 +202,11 @@ export async function computeNextRunAt(
|
|
|
222
202
|
* 统一计算多个未来执行时间。
|
|
223
203
|
* interval 模式直接乘法,cron 模式调用 croner。
|
|
224
204
|
*/
|
|
225
|
-
export
|
|
205
|
+
export function computeNextRuns(
|
|
226
206
|
spec: ScheduleSpec,
|
|
227
207
|
from?: number,
|
|
228
208
|
count = DEFAULT_NEXT_RUNS_COUNT,
|
|
229
|
-
):
|
|
209
|
+
): number[] {
|
|
230
210
|
if (spec.mode === 'interval') {
|
|
231
211
|
const start = from ?? Date.now()
|
|
232
212
|
return Array.from({ length: count }, (_, i) => start + spec.intervalMs * (i + 1))
|