@zhushanwen/pi-scheduler 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/__tests__/sdk-contract.test.ts +21 -22
- package/src/__tests__/service.test.ts +3 -0
- package/src/__tests__/tool.test.ts +15 -17
- package/src/index.ts +6 -13
- package/src/service.ts +9 -5
- package/src/tool.ts +7 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"pi": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"pi-package"
|
|
16
16
|
],
|
|
17
17
|
"devDependencies": {
|
|
18
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
18
19
|
"fast-check": "^4.9.0",
|
|
19
20
|
"vitest": "^4.1.8"
|
|
20
21
|
},
|
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
// 1. tool 注册名为 schedule / schedule_control
|
|
6
6
|
// 2. tool 有 execute 函数字段(不是 handler/fn)
|
|
7
7
|
// 3. execute 是 async function(SDK 期望返回 Promise)
|
|
8
|
-
// 4.
|
|
8
|
+
// 4. 错误路径 throw(W4:pi-agent-core 只对 execute throw 置 isError:true,
|
|
9
|
+
// 返回值里的 isError 字段被 agent-loop 丢弃——错误轮曾被标成功;
|
|
10
|
+
// 锚点 agent-loop.js:453-483/525-547)
|
|
9
11
|
//
|
|
10
12
|
// 不导入 SchedulerRuntime 的内部:只通过 index.ts 的 default export 测,
|
|
11
13
|
// 保证 tool 注册逻辑的入口契约。
|
|
12
14
|
//
|
|
13
15
|
// 关键回归点:runtime 在 session_start 前为 null。execute 通过 getRuntime() 延迟
|
|
14
16
|
// 读取——若在 factory 顶层捕获 runtime! 非空断言,注册时 runtime 为 null,
|
|
15
|
-
// execute 调用会 NPE。此套件验证 session_start 前 execute
|
|
17
|
+
// execute 调用会 NPE。此套件验证 session_start 前 execute 优雅 throw 而非 crash。
|
|
16
18
|
|
|
17
19
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
18
20
|
import { describe, expect, it, vi } from 'vitest'
|
|
@@ -21,7 +23,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
21
23
|
// 会触碰真实用户 FS(~/.pi/agent/scheduler/root/test/scheduler.json 的 renameSync/existsSync/
|
|
22
24
|
// unlinkSync;该目录是活跃数据目录,一旦路径存在会 rename+unlink 真实用户数据且结果不确定)。
|
|
23
25
|
// mock 掉 importer 模块:session_start 装配路径仍被调用(vi.fn 记录调用),FS 副作用为零;
|
|
24
|
-
// 装配时序由
|
|
26
|
+
// 装配时序由 scripts/verify-scheduler-e2e.cjs 的 S10/S12/S17 真实环境覆盖。
|
|
25
27
|
// mock 返回 vi.fn() 作为延迟删除 cleanup(MF-1:turn_end / session_shutdown 装配链路可测)。
|
|
26
28
|
vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
|
|
27
29
|
|
|
@@ -111,7 +113,7 @@ describe('pi-scheduler SDK contract', () => {
|
|
|
111
113
|
}
|
|
112
114
|
})
|
|
113
115
|
|
|
114
|
-
it('session_start 前 execute
|
|
116
|
+
it('session_start 前 execute throw(runtime 未初始化;W4:pi 只采信 throw)', async () => {
|
|
115
117
|
const { pi, tools, events } = createMockPi()
|
|
116
118
|
schedulerExtension(pi)
|
|
117
119
|
const fakeCtx = createFakeCtx()
|
|
@@ -119,12 +121,10 @@ describe('pi-scheduler SDK contract', () => {
|
|
|
119
121
|
// 不触发 session_start,runtime 仍为 null
|
|
120
122
|
expect(events.get('session_start')).toBeDefined()
|
|
121
123
|
|
|
122
|
-
|
|
123
|
-
expect(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
isError: true,
|
|
127
|
-
})
|
|
124
|
+
// W4:错误 throw 传播——pi catch 后置 isError:true,文案(含 Error: 前缀格式,R3)进 toolResult
|
|
125
|
+
await expect(
|
|
126
|
+
tools[0]!.execute('call-1', { prompt: 'x', schedule: '5m' }, undefined, undefined, fakeCtx),
|
|
127
|
+
).rejects.toThrow('Error: Scheduler not initialized: session not started')
|
|
128
128
|
})
|
|
129
129
|
|
|
130
130
|
it('session_start 后 execute 正常返回结果', async () => {
|
|
@@ -153,31 +153,30 @@ describe('pi-scheduler SDK contract', () => {
|
|
|
153
153
|
expect(result.isError).toBeFalsy()
|
|
154
154
|
})
|
|
155
155
|
|
|
156
|
-
it('handler
|
|
156
|
+
it('handler 业务失败 throw(W4:返回值 isError 被 pi 丢弃,throw 才被采信)', async () => {
|
|
157
157
|
const { pi, tools, events } = createMockPi()
|
|
158
158
|
schedulerExtension(pi)
|
|
159
159
|
const fakeCtx = createFakeCtx()
|
|
160
160
|
await events.get('session_start')!({ type: 'session_start', reason: 'startup' }, fakeCtx)
|
|
161
161
|
|
|
162
|
-
// 非法 cron 表达式:service.create
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
expect(result.content[0].text).not.toContain('Error:')
|
|
162
|
+
// 非法 cron 表达式:service.create 失败 → toToolResult throw service message 本体
|
|
163
|
+
//(无 'Error:' 前缀——前缀格式仅 index.ts 的初始化异常兜底使用)
|
|
164
|
+
await expect(
|
|
165
|
+
tools[0]!.execute('call-err', { prompt: 'x', schedule: 'invalid-cron-expr-xxx' }, undefined, undefined, fakeCtx),
|
|
166
|
+
).rejects.toThrow('Invalid schedule')
|
|
168
167
|
})
|
|
169
168
|
|
|
170
|
-
it('schedule_control tool
|
|
169
|
+
it('schedule_control tool 业务失败同样 throw(W4)', async () => {
|
|
171
170
|
const { pi, tools, events } = createMockPi()
|
|
172
171
|
schedulerExtension(pi)
|
|
173
172
|
const fakeCtx = createFakeCtx()
|
|
174
173
|
await events.get('session_start')!({ type: 'session_start', reason: 'startup' }, fakeCtx)
|
|
175
174
|
|
|
176
|
-
// toggle 不存在的 task id:service 返回 TASK_NOT_FOUND
|
|
175
|
+
// toggle 不存在的 task id:service 返回 TASK_NOT_FOUND → throw message 本体
|
|
177
176
|
const controlTool = tools.find(t => t.name === 'schedule_control')!
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
await expect(
|
|
178
|
+
controlTool.execute('call-ctrl', { action: 'toggle', id: 'deadbeef', enabled: false }, undefined, undefined, fakeCtx),
|
|
179
|
+
).rejects.toThrow('Task deadbeef not found.')
|
|
181
180
|
})
|
|
182
181
|
|
|
183
182
|
it('registerCommand 注册了名为 schedule 的命令', () => {
|
|
@@ -12,6 +12,9 @@ describe('SchedulerService', () => {
|
|
|
12
12
|
|
|
13
13
|
beforeEach(() => {
|
|
14
14
|
backend = new MockSchedulerBackend()
|
|
15
|
+
// 固定时间避免 clock-boundary flake:formatRelativeTime 内部读 Date.now(),
|
|
16
|
+
// 两次读之间的延迟可能导致 "in 1h" 变成 "in 59m"。
|
|
17
|
+
backend.nowValue = Date.now()
|
|
15
18
|
service = new SchedulerService(new SchedulerRuntime(backend, mockCtx), () => backend.now())
|
|
16
19
|
})
|
|
17
20
|
|
|
@@ -25,12 +25,12 @@ describe('schedule tool', () => {
|
|
|
25
25
|
expect(details.task.schedule).toEqual({ mode: 'interval', intervalMs: 300000 })
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
expect(
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
// W4:业务失败 throw(pi 只对 execute throw 置 isError:true,返回值里的 isError
|
|
29
|
+
// 被 agent-loop 丢弃——错误轮曾被标成功)。原 errorCode details 随 throw 不再产出。
|
|
30
|
+
it('invalid schedule throws with message (W4: pi 采信 throw)', async () => {
|
|
31
|
+
await expect(handler({ prompt: 'test', schedule: 'invalid' })).rejects.toThrow(
|
|
32
|
+
'Invalid schedule',
|
|
33
|
+
)
|
|
34
34
|
})
|
|
35
35
|
})
|
|
36
36
|
|
|
@@ -62,19 +62,17 @@ describe('schedule_control tool', () => {
|
|
|
62
62
|
expect(result.content[0]!.text).toContain('disabled')
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
it('missing id on toggle
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
it('missing id on toggle throws (W4)', async () => {
|
|
66
|
+
await expect(handler({ action: 'toggle', enabled: true })).rejects.toThrow(
|
|
67
|
+
'id is required',
|
|
68
|
+
)
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
// TC5 tool 侧:toggle 不存在 id →
|
|
72
|
-
it('TC5: toggle unknown id
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
expect(details.errorCode).toBe('TASK_NOT_FOUND')
|
|
77
|
-
expect(result.content[0]!.text).toBe('Task deadbeef not found.')
|
|
71
|
+
// TC5 tool 侧:toggle 不存在 id → throw(service message 原文即 TASK_NOT_FOUND 文案)
|
|
72
|
+
it('TC5: toggle unknown id throws with TASK_NOT_FOUND message (W4)', async () => {
|
|
73
|
+
await expect(handler({ action: 'toggle', id: 'deadbeef', enabled: false })).rejects.toThrow(
|
|
74
|
+
'Task deadbeef not found.',
|
|
75
|
+
)
|
|
78
76
|
})
|
|
79
77
|
|
|
80
78
|
it('deletes task', async () => {
|
package/src/index.ts
CHANGED
|
@@ -113,8 +113,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
113
113
|
|
|
114
114
|
// 注册 schedule tool
|
|
115
115
|
// execute 内联闭包:从 SDK 全签名 (toolCallId, params, signal, onUpdate, ctx) 提取 params 转调
|
|
116
|
-
// handler
|
|
117
|
-
//
|
|
116
|
+
// handler。错误路径 throw(W4):pi 只对 execute throw 置 isError:true(返回值里的
|
|
117
|
+
// isError 被 agent-loop 丢弃);getService() 未初始化异常穿透到这里,包装
|
|
118
|
+
// 'Error: Scheduler not initialized' 格式(R3 格式保持)。
|
|
118
119
|
pi.registerTool({
|
|
119
120
|
name: 'schedule',
|
|
120
121
|
label: 'Schedule',
|
|
@@ -131,16 +132,12 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
131
132
|
try {
|
|
132
133
|
return await createScheduleHandler(getService())(params)
|
|
133
134
|
} catch (err) {
|
|
134
|
-
|
|
135
|
-
content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
136
|
-
details: {},
|
|
137
|
-
isError: true,
|
|
138
|
-
}
|
|
135
|
+
throw new Error(`Error: ${err instanceof Error ? err.message : String(err)}`)
|
|
139
136
|
}
|
|
140
137
|
},
|
|
141
138
|
})
|
|
142
139
|
|
|
143
|
-
// 注册 schedule_control tool
|
|
140
|
+
// 注册 schedule_control tool(错误路径同上:throw 让 pi 置 isError)
|
|
144
141
|
pi.registerTool({
|
|
145
142
|
name: 'schedule_control',
|
|
146
143
|
label: 'Schedule Control',
|
|
@@ -157,11 +154,7 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
157
154
|
try {
|
|
158
155
|
return await createScheduleControlHandler(getService())(params)
|
|
159
156
|
} catch (err) {
|
|
160
|
-
|
|
161
|
-
content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
162
|
-
details: {},
|
|
163
|
-
isError: true,
|
|
164
|
-
}
|
|
157
|
+
throw new Error(`Error: ${err instanceof Error ? err.message : String(err)}`)
|
|
165
158
|
}
|
|
166
159
|
},
|
|
167
160
|
})
|
package/src/service.ts
CHANGED
|
@@ -71,19 +71,21 @@ export class SchedulerService {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
const count = task.kind === 'once' ? 1 : PREVIEW_RUN_COUNT
|
|
74
|
-
|
|
74
|
+
// 消息内相对时间与 nextRuns 必须同基准:分开读时钟会在整点边界漂移(in 1h → in 59m)
|
|
75
|
+
const now = this.now()
|
|
76
|
+
const nextRuns = await computeNextRuns(task.schedule, now, count)
|
|
75
77
|
// once 单行内联回显(只执行 1 次,编号列表会误导);recurring 保持 5 行编号列表
|
|
76
78
|
const runPreview =
|
|
77
79
|
task.kind === 'once'
|
|
78
|
-
? `Next run: ${formatRelativeTime(nextRuns[0]
|
|
80
|
+
? `Next run: ${formatRelativeTime(nextRuns[0]!, now)}`
|
|
79
81
|
: [
|
|
80
82
|
'Next 5 runs:',
|
|
81
|
-
...nextRuns.map((t, i) => ` ${i + 1}. ${formatRelativeTime(t)}`),
|
|
83
|
+
...nextRuns.map((t, i) => ` ${i + 1}. ${formatRelativeTime(t, now)}`),
|
|
82
84
|
].join('\n')
|
|
83
85
|
// 一行紧凑:name(id) + schedule(含 kind 信息) + expires + force。
|
|
84
86
|
// 删冗余 Kind 行(formatSchedule 已含 once/every);Expires/Force 合并(默认 no-expires/no-force 显式)。
|
|
85
87
|
const expiresLabel = task.expiresAt
|
|
86
|
-
? `expires ${formatRelativeTime(task.expiresAt)}`
|
|
88
|
+
? `expires ${formatRelativeTime(task.expiresAt, now)}`
|
|
87
89
|
: 'no-expires'
|
|
88
90
|
const forceLabel = task.force ? 'force' : 'no-force'
|
|
89
91
|
const message = [
|
|
@@ -99,8 +101,10 @@ export class SchedulerService {
|
|
|
99
101
|
if (tasks.length === 0) {
|
|
100
102
|
return { success: true, message: 'No scheduled tasks.', data: { tasks: [] } }
|
|
101
103
|
}
|
|
104
|
+
// 同 create:同一基准渲染全部相对时间,避免逐项读时钟的边界漂移
|
|
105
|
+
const now = this.now()
|
|
102
106
|
const message = tasks.map(t =>
|
|
103
|
-
`${t.enabled ? '●' : '○'} ${t.id} ${t.name} · ${formatSchedule(t.schedule, t.kind)} · ${formatRelativeTime(t.nextRunAt)}`
|
|
107
|
+
`${t.enabled ? '●' : '○'} ${t.id} ${t.name} · ${formatSchedule(t.schedule, t.kind)} · ${formatRelativeTime(t.nextRunAt, now)}`
|
|
104
108
|
).join('\n')
|
|
105
109
|
return { success: true, message, data: { tasks } }
|
|
106
110
|
}
|
package/src/tool.ts
CHANGED
|
@@ -27,8 +27,9 @@ export const scheduleGuidelines = [
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* schedule tool handler(SchedulerService 瘦壳,无独立业务逻辑)。
|
|
30
|
-
* 业务失败 →
|
|
31
|
-
*
|
|
30
|
+
* 业务失败 → throw(pi 只对 execute throw 置 isError:true,返回值里的 isError
|
|
31
|
+
* 被 agent-loop 丢弃——W4 修复,锚点 agent-loop.js:453-483);service 未初始化等
|
|
32
|
+
* 初始化异常不在此 catch——穿透到 index.ts execute 的 catch 兜底(R3)。
|
|
32
33
|
*/
|
|
33
34
|
export function createScheduleHandler(service: SchedulerService) {
|
|
34
35
|
return async (params: ScheduleParamsT) => {
|
|
@@ -86,19 +87,16 @@ export function createScheduleControlHandler(service: SchedulerService) {
|
|
|
86
87
|
|
|
87
88
|
/**
|
|
88
89
|
* ServiceResult → tool execute 返回。
|
|
89
|
-
* 成功 → {content: [message], details: data};失败 →
|
|
90
|
+
* 成功 → {content: [message], details: data};失败 → throw(W4:pi 契约只有
|
|
91
|
+
* execute throw 才置 isError:true,pi catch 后 message 原样成为 toolResult content,
|
|
92
|
+
* 错误轮不再被标成功)。
|
|
90
93
|
*/
|
|
91
94
|
function toToolResult(result: ServiceResult): {
|
|
92
95
|
content: { type: 'text'; text: string }[]
|
|
93
96
|
details: unknown
|
|
94
|
-
isError?: boolean
|
|
95
97
|
} {
|
|
96
98
|
if (!result.success) {
|
|
97
|
-
|
|
98
|
-
content: [{ type: 'text' as const, text: result.message }],
|
|
99
|
-
details: { errorCode: result.errorCode },
|
|
100
|
-
isError: true,
|
|
101
|
-
}
|
|
99
|
+
throw new Error(result.message)
|
|
102
100
|
}
|
|
103
101
|
return {
|
|
104
102
|
content: [{ type: 'text' as const, text: result.message }],
|