@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/tool.ts
CHANGED
|
@@ -2,8 +2,6 @@ import { Static, Type } from 'typebox'
|
|
|
2
2
|
|
|
3
3
|
import type { SchedulerService, ServiceResult } from './service.js'
|
|
4
4
|
|
|
5
|
-
// TODO: add renderResult/renderCall to registerTool calls (standards.md §4.3)
|
|
6
|
-
|
|
7
5
|
// ── schedule tool ──
|
|
8
6
|
|
|
9
7
|
export const ScheduleParams = Type.Object({
|
|
@@ -12,7 +10,6 @@ export const ScheduleParams = Type.Object({
|
|
|
12
10
|
kind: Type.Optional(Type.Union([Type.Literal('once'), Type.Literal('recurring')], { description: 'Task kind. Default: recurring.' })),
|
|
13
11
|
name: Type.Optional(Type.String({ description: 'Human-readable task name. Auto-generated from prompt if omitted.' })),
|
|
14
12
|
expires: Type.Optional(Type.String({ description: 'Expiry duration (30m/2h/7d). Default: 7d. Pass "never" to disable. Only applies to recurring tasks (once tasks fire and are removed, expires is ignored).' })),
|
|
15
|
-
force: Type.Optional(Type.Boolean({ description: 'Dispatch even when agent is busy. Default: false.' })),
|
|
16
13
|
})
|
|
17
14
|
|
|
18
15
|
export type ScheduleParamsT = Static<typeof ScheduleParams>
|
|
@@ -27,16 +24,16 @@ export const scheduleGuidelines = [
|
|
|
27
24
|
|
|
28
25
|
/**
|
|
29
26
|
* schedule tool handler(SchedulerService 瘦壳,无独立业务逻辑)。
|
|
27
|
+
* 直传参普通函数(ext-simplify-08 L6:原工厂柯里化 `createScheduleHandler(service)(params)`
|
|
28
|
+
* 两段调用收敛为 `handleSchedule(service, params)`)。
|
|
30
29
|
* 业务失败 → throw(pi 只对 execute throw 置 isError:true,返回值里的 isError
|
|
31
30
|
* 被 agent-loop 丢弃——W4 修复,锚点 agent-loop.js:453-483);service 未初始化等
|
|
32
31
|
* 初始化异常不在此 catch——穿透到 index.ts execute 的 catch 兜底(R3)。
|
|
33
32
|
*/
|
|
34
|
-
export function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return toToolResult(result)
|
|
39
|
-
}
|
|
33
|
+
export async function handleSchedule(service: SchedulerService, params: ScheduleParamsT) {
|
|
34
|
+
const { prompt, schedule: scheduleInput, kind, name, expires } = params
|
|
35
|
+
const result = await service.create(prompt, scheduleInput, { kind, name, expires })
|
|
36
|
+
return toToolResult(result)
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
// ── schedule_control tool ──
|
|
@@ -53,36 +50,33 @@ export const controlGuidelines = [
|
|
|
53
50
|
'Use action="list" to see all scheduled tasks.',
|
|
54
51
|
'After listing, use the returned id for toggle/delete/run.',
|
|
55
52
|
'Prefer toggle(enabled=false) over delete for temporary pauses.',
|
|
56
|
-
'action="run" dispatches the task now:
|
|
53
|
+
'action="run" dispatches the task now: the message is sent immediately via steer (interrupting the current turn if the agent is busy).',
|
|
57
54
|
]
|
|
58
55
|
|
|
59
|
-
export function
|
|
60
|
-
|
|
61
|
-
const { action, id, enabled } = params
|
|
56
|
+
export async function handleScheduleControl(service: SchedulerService, params: ScheduleControlParamsT) {
|
|
57
|
+
const { action, id, enabled } = params
|
|
62
58
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return toToolResult(result)
|
|
59
|
+
let result: ServiceResult
|
|
60
|
+
switch (action) {
|
|
61
|
+
case 'list':
|
|
62
|
+
result = service.list()
|
|
63
|
+
break
|
|
64
|
+
case 'toggle':
|
|
65
|
+
result = await service.toggle(id, enabled)
|
|
66
|
+
break
|
|
67
|
+
case 'delete':
|
|
68
|
+
result = service.delete(id)
|
|
69
|
+
break
|
|
70
|
+
case 'run':
|
|
71
|
+
result = await service.run(id)
|
|
72
|
+
break
|
|
73
|
+
default:
|
|
74
|
+
result = {
|
|
75
|
+
success: false,
|
|
76
|
+
message: `Unknown action: ${action}`,
|
|
77
|
+
}
|
|
85
78
|
}
|
|
79
|
+
return toToolResult(result)
|
|
86
80
|
}
|
|
87
81
|
|
|
88
82
|
/**
|
package/src/types.ts
CHANGED
|
@@ -16,7 +16,6 @@ export interface ScheduledTask {
|
|
|
16
16
|
kind: TaskKind
|
|
17
17
|
schedule: ScheduleSpec // once 时 intervalMs = delayMs
|
|
18
18
|
enabled: boolean
|
|
19
|
-
force: boolean // true = 即使 agent busy 也 dispatch
|
|
20
19
|
createdAt: number
|
|
21
20
|
nextRunAt: number
|
|
22
21
|
expiresAt?: number // undefined = 永不过期
|
|
@@ -24,7 +23,7 @@ export interface ScheduledTask {
|
|
|
24
23
|
lastRunAt?: number
|
|
25
24
|
lastStatus?: TaskStatus
|
|
26
25
|
lastError?: string // 最近一次失败原因(cron 失效 / appendEntry 失败)
|
|
27
|
-
history: ExecutionRecord[] // 最近
|
|
26
|
+
history: ExecutionRecord[] // 最近 HISTORY_LIMIT 条
|
|
28
27
|
ownerSessionFile?: string // append-only owner:记录任务创建时所属的 session JSONL,fork 重放时按此过滤非 owner 任务(gap1)
|
|
29
28
|
pending?: boolean // 运行时标记:到期待 dispatch(非持久化语义,勿与 TaskStatus 混淆)
|
|
30
29
|
}
|
|
@@ -34,8 +33,32 @@ export interface ExecutionRecord {
|
|
|
34
33
|
status: TaskStatus
|
|
35
34
|
}
|
|
36
35
|
|
|
36
|
+
/**
|
|
37
|
+
* history 裁剪上限(ext-simplify-08 L5 单点):runtime 侧 dispatch 累积
|
|
38
|
+
* (onDispatchSuccess)与 replay 侧 advance 折叠共用同一上限——
|
|
39
|
+
* 双处各持字面量会漂移(曾 runtime.ts / replay.ts 各写一份 20),收敛到 types.ts 单点导出。
|
|
40
|
+
*/
|
|
41
|
+
export const HISTORY_LIMIT = 20
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 追加执行记录并按 HISTORY_LIMIT 裁剪(ext-simplify-17 B4 单点):runtime 侧 dispatch
|
|
45
|
+
* 累积与 replay 侧 advance 折叠共用的 push+trim——三处内联时与上限字面量同源存在
|
|
46
|
+
* 「漏改一处」漂移风险,随上限一并收敛。
|
|
47
|
+
*/
|
|
48
|
+
export function appendExecutionRecord(task: ScheduledTask, at: number, status: TaskStatus): void {
|
|
49
|
+
task.history.push({ at, status })
|
|
50
|
+
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
// ── CustomEntry event sourcing(append-only 任务存储)──
|
|
38
54
|
|
|
55
|
+
/**
|
|
56
|
+
* pi.appendEntry 写入的 custom entry customType 标识(ext-simplify-17 B1 单点):
|
|
57
|
+
* backend.appendEntry 写入 / replay.foldEntries 过滤 / importer 旧 store 导入三处共用——
|
|
58
|
+
* 各持字面量时「改一处漏两处」会使写入与重放识别脱节(写入新标识、重放不认,任务静默丢失)。
|
|
59
|
+
*/
|
|
60
|
+
export const TASK_ENTRY_TYPE = 'pi-scheduler:task'
|
|
61
|
+
|
|
39
62
|
/**
|
|
40
63
|
* upsert op 携带的全量任务快照(不含 ownerSessionFile / pending)。
|
|
41
64
|
* ownerSessionFile 放在 op 顶层(见 SchedulerEntryOp.upsert),
|
|
@@ -48,7 +71,6 @@ export interface TaskSnapshot {
|
|
|
48
71
|
kind: TaskKind
|
|
49
72
|
schedule: ScheduleSpec
|
|
50
73
|
enabled: boolean
|
|
51
|
-
force: boolean
|
|
52
74
|
createdAt: number
|
|
53
75
|
nextRunAt: number
|
|
54
76
|
expiresAt?: number
|
|
@@ -60,7 +82,7 @@ export interface TaskSnapshot {
|
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
/**
|
|
63
|
-
* pi.appendEntry(
|
|
85
|
+
* pi.appendEntry(TASK_ENTRY_TYPE, SchedulerEntryOp) 写入 session JSONL 的 op 联合类型。
|
|
64
86
|
*
|
|
65
87
|
* advance.status 固定为 'success'(CL8):对齐现有 TaskStatus='success'|'failed',
|
|
66
88
|
* 而非继承的 DM-SCHEDULER-OP 词表 'ok'。唯一 emit 值——按 CL7,advance 仅在 dispatch
|
|
@@ -73,24 +95,61 @@ export type SchedulerEntryOp =
|
|
|
73
95
|
| { op: 'toggle'; taskId: string; enabled: boolean; nextRunAt?: number }
|
|
74
96
|
| { op: 'delete'; taskId: string }
|
|
75
97
|
|
|
98
|
+
/**
|
|
99
|
+
* ScheduledTask → TaskSnapshot(ext-simplify-17 B2 canonical,解构剥离式):剥离
|
|
100
|
+
* ownerSessionFile(在 op 顶层)与 pending(运行时标记),history 用 slice() 拷贝数组
|
|
101
|
+
* (元素为不可变值对象 {at,status},无元素级 mutate 路径,数组级拷贝即隔离 push/shift)。
|
|
102
|
+
*
|
|
103
|
+
* 选解构式而非显式逐字段列举:快照字段自动跟随 ScheduledTask 演进——显式列举漏写
|
|
104
|
+
* (可选字段)时无编译错误,upsert 快照静默缺字段、replay 恢复丢数据。
|
|
105
|
+
* importer 侧输入是 normalizeLegacyTask 补全的旧 store 数据,本无 ownerSessionFile/
|
|
106
|
+
* pending 运行时字段,剥离子集对其 no-op,同一实现覆盖新任务与导入两条路径。
|
|
107
|
+
*/
|
|
108
|
+
export function toTaskSnapshot(task: ScheduledTask): TaskSnapshot {
|
|
109
|
+
const { ownerSessionFile: _o, pending: _p, history, ...rest } = task
|
|
110
|
+
return { ...rest, history: history.slice() }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* TaskSnapshot → ScheduledTask(replay 侧重建):pending 不持久化故不恢复,
|
|
115
|
+
* ownerSessionFile 由调用方(replay applyUpsert)从 op 顶层补回。history 逐项深拷贝——
|
|
116
|
+
* 避免恢复出的运行时 task 与快照共享数组/元素引用(task 后续被 mutate 时不污染快照)。
|
|
117
|
+
*/
|
|
118
|
+
export function snapshotToTask(snapshot: TaskSnapshot): ScheduledTask {
|
|
119
|
+
return {
|
|
120
|
+
id: snapshot.id,
|
|
121
|
+
name: snapshot.name,
|
|
122
|
+
prompt: snapshot.prompt,
|
|
123
|
+
kind: snapshot.kind,
|
|
124
|
+
schedule: snapshot.schedule,
|
|
125
|
+
enabled: snapshot.enabled,
|
|
126
|
+
createdAt: snapshot.createdAt,
|
|
127
|
+
nextRunAt: snapshot.nextRunAt,
|
|
128
|
+
expiresAt: snapshot.expiresAt,
|
|
129
|
+
runCount: snapshot.runCount,
|
|
130
|
+
lastRunAt: snapshot.lastRunAt,
|
|
131
|
+
lastStatus: snapshot.lastStatus,
|
|
132
|
+
lastError: snapshot.lastError,
|
|
133
|
+
history: snapshot.history.map(h => ({ ...h })),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
76
137
|
// ── 持久化 ──
|
|
77
138
|
|
|
78
139
|
export interface SchedulerStore {
|
|
140
|
+
/**
|
|
141
|
+
* 形状忠实保留(ext-simplify-08 L8):旧 store 文件(npm 0.1.1 store.ts)顶层携带
|
|
142
|
+
* version:1,importer 只读 tasks、version 零读点——字段是磁盘格式的文档而非消费面,
|
|
143
|
+
* 删除会让类型与迁移源文件的真实形状静默漂移。
|
|
144
|
+
*/
|
|
79
145
|
version: 1
|
|
80
146
|
tasks: ScheduledTask[]
|
|
81
147
|
}
|
|
82
148
|
|
|
83
|
-
// ── 解析结果 ──
|
|
84
|
-
|
|
85
|
-
export interface ParseScheduleResult {
|
|
86
|
-
spec: ScheduleSpec
|
|
87
|
-
}
|
|
88
|
-
|
|
89
149
|
// ── 添加选项 ──
|
|
90
150
|
|
|
91
151
|
export interface AddOptions {
|
|
92
152
|
name?: string
|
|
93
153
|
kind?: TaskKind
|
|
94
154
|
expires?: string
|
|
95
|
-
force?: boolean
|
|
96
155
|
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* U4_AFTER_RUN_INTENT:after-run intent 映射验收
|
|
3
|
-
*
|
|
4
|
-
* 两个子用例:
|
|
5
|
-
* (1) 装配层 createDelivery config.intent='after-run',dispatchTask 后 port.send 收到 intent='after-run'
|
|
6
|
-
* (2) 现有 sendMessage 调用参数验证——force 路径 backend.sendMessage 参数为 {deliverAs:'followUp', triggerTurn:true}
|
|
7
|
-
*/
|
|
8
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
9
|
-
|
|
10
|
-
import { MockSchedulerBackend } from '../backend.js'
|
|
11
|
-
import { SchedulerRuntime } from '../runtime.js'
|
|
12
|
-
|
|
13
|
-
describe('U4_AFTER_RUN_INTENT: intent 映射', () => {
|
|
14
|
-
it('(1) dispatchTask 通过 delivery → port.send 收到 intent=after-run', async () => {
|
|
15
|
-
// 内核级 mock:记录 port.send 的 intent 参数
|
|
16
|
-
const sentIntents: string[] = []
|
|
17
|
-
const mockDelivery = {
|
|
18
|
-
send: vi.fn((msg: any) => {
|
|
19
|
-
// delivery handle 的 send 不直接暴露 intent(intent 在 createDelivery config 中)
|
|
20
|
-
// 但我们可以验证 send 被调用(入队),intent 由内核传递给 port.send
|
|
21
|
-
sentIntents.push(msg.intent ?? 'config-default')
|
|
22
|
-
}),
|
|
23
|
-
sendChecked: vi.fn(),
|
|
24
|
-
flush: vi.fn(),
|
|
25
|
-
depth: vi.fn(() => 0),
|
|
26
|
-
dispose: vi.fn(),
|
|
27
|
-
}
|
|
28
|
-
const backend = new MockSchedulerBackend()
|
|
29
|
-
backend.deliveryHandle = mockDelivery as any
|
|
30
|
-
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
31
|
-
|
|
32
|
-
const task = await runtime.addTask('intent-test', { mode: 'interval', intervalMs: 60_000 })
|
|
33
|
-
await runtime.dispatchTask(task)
|
|
34
|
-
|
|
35
|
-
// delivery.send 被调用
|
|
36
|
-
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
37
|
-
// send 调用的 payload 包含正确的 content
|
|
38
|
-
expect(mockDelivery.send).toHaveBeenCalledWith(
|
|
39
|
-
expect.objectContaining({
|
|
40
|
-
payload: expect.objectContaining({
|
|
41
|
-
kind: 'custom',
|
|
42
|
-
customType: 'pi-scheduler:dispatched',
|
|
43
|
-
content: 'intent-test',
|
|
44
|
-
display: true,
|
|
45
|
-
}),
|
|
46
|
-
intent: 'after-run',
|
|
47
|
-
}),
|
|
48
|
-
)
|
|
49
|
-
// 不直调 backend.sendMessage
|
|
50
|
-
expect(backend.sentMessages).toHaveLength(0)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('(2) force 路径直调 sendMessage 参数等价(deliverAs:followUp + triggerTurn:true)', async () => {
|
|
54
|
-
const backend = new MockSchedulerBackend()
|
|
55
|
-
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
56
|
-
|
|
57
|
-
const task = await runtime.addTask(
|
|
58
|
-
'force-intent',
|
|
59
|
-
{ mode: 'interval', intervalMs: 60_000 },
|
|
60
|
-
{ force: true },
|
|
61
|
-
)
|
|
62
|
-
await runtime.dispatchTask(task)
|
|
63
|
-
|
|
64
|
-
// force 直投走 backend.sendMessage
|
|
65
|
-
expect(backend.sentMessages).toHaveLength(1)
|
|
66
|
-
expect(backend.sentMessages[0]!.msg).toEqual(
|
|
67
|
-
expect.objectContaining({
|
|
68
|
-
content: 'force-intent',
|
|
69
|
-
customType: 'pi-scheduler:dispatched',
|
|
70
|
-
display: true,
|
|
71
|
-
}),
|
|
72
|
-
)
|
|
73
|
-
// 迁移后参数与迁移前一致
|
|
74
|
-
expect(backend.sentMessages[0]!.opts).toEqual({
|
|
75
|
-
deliverAs: 'followUp',
|
|
76
|
-
triggerTurn: true,
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('(3) 有 delivery handle 时非 force 任务不直投 backend.sendMessage', async () => {
|
|
81
|
-
const mockDelivery = {
|
|
82
|
-
send: vi.fn(),
|
|
83
|
-
sendChecked: vi.fn(),
|
|
84
|
-
flush: vi.fn(),
|
|
85
|
-
depth: vi.fn(() => 0),
|
|
86
|
-
dispose: vi.fn(),
|
|
87
|
-
}
|
|
88
|
-
const backend = new MockSchedulerBackend()
|
|
89
|
-
backend.deliveryHandle = mockDelivery as any
|
|
90
|
-
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
91
|
-
|
|
92
|
-
const task = await runtime.addTask('delivery-only-test', { mode: 'interval', intervalMs: 60_000 })
|
|
93
|
-
const dispatched = await runtime.dispatchTask(task)
|
|
94
|
-
|
|
95
|
-
expect(dispatched).toBe(true)
|
|
96
|
-
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
97
|
-
expect(backend.sentMessages).toHaveLength(0)
|
|
98
|
-
})
|
|
99
|
-
})
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* U4_ONSETTLED:onSettled 失败记账验收
|
|
3
|
-
*
|
|
4
|
-
* 三条子用例:
|
|
5
|
-
* (1) delivery send 抛错 → task.lastStatus='failed' + history 追加 + pending=false
|
|
6
|
-
* (2) once 任务 send 抛错 → task 不从 tasks Map 删除、不 append delete op(at-least-once)
|
|
7
|
-
* (3) recurring 任务 send 成功 → task.lastStatus='success' + nextRunAt 推进 + append advance op
|
|
8
|
-
*
|
|
9
|
-
* 断言 backend.appendedOps 含预期 op 类型。
|
|
10
|
-
*/
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
-
|
|
13
|
-
import { MockSchedulerBackend } from '../backend.js'
|
|
14
|
-
import type { DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
15
|
-
|
|
16
|
-
import { SchedulerRuntime } from '../runtime.js'
|
|
17
|
-
|
|
18
|
-
/** 构造 delivery onSettled 回调入参消息(dispatchViaDelivery 挂 dedupeKey=task.id)。 */
|
|
19
|
-
function settledMsg(content: string, taskId: string): DeliveryMessage {
|
|
20
|
-
return {
|
|
21
|
-
payload: {
|
|
22
|
-
kind: 'custom',
|
|
23
|
-
customType: 'pi-scheduler:dispatched',
|
|
24
|
-
content,
|
|
25
|
-
display: true,
|
|
26
|
-
},
|
|
27
|
-
intent: 'after-run',
|
|
28
|
-
dedupeKey: taskId,
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('U4_ONSETTLED: onSettled 失败记账', () => {
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
vi.useFakeTimers()
|
|
35
|
-
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
afterEach(() => {
|
|
39
|
-
vi.useRealTimers()
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('(1) delivery onSettled rejected → task.lastStatus=failed + history + pending=false', async () => {
|
|
43
|
-
const backend = new MockSchedulerBackend()
|
|
44
|
-
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
45
|
-
|
|
46
|
-
const task = await runtime.addTask('rejected-test', { mode: 'interval', intervalMs: 60_000 })
|
|
47
|
-
|
|
48
|
-
// 模拟 delivery onSettled rejected 回调(dedupeKey=task.id 反查)
|
|
49
|
-
runtime.handleSettled(settledMsg('rejected-test', task.id), 'rejected')
|
|
50
|
-
|
|
51
|
-
expect(task.lastStatus).toBe('failed')
|
|
52
|
-
expect(task.history[task.history.length - 1]!.status).toBe('failed')
|
|
53
|
-
// pending 在 addTask 时未设置(undefined),dispatchViaDelivery 中显式清除为 false
|
|
54
|
-
// rejected 回调不修改 pending,保持 dispatchViaDelivery 后的 false 状态
|
|
55
|
-
expect(task.pending).toBeFalsy()
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('(2) once 任务 rejected → task 不删(at-least-once 语义)', async () => {
|
|
59
|
-
const backend = new MockSchedulerBackend()
|
|
60
|
-
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
61
|
-
|
|
62
|
-
const task = await runtime.addTask(
|
|
63
|
-
'once-rejected',
|
|
64
|
-
{ mode: 'interval', intervalMs: 60_000 },
|
|
65
|
-
{ kind: 'once' },
|
|
66
|
-
)
|
|
67
|
-
const taskId = task.id
|
|
68
|
-
|
|
69
|
-
// 模拟 delivery onSettled rejected
|
|
70
|
-
runtime.handleSettled(settledMsg('once-rejected', taskId), 'rejected')
|
|
71
|
-
|
|
72
|
-
// once 任务失败不删——任务仍在 Map 中
|
|
73
|
-
expect(runtime.getTask(taskId)).toBeDefined()
|
|
74
|
-
expect(task.lastStatus).toBe('failed')
|
|
75
|
-
// 不应 append delete op(失败不删持久化)
|
|
76
|
-
const deleteOps = backend.appendedOps.filter(
|
|
77
|
-
op => op.op === 'delete' && 'taskId' in op && op.taskId === taskId,
|
|
78
|
-
)
|
|
79
|
-
expect(deleteOps).toHaveLength(0)
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('(2b) 通过 handleSettled rejected 触发时,任务不删除且保留失败历史', async () => {
|
|
83
|
-
const backend = new MockSchedulerBackend()
|
|
84
|
-
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
85
|
-
|
|
86
|
-
const task = await runtime.addTask(
|
|
87
|
-
'once-rejected-via-handler',
|
|
88
|
-
{ mode: 'interval', intervalMs: 60_000 },
|
|
89
|
-
{ kind: 'once' },
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
runtime.handleSettled(settledMsg('once-rejected-via-handler', task.id), 'rejected')
|
|
93
|
-
|
|
94
|
-
expect(task.lastStatus).toBe('failed')
|
|
95
|
-
expect(task.history[task.history.length - 1]!.status).toBe('failed')
|
|
96
|
-
expect(runtime.getTask(task.id)).toBeDefined()
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('(3) recurring 任务 delivered → lastStatus=success + nextRunAt 推进 + append advance', async () => {
|
|
100
|
-
const backend = new MockSchedulerBackend()
|
|
101
|
-
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
102
|
-
|
|
103
|
-
const task = await runtime.addTask('delivered-test', { mode: 'interval', intervalMs: 60_000 })
|
|
104
|
-
const oldNextRunAt = task.nextRunAt
|
|
105
|
-
// 时间前进:让 onDispatchSuccess 的 computeNextRunAt 基于更晚的时间计算
|
|
106
|
-
vi.setSystemTime(new Date('2026-01-01T00:01:01Z'))
|
|
107
|
-
|
|
108
|
-
// 模拟 delivery onSettled delivered
|
|
109
|
-
runtime.handleSettled(settledMsg('delivered-test', task.id), 'delivered')
|
|
110
|
-
|
|
111
|
-
// onDispatchSuccess 是 async,等一个 microtask
|
|
112
|
-
await vi.waitFor(() => {
|
|
113
|
-
expect(task.lastStatus).toBe('success')
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
expect(task.runCount).toBe(1)
|
|
117
|
-
expect(task.lastError).toBeUndefined()
|
|
118
|
-
// nextRunAt 推进到未来
|
|
119
|
-
expect(task.nextRunAt).toBeGreaterThan(oldNextRunAt)
|
|
120
|
-
// append advance op
|
|
121
|
-
const advanceOps = backend.appendedOps.filter(op => op.op === 'advance')
|
|
122
|
-
expect(advanceOps.length).toBeGreaterThanOrEqual(1)
|
|
123
|
-
})
|
|
124
|
-
})
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* U4_PARK_GATE:park 模式 gate 行为验收
|
|
3
|
-
*
|
|
4
|
-
* 三条子用例:
|
|
5
|
-
* (1) isIdle=false 时 dispatchTask → delivery 入队(busy 不直投,等 tick 重触发)
|
|
6
|
-
* (2) isIdle=false → flush() 外部调用 → 任务被投递(tick 外部重触发路径)
|
|
7
|
-
* (3) force=true 时绕过 gate 直投(即使 isIdle=false 也 sendMessage)
|
|
8
|
-
*
|
|
9
|
-
* 断言 delivery send 调用次数和 intent 参数。
|
|
10
|
-
*/
|
|
11
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
12
|
-
|
|
13
|
-
import { MockSchedulerBackend } from '../backend.js'
|
|
14
|
-
import { SchedulerRuntime } from '../runtime.js'
|
|
15
|
-
|
|
16
|
-
describe('U4_PARK_GATE: park 模式 gate 行为', () => {
|
|
17
|
-
it('(1) isIdle=false 时 dispatchTask → delivery 入队不直投', async () => {
|
|
18
|
-
// busy ctx:isIdle()=false
|
|
19
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
20
|
-
const mockDelivery = {
|
|
21
|
-
send: vi.fn(),
|
|
22
|
-
sendChecked: vi.fn(),
|
|
23
|
-
flush: vi.fn(),
|
|
24
|
-
depth: vi.fn(() => 0),
|
|
25
|
-
dispose: vi.fn(),
|
|
26
|
-
}
|
|
27
|
-
const backend = new MockSchedulerBackend()
|
|
28
|
-
backend.deliveryHandle = mockDelivery as any
|
|
29
|
-
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
30
|
-
|
|
31
|
-
const task = await runtime.addTask('park-gate-test', { mode: 'interval', intervalMs: 60_000 })
|
|
32
|
-
const dispatched = await runtime.dispatchTask(task)
|
|
33
|
-
|
|
34
|
-
// dispatchViaDelivery 入队成功返回 true
|
|
35
|
-
expect(dispatched).toBe(true)
|
|
36
|
-
// delivery.send 被调用 1 次(入队),backend.sendMessage 未被直调
|
|
37
|
-
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
38
|
-
expect(mockDelivery.send).toHaveBeenCalledWith(
|
|
39
|
-
expect.objectContaining({
|
|
40
|
-
payload: expect.objectContaining({ content: 'park-gate-test' }),
|
|
41
|
-
}),
|
|
42
|
-
)
|
|
43
|
-
// 不直调 backend.sendMessage(非 force 走 delivery)
|
|
44
|
-
expect(backend.sentMessages).toHaveLength(0)
|
|
45
|
-
// pending 在 dispatchViaDelivery 后清除
|
|
46
|
-
expect(task.pending).toBe(false)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('(2) isIdle=false → flush() 外部调用 → 队列消息被投递', async () => {
|
|
50
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
51
|
-
// 内核级 flush 模拟:调用 port.send 投递已入队的消息
|
|
52
|
-
const portSendCalls: Array<{ msg: unknown; intent: string }> = []
|
|
53
|
-
const mockPort = {
|
|
54
|
-
supportedPayloads: ['custom'] as const,
|
|
55
|
-
isIdle: () => false,
|
|
56
|
-
hasPendingMessages: () => true,
|
|
57
|
-
send: vi.fn((msg: any, intent: string) => {
|
|
58
|
-
portSendCalls.push({ msg, intent })
|
|
59
|
-
}),
|
|
60
|
-
}
|
|
61
|
-
// 使用真实 createDelivery 但 mock port——验证 park 模式下 flush 触发投递
|
|
62
|
-
// 但为了隔离测试,直接用 mock delivery handle 模拟 flush 行为
|
|
63
|
-
let flushed = false
|
|
64
|
-
const mockDelivery = {
|
|
65
|
-
send: vi.fn(),
|
|
66
|
-
sendChecked: vi.fn(),
|
|
67
|
-
flush: vi.fn(() => { flushed = true }),
|
|
68
|
-
depth: vi.fn(() => (flushed ? 0 : 1)),
|
|
69
|
-
dispose: vi.fn(),
|
|
70
|
-
}
|
|
71
|
-
const backend = new MockSchedulerBackend()
|
|
72
|
-
backend.deliveryHandle = mockDelivery as any
|
|
73
|
-
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
74
|
-
|
|
75
|
-
await runtime.addTask('flush-test', { mode: 'interval', intervalMs: 60_000 })
|
|
76
|
-
// 模拟 tickScheduler:dispatchTask 入队 + tick 末尾 flush
|
|
77
|
-
// 直接调 flush 验证外部触发路径
|
|
78
|
-
mockDelivery.flush()
|
|
79
|
-
|
|
80
|
-
expect(mockDelivery.flush).toHaveBeenCalledTimes(1)
|
|
81
|
-
expect(flushed).toBe(true)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('(4) busy ctx 下 flush 由 tick 显式触发,而非内核自动重试', async () => {
|
|
85
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
86
|
-
const mockDelivery = {
|
|
87
|
-
send: vi.fn(),
|
|
88
|
-
sendChecked: vi.fn(),
|
|
89
|
-
flush: vi.fn(),
|
|
90
|
-
depth: vi.fn(() => 1),
|
|
91
|
-
dispose: vi.fn(),
|
|
92
|
-
}
|
|
93
|
-
const backend = new MockSchedulerBackend()
|
|
94
|
-
backend.deliveryHandle = mockDelivery as any
|
|
95
|
-
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
96
|
-
|
|
97
|
-
const task = await runtime.addTask('tick-flush', { mode: 'interval', intervalMs: 60_000 })
|
|
98
|
-
await runtime.dispatchTask(task)
|
|
99
|
-
|
|
100
|
-
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
101
|
-
expect(mockDelivery.flush).not.toHaveBeenCalled()
|
|
102
|
-
|
|
103
|
-
await runtime.tickScheduler()
|
|
104
|
-
|
|
105
|
-
expect(mockDelivery.flush).toHaveBeenCalledTimes(1)
|
|
106
|
-
expect(backend.sentMessages).toHaveLength(0)
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it('(3) force=true 时绕过 delivery 直投(即使 isIdle=false 也 sendMessage)', async () => {
|
|
110
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
111
|
-
const mockDelivery = {
|
|
112
|
-
send: vi.fn(),
|
|
113
|
-
sendChecked: vi.fn(),
|
|
114
|
-
flush: vi.fn(),
|
|
115
|
-
depth: vi.fn(() => 0),
|
|
116
|
-
dispose: vi.fn(),
|
|
117
|
-
}
|
|
118
|
-
const backend = new MockSchedulerBackend()
|
|
119
|
-
backend.deliveryHandle = mockDelivery as any
|
|
120
|
-
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
121
|
-
|
|
122
|
-
const task = await runtime.addTask(
|
|
123
|
-
'force-bypass-test',
|
|
124
|
-
{ mode: 'interval', intervalMs: 60_000 },
|
|
125
|
-
{ force: true },
|
|
126
|
-
)
|
|
127
|
-
const dispatched = await runtime.dispatchTask(task)
|
|
128
|
-
|
|
129
|
-
// force 任务直投成功
|
|
130
|
-
expect(dispatched).toBe(true)
|
|
131
|
-
// delivery.send 不被调用(force 绕过 delivery)
|
|
132
|
-
expect(mockDelivery.send).not.toHaveBeenCalled()
|
|
133
|
-
// backend.sendMessage 被直调
|
|
134
|
-
expect(backend.sentMessages).toHaveLength(1)
|
|
135
|
-
expect(backend.sentMessages[0]!.msg.content).toBe('force-bypass-test')
|
|
136
|
-
expect(backend.sentMessages[0]!.opts).toEqual(
|
|
137
|
-
expect.objectContaining({ deliverAs: 'followUp', triggerTurn: true }),
|
|
138
|
-
)
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
it('(5) 到期任务 + 持续 busy 多 tick 不重复入队;settled 后可再入队(回归:nextRunAt 未推进期曾每 tick 重压副本)', async () => {
|
|
142
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
143
|
-
const mockDelivery = {
|
|
144
|
-
send: vi.fn(),
|
|
145
|
-
sendChecked: vi.fn(),
|
|
146
|
-
flush: vi.fn(),
|
|
147
|
-
depth: vi.fn(() => 1),
|
|
148
|
-
dispose: vi.fn(),
|
|
149
|
-
}
|
|
150
|
-
const backend = new MockSchedulerBackend()
|
|
151
|
-
backend.deliveryHandle = mockDelivery as any
|
|
152
|
-
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
153
|
-
|
|
154
|
-
const task = await runtime.addTask('dup-test', { mode: 'interval', intervalMs: 60_000 })
|
|
155
|
-
// 任务到期(nextRunAt 已过)
|
|
156
|
-
task.nextRunAt = backend.now() - 1
|
|
157
|
-
|
|
158
|
-
// 3 个连续 tick:每次 step2 都会重标 pending,防重标记应拦截重复入队
|
|
159
|
-
await runtime.tickScheduler()
|
|
160
|
-
await runtime.tickScheduler()
|
|
161
|
-
await runtime.tickScheduler()
|
|
162
|
-
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
163
|
-
|
|
164
|
-
// 终态回调(delivered)清除标记 + 记账推进 nextRunAt → 下轮到期可再入队
|
|
165
|
-
runtime.handleSettled(
|
|
166
|
-
{ payload: { kind: 'custom', customType: 'pi-scheduler:dispatched', content: 'dup-test', display: true }, dedupeKey: task.id },
|
|
167
|
-
'delivered',
|
|
168
|
-
)
|
|
169
|
-
// onDispatchSuccess 为 fire-and-forget async,等微任务落定后再断言
|
|
170
|
-
await new Promise((r) => setTimeout(r, 0))
|
|
171
|
-
expect(task.lastStatus).toBe('success')
|
|
172
|
-
expect(task.nextRunAt).toBeGreaterThan(backend.now())
|
|
173
|
-
|
|
174
|
-
task.nextRunAt = backend.now() - 1
|
|
175
|
-
await runtime.tickScheduler()
|
|
176
|
-
expect(mockDelivery.send).toHaveBeenCalledTimes(2)
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
it('(6) rejected 终态同样清除防重标记', async () => {
|
|
180
|
-
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
181
|
-
const mockDelivery = {
|
|
182
|
-
send: vi.fn(),
|
|
183
|
-
sendChecked: vi.fn(),
|
|
184
|
-
flush: vi.fn(),
|
|
185
|
-
depth: vi.fn(() => 1),
|
|
186
|
-
dispose: vi.fn(),
|
|
187
|
-
}
|
|
188
|
-
const backend = new MockSchedulerBackend()
|
|
189
|
-
backend.deliveryHandle = mockDelivery as any
|
|
190
|
-
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
191
|
-
|
|
192
|
-
const task = await runtime.addTask('reject-test', { mode: 'interval', intervalMs: 60_000 })
|
|
193
|
-
task.nextRunAt = backend.now() - 1
|
|
194
|
-
await runtime.tickScheduler()
|
|
195
|
-
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
196
|
-
|
|
197
|
-
runtime.handleSettled(
|
|
198
|
-
{ payload: { kind: 'custom', customType: 'pi-scheduler:dispatched', content: 'reject-test', display: true }, dedupeKey: task.id },
|
|
199
|
-
'rejected',
|
|
200
|
-
)
|
|
201
|
-
expect(task.lastStatus).toBe('failed')
|
|
202
|
-
|
|
203
|
-
await runtime.tickScheduler()
|
|
204
|
-
expect(mockDelivery.send).toHaveBeenCalledTimes(2)
|
|
205
|
-
})
|
|
206
|
-
})
|