@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.3.1",
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. handler 抛错被 catch 转为 { isError: true }(standards.md §4.2 禁止抛)
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 优雅返回 isError 而非 crash。
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
- // 装配时序由 tools/verify-scheduler-e2e.cjs 的 S10/S12/S17 真实环境覆盖。
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 抛错返回 isError(runtime 未初始化)', async () => {
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
- const result = await tools[0]!.execute('call-1', { prompt: 'x', schedule: '5m' }, undefined, undefined, fakeCtx)
123
- expect(result).toEqual({
124
- content: [{ type: 'text', text: 'Error: Scheduler not initialized: session not started' }],
125
- details: {},
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 返回结构化失败被 isError 标记(而非 throw)—— standards.md §4.2 契约', async () => {
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 返回 INVALID_SCHEDULE 结构化失败,
163
- // handler 返回 isError result(text 为 service message 本体,无 'Error:' 前缀)。
164
- const result = await tools[0]!.execute('call-err', { prompt: 'x', schedule: 'invalid-cron-expr-xxx' }, undefined, undefined, fakeCtx)
165
- expect(result.isError).toBe(true)
166
- expect(result.content[0].text).toContain('Invalid schedule')
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 同样返回结构化 isError', async () => {
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,handler 返回 isError
175
+ // toggle 不存在的 task id:service 返回 TASK_NOT_FOUND throw message 本体
177
176
  const controlTool = tools.find(t => t.name === 'schedule_control')!
178
- const result = await controlTool.execute('call-ctrl', { action: 'toggle', id: 'deadbeef', enabled: false }, undefined, undefined, fakeCtx)
179
- expect(result.isError).toBe(true)
180
- expect(result.content[0].text).toBe('Task deadbeef not found.')
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
- it('invalid schedule returns isError with message (not throw)', async () => {
29
- const result = await handler({ prompt: 'test', schedule: 'invalid' })
30
- expect(result.isError).toBe(true)
31
- expect(result.content[0]!.text).toContain('Invalid schedule')
32
- const details = result.details as { errorCode: string }
33
- expect(details.errorCode).toBe('INVALID_SCHEDULE')
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 returns isError', async () => {
66
- const result = await handler({ action: 'toggle', enabled: true })
67
- expect(result.isError).toBe(true)
68
- expect(result.content[0]!.text).toContain('id is required')
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 → 结构化 isError + TASK_NOT_FOUND
72
- it('TC5: toggle unknown id returns isError with TASK_NOT_FOUND', async () => {
73
- const result = await handler({ action: 'toggle', id: 'deadbeef', enabled: false })
74
- expect(result.isError).toBe(true)
75
- const details = result.details as { errorCode: string }
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。catch 兜底 INTERNAL + 未初始化异常(R3:handler catch getService(),
117
- // 初始化异常穿透到这里,保持 'Error: Scheduler not initialized' 格式)。
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
- return {
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
- return {
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
- const nextRuns = await computeNextRuns(task.schedule, this.now(), count)
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
- * 业务失败 → 结构化 isError result(不 throw);service 未初始化等初始化异常
31
- * 不在此 catch——穿透到 index.ts execute catch 兜底(R3)。
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};失败 → isError + details.errorCode。
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
- return {
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 }],