@zhushanwen/pi-scheduler 0.0.5 → 0.1.1

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 ADDED
@@ -0,0 +1,175 @@
1
+ # scheduler
2
+
3
+ 定时任务调度扩展:按 duration(`5m` / `2h` / `1d`)间隔或 cron 表达式,在指定时间向 agent 注入消息。支持一次性提醒(once)、强制触发(force)、过期策略(expires)与持久化(重启后任务保留)。
4
+
5
+ ## 简介与安装
6
+
7
+ pi-scheduler 是 xyz-agent 的 **mandatory 扩展**(`packages/shared/src/mandatory-extensions.json`,tier: `feature`)——xyz-agent 启动时自动安装并启用,无需手动操作。
8
+
9
+ 独立 pi 环境手动安装:
10
+
11
+ ```bash
12
+ npm install @zhushanwen/pi-scheduler
13
+ ```
14
+
15
+ 安装后扩展在 pi 会话启动时自动装配(见下节),无需额外配置。
16
+
17
+ ## 激活方式
18
+
19
+ 扩展 factory(`src/index.ts`)监听 pi 的 session 生命周期事件,在 `session_start` 时装配完整调用链:
20
+
21
+ ```
22
+ session_start
23
+ └─ PiSchedulerBackend(FS 读写 + pi.sendMessage + 时间源)
24
+ └─ SchedulerRuntime(内存态 + 30s tick 调度 + 限流)
25
+ └─ SchedulerService(tool/command 唯一业务入口)
26
+ ├─ runtime.loadTasks(backend.loadTasks()) ← 从磁盘恢复任务
27
+ ├─ runtime.startScheduler() ← 启动 tick
28
+ └─ 注册 scheduler widget(30s 刷新)
29
+ ```
30
+
31
+ `session_shutdown` 时执行 `runtime.persistSync()`(立即写盘)并停止 tick。任务状态因此随 session 持久化:关闭重开 session 后任务仍存在、到期仍会触发。
32
+
33
+ ## /schedule 命令用法
34
+
35
+ 注册为 `/schedule` 命令。无参数时显示 usage;第一个参数匹配子命令关键词则走子命令分支,否则尝试创建任务。
36
+
37
+ ### 子命令
38
+
39
+ | 子命令 | 行为 |
40
+ |--------|------|
41
+ | `/schedule list` | 列出所有任务(id、名称、调度、下次执行时间) |
42
+ | `/schedule on <id>` | 启用任务 |
43
+ | `/schedule off <id>` | 停用任务(推荐临时暂停用 off,不用 rm) |
44
+ | `/schedule rm <id>` | 删除任务 |
45
+ | `/schedule run <id>` | 立即执行任务 |
46
+ | `/schedule once <schedule> <prompt>` | 创建一次性提醒(kind=once) |
47
+ | `/schedule cron <expression> <prompt>` | 创建 cron 任务 |
48
+
49
+ 任务 id 由 8 位 hex 自动生成,`list` 后从输出中获取。
50
+
51
+ ### 引号转义
52
+
53
+ 参数用 shell 风格引号解析(`tokenizeQuoted`,`src/commands.ts`):
54
+
55
+ - 单引号 `'...'` 或双引号 `"..."` 内的内容作为一个 token,引号字符本身被剥离
56
+ - 含空格的多词参数(cron 表达式、prompt)**必须加引号**,否则会被拆成多个 token
57
+
58
+ 例如 cron 表达式 `0 9 * * 1-5` 含空格,必须写成 `'/schedule cron '0 9 * * 1-5' standup'`;prompt `check build` 同理写成 `'/schedule 5m 'check build''`。
59
+
60
+ ### 子命令补全
61
+
62
+ 输入 `/schedule ` 后 Tab 补全子命令关键词(list/on/off/rm/run/once/cron);`on`/`off`/`rm`/`run` 之后补全当前任务 id。
63
+
64
+ ## schedule 语法
65
+
66
+ ### duration(间隔调度)
67
+
68
+ `<数字><单位>`,数字与单位间可有空格,**大小写不敏感**:
69
+
70
+ | 单位 | 含义 | 乘数 |
71
+ |------|------|------|
72
+ | `s` / `sec` / `second` / `seconds` | 秒 | 1,000 ms |
73
+ | `m` / `min` / `minute` / `minutes` | 分 | 60,000 ms |
74
+ | `h` / `hr` / `hour` / `hours` | 时 | 3,600,000 ms |
75
+ | `d` / `day` / `days` | 天 | 86,400,000 ms |
76
+
77
+ 例如:`5m`、`2h`、`1d`、`30seconds`、`2hours`。非法输入(裸数字、未知单位、空串、负值)解析失败 → 创建任务报 `INVALID_SCHEDULE`。
78
+
79
+ ### cron(时间点调度)
80
+
81
+ 标准 cron 表达式,支持 5 字段与 6 字段:
82
+
83
+ - **5 字段**(分 时 日 月 周):自动补 `0` 秒字段(如 `0 9 * * 1-5` → `0 0 9 * * 1-5`)
84
+ - **6 字段**(秒 分 时 日 月 周):原样使用
85
+
86
+ **含空格自动走 cron 分支**:schedule 输入中不含空格 → 按 duration 解析;含空格 → 按 cron 解析。因此 cron 表达式必须包含空格(正常写法天然如此),duration 不得含空格。
87
+
88
+ ## 选项语义
89
+
90
+ `schedule` tool 的 `kind` / `name` / `expires` / `force` 参数(`/schedule` 命令的 `once`/`cron` 前缀对应 kind):
91
+
92
+ | 选项 | 取值 | 语义 |
93
+ |------|------|------|
94
+ | `kind` | `recurring`(默认)/ `once` | recurring 每次触发后按 schedule 重算下次时间;once 触发一次后自动删除 |
95
+ | `name` | 字符串 | 任务可读名称,缺省从 prompt 自动生成(≤30 字原样,超长截前 27 字加省略号) |
96
+ | `expires` | duration 字符串 / `never` | recurring 任务的过期时间:`now + duration`;`never` 永不过期;缺省 7 天。**once 任务不设过期**(触发即删,expires 忽略) |
97
+ | `force` | `true` / `false`(默认) | `true` 时即使 agent 忙(非 idle 或有 pending 消息)也强制 dispatch;`false` 时忙则延迟到下次 tick |
98
+
99
+ ## 示例
100
+
101
+ **recurring 间隔任务**(每 5 分钟检查构建):
102
+
103
+ ```
104
+ /schedule 5m 'check build'
105
+ ```
106
+
107
+ **一次性提醒**(10 秒后提醒):
108
+
109
+ ```
110
+ /schedule once 10s remind
111
+ ```
112
+
113
+ **cron 任务**(工作日早 9 点站会):
114
+
115
+ ```
116
+ /schedule cron '0 9 * * 1-5' standup
117
+ ```
118
+
119
+ **force 立即触发**(tool 调用:即使忙也执行):
120
+
121
+ ```json
122
+ {"prompt": "deploy staging", "schedule": "*/10 * * * *", "force": true}
123
+ ```
124
+
125
+ **永不过期**(tool 调用:长期任务不设 7 天默认过期):
126
+
127
+ ```json
128
+ {"prompt": "monthly report", "schedule": "1d", "expires": "never"}
129
+ ```
130
+
131
+ ## 限制与运行时行为
132
+
133
+ | 限制/行为 | 值 | 说明 |
134
+ |-----------|-----|------|
135
+ | 任务上限 | **50**(`MAX_TASKS`) | 超过抛 `Task limit reached (50)`,需先删除任务 |
136
+ | 触发频率上限 | **6 次/分钟**(`RATE_LIMIT_PER_MINUTE`) | 滑动 60s 窗口。`/schedule run` 超限返回 `DISPATCH_SKIPPED`;tick 自动 dispatch 超限静默跳过 |
137
+ | tick 间隔 | **30s**(`TICK_INTERVAL_MS`) | 到期任务在下一个 tick 被 dispatch;实际触发时间可能比计划晚最多 30s |
138
+ | 默认过期 | **7 天**(`DEFAULT_EXPIRY_MS`) | recurring 任务缺省 `expires` 时;`expires: 'never'` 关闭 |
139
+ | once 任务 | 触发后自动删除 | 不参与后续调度 |
140
+ | cron 失效 | 任务停用 + `lastStatus=failed` + `lastError='cron expression invalid'` | 不会用 `now()` 兜底导致每 tick 重触发死循环 |
141
+ | persist 失败 | `console.warn` + 内存态保留 + `lastError='persist failed'` | 不打断调度,下次 tick 继续尝试 |
142
+ | 忙时 dispatch | 非 force 任务在 agent 忙(非 idle / 有 pending 消息)时跳过,延迟到下次 tick | force=true 可绕过 |
143
+ | history | 保留最近 **20** 条执行记录 | 超出丢弃最旧 |
144
+ | 持久化 | 每次变更写盘(session_shutdown 强制同步写) | 重启后任务保留 |
145
+
146
+ 错误语义:创建时 schedule 解析失败 → `INVALID_SCHEDULE`;`run`/`toggle`/`delete` 引用不存在的 id → `TASK_NOT_FOUND`;`run` 时任务 disabled / busy / rate-limited → `DISPATCH_SKIPPED`(message 含 `busy, disabled, or rate-limited`)。
147
+
148
+ ## 数据存储位置
149
+
150
+ 任务存储为单个 JSON 文件,按 workspace 路径隔离(不同 cwd 存不同文件):
151
+
152
+ ```
153
+ ~/.pi/agent/scheduler/<root>/<segments>/scheduler.json
154
+ ```
155
+
156
+ - `<root>`:路径根(如 `/` → `root`)
157
+ - `<segments>`:cwd 相对根的路径段(如 `/Users/me/project` → `Users/me/project`)
158
+
159
+ 删除文件即清除全部任务;损坏的 JSON 自动降级为空 store 并 `console.warn`。
160
+
161
+ ## 开发
162
+
163
+ ```bash
164
+ pnpm test # 运行全部 vitest 测试(等价 npx vitest run)
165
+ npx vitest run src/__tests__/<file>.test.ts # 单个文件
166
+ ```
167
+
168
+ 测试策略:
169
+
170
+ - **依赖反转**:`SchedulerRuntime` 只依赖 `SchedulerBackend` 接口(`sendMessage` / `persist` / `now`),不触碰 FS/pi。测试注入 `MockSchedulerBackend`(`src/backend.ts` 同文件 export)实现零副作用测试:`sentMessages` 记录发送、`persistedStores` 记录持久化、`persistError` 注入失败、`nowValue` 固定时间源
171
+ - **纯函数**:`parseDuration` / `formatDuration` / `parseSchedule` / `computeNextRunAt` / `computeNextRuns`(`src/parsing.ts`)无副作用,可直接断言
172
+ - **property-based**:`src/__tests__/property.test.ts` 用 fast-check 生成随机组合验证不变量(interval 精确、duration round-trip、format↔parse 一致性),生成器范围契约见该文件注释
173
+ - **round-trip**:`store.test.ts`(mock fs)验证路径与 GC;`store-roundtrip.test.ts`(真实 fs,os.tmpdir 隔离)验证 load 白名单字段(lastError/lastStatus/lastRunAt/expiresAt/force/history)持久化往返
174
+
175
+ 扩展内部结构:`backend.ts`(后端抽象)→ `runtime.ts`(调度核心)→ `service.ts`(业务入口)→ `tool.ts`/`commands.ts`(tool 与 /schedule 命令适配层)→ `widget.ts`(状态栏 widget)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.0.5",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "pi": {
@@ -13,6 +13,7 @@
13
13
  "pi-package"
14
14
  ],
15
15
  "devDependencies": {
16
+ "fast-check": "^4.9.0",
16
17
  "vitest": "^4.1.8"
17
18
  },
18
19
  "files": [
@@ -22,14 +23,14 @@
22
23
  ],
23
24
  "peerDependencies": {
24
25
  "@earendil-works/pi-coding-agent": "*",
25
- "@sinclair/typebox": "*",
26
- "croner": "^9.0.0"
26
+ "croner": "^9.0.0",
27
+ "typebox": "*"
27
28
  },
28
29
  "peerDependenciesMeta": {
29
30
  "@earendil-works/pi-coding-agent": {
30
31
  "optional": true
31
32
  },
32
- "@sinclair/typebox": {
33
+ "typebox": {
33
34
  "optional": true
34
35
  },
35
36
  "croner": {
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { MockSchedulerBackend } from '../backend.js'
4
+ import { SchedulerRuntime } from '../runtime.js'
5
+ import type { SchedulerStore } from '../types.js'
6
+
7
+ const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
8
+
9
+ describe('MockSchedulerBackend', () => {
10
+ it('records sendMessage calls', async () => {
11
+ const backend = new MockSchedulerBackend()
12
+ await backend.sendMessage(
13
+ { content: 'hi', customType: 'pi-scheduler:dispatched', display: true },
14
+ { deliverAs: 'followUp', triggerTurn: true },
15
+ )
16
+ expect(backend.sentMessages).toHaveLength(1)
17
+ expect(backend.sentMessages[0]!.msg).toEqual({
18
+ content: 'hi',
19
+ customType: 'pi-scheduler:dispatched',
20
+ display: true,
21
+ })
22
+ expect(backend.sentMessages[0]!.opts).toEqual({ deliverAs: 'followUp', triggerTurn: true })
23
+ })
24
+
25
+ it('records persist calls and throws injected persistError', async () => {
26
+ const backend = new MockSchedulerBackend()
27
+ const store: SchedulerStore = { version: 1, tasks: [] }
28
+
29
+ await backend.persist(store)
30
+ expect(backend.persistedStores).toHaveLength(1)
31
+ expect(backend.persistedStores[0]).toBe(store)
32
+
33
+ // persistError 注入:persist 抛该错(ERR-6 语义——错误必须能传到调用栈)
34
+ backend.persistError = new Error('disk full')
35
+ await expect(backend.persist(store)).rejects.toThrow('disk full')
36
+ })
37
+
38
+ it('now() returns injected nowValue or Date.now()', () => {
39
+ const backend = new MockSchedulerBackend()
40
+ expect(Math.abs(backend.now() - Date.now())).toBeLessThan(1000)
41
+ backend.nowValue = 123456
42
+ expect(backend.now()).toBe(123456)
43
+ })
44
+
45
+ // ── TC2:new SchedulerRuntime(mockBackend) 可注入单测,零 FS ──
46
+
47
+ it('TC2: SchedulerRuntime with MockSchedulerBackend constructs and dispatches via mock', async () => {
48
+ const backend = new MockSchedulerBackend()
49
+ // 构造不抛错(无需 cwd/pi/store mock)
50
+ const runtime = new SchedulerRuntime(backend, mockCtx)
51
+ const task = await runtime.addTask('probe', { mode: 'interval', intervalMs: 60000 })
52
+ expect(task).toBeDefined()
53
+ // addTask 的 persist 走了 mock backend(零 FS)
54
+ expect(backend.persistedStores).toHaveLength(1)
55
+
56
+ await runtime.dispatchTask(task)
57
+
58
+ // dispatch 消息走 mock backend,含 task.prompt
59
+ expect(backend.sentMessages).toHaveLength(1)
60
+ expect(backend.sentMessages[0]!.msg.content).toBe('probe')
61
+ expect(backend.sentMessages[0]!.msg.customType).toBe('pi-scheduler:dispatched')
62
+ })
63
+ })
@@ -1,17 +1,11 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
+ import { MockSchedulerBackend } from '../backend.js'
3
4
  import { executeScheduleCommand, registerScheduleCommand } from '../commands.js'
4
5
  import { SchedulerRuntime } from '../runtime.js'
6
+ import { SchedulerService } from '../service.js'
5
7
 
6
- // Mock store 避免 FS 副作用(runtime constructor 调 createStore)。
7
- vi.mock('../store.js', () => ({
8
- createStore: () => ({
9
- load: () => ({ version: 1, tasks: [] }),
10
- persist: vi.fn(),
11
- persistSync: vi.fn(),
12
- storePath: '/mocked.json',
13
- }),
14
- }))
8
+ // MockSchedulerBackend FS 副作用,无需 mock store.js。
15
9
 
16
10
  interface CommandOpts {
17
11
  description: string
@@ -20,7 +14,7 @@ interface CommandOpts {
20
14
  }
21
15
 
22
16
  describe('/schedule command', () => {
23
- let runtime: SchedulerRuntime
17
+ let service: SchedulerService
24
18
  let commandOpts: CommandOpts
25
19
 
26
20
  beforeEach(() => {
@@ -31,31 +25,29 @@ describe('/schedule command', () => {
31
25
  commandOpts = opts
32
26
  },
33
27
  }
34
- runtime = new SchedulerRuntime(
35
- '/test',
36
- { sendMessage: vi.fn() } as never,
37
- { isIdle: () => true, hasPendingMessages: () => false } as never,
28
+ service = new SchedulerService(
29
+ new SchedulerRuntime(new MockSchedulerBackend(), { isIdle: () => true, hasPendingMessages: () => false }),
38
30
  )
39
- registerScheduleCommand(mockPi as never, () => runtime)
31
+ registerScheduleCommand(mockPi as never, () => service)
40
32
  })
41
33
 
42
34
  // ── 子命令路由:list ──
43
35
 
44
36
  it('list returns empty message when no tasks', async () => {
45
- expect(await executeScheduleCommand(runtime, 'list')).toBe('No scheduled tasks.')
37
+ expect(await executeScheduleCommand(service, 'list')).toBe('No scheduled tasks.')
46
38
  })
47
39
 
48
40
  it('list returns formatted task lines', async () => {
49
- await runtime.addTask('check build', { mode: 'interval', intervalMs: 60000 })
50
- const result = await executeScheduleCommand(runtime, 'list')
41
+ await service.create('check build', '5m')
42
+ const result = await executeScheduleCommand(service, 'list')
51
43
  expect(result).toContain('check build')
52
- expect(result).toContain('every 1m')
44
+ expect(result).toContain('every 5m')
53
45
  })
54
46
 
55
47
  it('list marks disabled tasks with ○', async () => {
56
- const task = await runtime.addTask('paused task', { mode: 'interval', intervalMs: 60000 })
57
- await runtime.toggleTask(task.id, false)
58
- const result = await executeScheduleCommand(runtime, 'list')
48
+ const created = await service.create('paused task', '5m')
49
+ await service.toggle(created.data!.task.id, false)
50
+ const result = await executeScheduleCommand(service, 'list')
59
51
  expect(result).toContain('○')
60
52
  expect(result).toContain('paused task')
61
53
  })
@@ -63,110 +55,114 @@ describe('/schedule command', () => {
63
55
  // ── 子命令路由:on / off ──
64
56
 
65
57
  it('off toggles task enabled to false', async () => {
66
- const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
67
- const result = await executeScheduleCommand(runtime, `off ${task.id}`)
58
+ const created = await service.create('test', '5m')
59
+ const result = await executeScheduleCommand(service, `off ${created.data!.task.id}`)
68
60
  expect(result).toContain('disabled')
69
- expect(runtime.getTask(task.id)?.enabled).toBe(false)
61
+ expect(service.runtime.getTask(created.data!.task.id)?.enabled).toBe(false)
70
62
  })
71
63
 
72
64
  it('on toggles task enabled to true', async () => {
73
- const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
74
- await runtime.toggleTask(task.id, false)
75
- const result = await executeScheduleCommand(runtime, `on ${task.id}`)
65
+ const created = await service.create('test', '5m')
66
+ await service.toggle(created.data!.task.id, false)
67
+ const result = await executeScheduleCommand(service, `on ${created.data!.task.id}`)
76
68
  expect(result).toContain('enabled')
77
- expect(runtime.getTask(task.id)?.enabled).toBe(true)
69
+ expect(service.runtime.getTask(created.data!.task.id)?.enabled).toBe(true)
78
70
  })
79
71
 
80
72
  it('off with missing id returns usage', async () => {
81
- expect(await executeScheduleCommand(runtime, 'off')).toBe('Usage: /schedule off <id>')
73
+ expect(await executeScheduleCommand(service, 'off')).toBe('Usage: /schedule off <id>')
82
74
  })
83
75
 
84
76
  it('on with missing id returns usage', async () => {
85
- expect(await executeScheduleCommand(runtime, 'on')).toBe('Usage: /schedule on <id>')
77
+ expect(await executeScheduleCommand(service, 'on')).toBe('Usage: /schedule on <id>')
86
78
  })
87
79
 
80
+ // TC5 command 侧:消息同源(service 产出 TASK_NOT_FOUND message)
88
81
  it('off with unknown id returns not found', async () => {
89
- expect(await executeScheduleCommand(runtime, 'off deadbeef')).toBe('Task deadbeef not found.')
82
+ expect(await executeScheduleCommand(service, 'off deadbeef')).toBe('Task deadbeef not found.')
90
83
  })
91
84
 
92
85
  // ── 子命令路由:rm ──
93
86
 
94
87
  it('rm deletes task', async () => {
95
- const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
96
- const result = await executeScheduleCommand(runtime, `rm ${task.id}`)
88
+ const created = await service.create('test', '5m')
89
+ const result = await executeScheduleCommand(service, `rm ${created.data!.task.id}`)
97
90
  expect(result).toContain('deleted')
98
- expect(runtime.getTask(task.id)).toBeUndefined()
91
+ expect(service.runtime.getTask(created.data!.task.id)).toBeUndefined()
99
92
  })
100
93
 
101
94
  it('rm with missing id returns usage', async () => {
102
- expect(await executeScheduleCommand(runtime, 'rm')).toBe('Usage: /schedule rm <id>')
95
+ expect(await executeScheduleCommand(service, 'rm')).toBe('Usage: /schedule rm <id>')
103
96
  })
104
97
 
105
98
  it('rm with unknown id returns not found', async () => {
106
- expect(await executeScheduleCommand(runtime, 'rm deadbeef')).toBe('Task deadbeef not found.')
99
+ expect(await executeScheduleCommand(service, 'rm deadbeef')).toBe('Task deadbeef not found.')
107
100
  })
108
101
 
109
102
  // ── 子命令路由:run ──
110
103
 
111
104
  it('run executes task', async () => {
112
- const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
113
- const result = await executeScheduleCommand(runtime, `run ${task.id}`)
105
+ const created = await service.create('test', '5m')
106
+ const result = await executeScheduleCommand(service, `run ${created.data!.task.id}`)
114
107
  expect(result).toContain('executed')
115
108
  // dispatchTask 更新 task 对象(同一引用),runCount 自增到 1。
116
- expect(runtime.getTask(task.id)?.runCount).toBe(1)
109
+ expect(service.runtime.getTask(created.data!.task.id)?.runCount).toBe(1)
117
110
  })
118
111
 
119
112
  it('run with missing id returns usage', async () => {
120
- expect(await executeScheduleCommand(runtime, 'run')).toBe('Usage: /schedule run <id>')
113
+ expect(await executeScheduleCommand(service, 'run')).toBe('Usage: /schedule run <id>')
121
114
  })
122
115
 
123
116
  it('run with unknown id returns not found', async () => {
124
- expect(await executeScheduleCommand(runtime, 'run deadbeef')).toBe('Task deadbeef not found.')
117
+ expect(await executeScheduleCommand(service, 'run deadbeef')).toBe('Task deadbeef not found.')
125
118
  })
126
119
 
127
120
  // ── 创建任务分支 ──
128
121
 
129
122
  it('creates interval task from /schedule 5m check build', async () => {
130
- const result = await executeScheduleCommand(runtime, '5m check build')
123
+ const result = await executeScheduleCommand(service, '5m check build')
131
124
  expect(result).toContain('check build')
132
125
  expect(result).toContain('every 5m')
133
- expect(runtime.listTasks()).toHaveLength(1)
126
+ expect(service.runtime.listTasks()).toHaveLength(1)
134
127
  })
135
128
 
136
129
  it('created interval task is recurring by default', async () => {
137
- await executeScheduleCommand(runtime, '5m check build')
138
- const task = runtime.listTasks()[0]!
130
+ await executeScheduleCommand(service, '5m check build')
131
+ const task = service.runtime.listTasks()[0]!
139
132
  expect(task.kind).toBe('recurring')
140
133
  })
141
134
 
142
135
  it('creates once task from /schedule once 10s remind', async () => {
143
- const result = await executeScheduleCommand(runtime, 'once 10s remind me')
136
+ const result = await executeScheduleCommand(service, 'once 10s remind me')
144
137
  expect(result).toContain('remind me')
138
+ // once 显示为 'once in 10s'(非误导性的 'every 10s')
139
+ expect(result).toContain('once in 10s')
140
+ expect(result).not.toContain('every 10s')
145
141
  // once 任务 dispatch 后会被删除,但创建时尚未 dispatch
146
- expect(runtime.listTasks()).toHaveLength(1)
147
- const task = runtime.listTasks()[0]!
142
+ expect(service.runtime.listTasks()).toHaveLength(1)
143
+ const task = service.runtime.listTasks()[0]!
148
144
  expect(task.kind).toBe('once')
149
145
  })
150
146
 
151
147
  // Quote-aware tokenizer 修复后,cron 'expr' 能正确提取整个表达式。
152
148
  it('creates cron task from quoted expression', async () => {
153
- const result = await executeScheduleCommand(runtime, "cron '*/10 * * * *' prompt")
149
+ const result = await executeScheduleCommand(service, "cron '*/10 * * * *' prompt")
154
150
  expect(result).toContain('created')
155
151
  expect(result).toContain('*/10 * * * *')
156
- expect(runtime.listTasks()).toHaveLength(1)
152
+ expect(service.runtime.listTasks()).toHaveLength(1)
157
153
  })
158
154
 
159
155
  it('creates cron task from double-quoted expression', async () => {
160
- const result = await executeScheduleCommand(runtime, 'cron "0 9 * * 1-5" standup reminder')
156
+ const result = await executeScheduleCommand(service, 'cron "0 9 * * 1-5" standup reminder')
161
157
  expect(result).toContain('created')
162
158
  expect(result).toContain('0 9 * * 1-5')
163
- expect(runtime.listTasks()).toHaveLength(1)
159
+ expect(service.runtime.listTasks()).toHaveLength(1)
164
160
  })
165
161
 
166
162
  // Unquoted multi-token cron still fails -- tokenizer cannot distinguish cron fields from prompt.
167
163
  // Users should quote the cron expression or use the schedule tool (JSON params are unambiguous).
168
164
  it('cron branch fails on unquoted multi-token expression (use quotes)', async () => {
169
- const result = await executeScheduleCommand(runtime, 'cron */10 * * * * prompt')
165
+ const result = await executeScheduleCommand(service, 'cron */10 * * * * prompt')
170
166
  expect(result).toMatch(/^Invalid schedule:/)
171
167
  expect(result).toContain('*/10')
172
168
  })
@@ -174,21 +170,21 @@ describe('/schedule command', () => {
174
170
  // ── 错误分支 ──
175
171
 
176
172
  it('invalid schedule returns error message', async () => {
177
- const result = await executeScheduleCommand(runtime, 'invalid-duration-str')
173
+ const result = await executeScheduleCommand(service, 'invalid-duration-str')
178
174
  expect(result).toMatch(/invalid|usage/i)
179
175
  })
180
176
 
181
177
  it('schedule with no prompt returns usage', async () => {
182
- const result = await executeScheduleCommand(runtime, '5m')
178
+ const result = await executeScheduleCommand(service, '5m')
183
179
  expect(result).toBe('Usage: /schedule <schedule> <prompt>')
184
180
  })
185
181
 
186
182
  it('no args returns TUI not-implemented message', async () => {
187
- const result = await executeScheduleCommand(runtime, '')
183
+ const result = await executeScheduleCommand(service, '')
188
184
  expect(result).toContain('not yet implemented')
189
185
  })
190
186
 
191
- it('returns error when runtime is null', async () => {
187
+ it('returns error when service is null', async () => {
192
188
  expect(await executeScheduleCommand(null, 'list')).toBe('Scheduler not initialized: session not started.')
193
189
  })
194
190
 
@@ -215,7 +211,8 @@ describe('/schedule command', () => {
215
211
  })
216
212
 
217
213
  it('completes task ids after on/off/rm/run', async () => {
218
- const task = await runtime.addTask('mytask', { mode: 'interval', intervalMs: 60000 })
214
+ const created = await service.create('mytask', '5m')
215
+ const task = created.data!.task
219
216
  // 注意:路由要求 parts.length >= 2 才进 task-id 分支('on ' 单 token 进子命令分支)。
220
217
  // 当前实现对部分输入的 id 不做过滤,返回所有 task id。
221
218
  const completions = commandOpts.getArgumentCompletions(`on ${task.id.slice(0, 2)}`) as Array<{ label: string; description: string }>
@@ -224,7 +221,7 @@ describe('/schedule command', () => {
224
221
  expect(completions.find(c => c.label === task.id)?.description).toContain('mytask')
225
222
  })
226
223
 
227
- it('returns null for completion when runtime missing and prefix has 2 tokens', () => {
224
+ it('returns null for completion when service missing and prefix has 2 tokens', () => {
228
225
  const mockPi = {
229
226
  registerCommand: (_name: string, opts: CommandOpts) => {
230
227
  commandOpts = opts
@@ -58,11 +58,10 @@ describe('computeNextCronRuns', () => {
58
58
  })
59
59
 
60
60
  describe('parseSchedule (cron 分支)', () => {
61
- it('5 字段 cron 表达式补秒字段并附带 note', async () => {
61
+ it('5 字段 cron 表达式补秒字段(note 不再透传到 parseSchedule 结果)', async () => {
62
62
  const result = await parseSchedule('*/10 * * * *')
63
63
  expect(result).toEqual({
64
64
  spec: { mode: 'cron', cronExpression: '0 */10 * * * *' },
65
- note: 'Auto-prepended seconds field (0)',
66
65
  })
67
66
  })
68
67
 
@@ -9,6 +9,13 @@ describe('formatSchedule', () => {
9
9
  expect(formatSchedule({ mode: 'interval', intervalMs: 3_600_000 })).toBe('every 1h')
10
10
  })
11
11
 
12
+ it('formats once kind as "once in X" (not misleading "every X")', () => {
13
+ expect(formatSchedule({ mode: 'interval', intervalMs: 300_000 }, 'once')).toBe('once in 5m')
14
+ expect(formatSchedule({ mode: 'interval', intervalMs: 3_600_000 }, 'once')).toBe('once in 1h')
15
+ // recurring/缺省保持 every
16
+ expect(formatSchedule({ mode: 'interval', intervalMs: 300_000 }, 'recurring')).toBe('every 5m')
17
+ })
18
+
12
19
  it('formats cron spec', () => {
13
20
  expect(formatSchedule({ mode: 'cron', cronExpression: '*/10 * * * *' })).toBe('*/10 * * * *')
14
21
  })
@@ -83,6 +90,34 @@ describe('formatDuration', () => {
83
90
  expect(formatDuration(90_000)).toBe('90s')
84
91
  expect(formatDuration(1500)).toBe('2s')
85
92
  })
93
+
94
+ // TC7 边界值:最大单位整除优先(300000 → '5m' 而非 '300s')
95
+ it('prefers largest unit when ms is evenly divisible', () => {
96
+ expect(formatDuration(300_000)).toBe('5m')
97
+ expect(formatDuration(3_600_000)).toBe('1h')
98
+ expect(formatDuration(86_400_000)).toBe('1d')
99
+ expect(formatDuration(60_000)).toBe('1m')
100
+ expect(formatDuration(1000)).toBe('1s')
101
+ })
102
+
103
+ it('handles zero and negative as 0s', () => {
104
+ expect(formatDuration(0)).toBe('0s')
105
+ expect(formatDuration(-1)).toBe('0s')
106
+ expect(formatDuration(-86_400_000)).toBe('0s')
107
+ })
108
+
109
+ // TC7 修正(design-review R1):1500000 % 60000 === 0 → '25m'(最大单位优先),
110
+ // 而非 design 初稿误写的 '1500s'。非整除大单位落秒用例改用 1510000。
111
+ it('prefers even minute when ms is multiple of 60000 (1500000 → 25m)', () => {
112
+ expect(formatDuration(1_500_000)).toBe('25m')
113
+ })
114
+
115
+ it('falls back to seconds for large non-divisible values (1510000 → 1510s)', () => {
116
+ // 1510000 % 60000 = 10000 ≠ 0 → 不整除 m,落秒分支
117
+ expect(formatDuration(1_510_000)).toBe('1510s')
118
+ // 1500001 % 1000 ≠ 0 → Math.round 兜底
119
+ expect(formatDuration(1_500_001)).toBe('1500s')
120
+ })
86
121
  })
87
122
 
88
123
  describe('generateTaskId', () => {
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import {
4
+ computeNextCronRunAt,
5
+ computeNextCronRuns,
6
+ computeNextRunAt,
4
7
  computeNextRuns,
5
8
  formatDuration,
6
9
  normalizeCronExpression,
@@ -130,6 +133,32 @@ describe('parseSchedule', () => {
130
133
  })
131
134
  })
132
135
 
136
+ describe('computeNextRunAt', () => {
137
+ it('interval 模式 → from + intervalMs(精确相等)', async () => {
138
+ const from = Date.now()
139
+ const next = await computeNextRunAt({ mode: 'interval', intervalMs: 60_000 }, from)
140
+ expect(next).toBe(from + 60_000)
141
+ })
142
+
143
+ it('interval 模式 from 缺省用当前时间', async () => {
144
+ const before = Date.now()
145
+ const next = await computeNextRunAt({ mode: 'interval', intervalMs: 60_000 })
146
+ expect(next!).toBeGreaterThanOrEqual(before + 60_000)
147
+ })
148
+
149
+ it('cron 有效 → 返回未来时间戳(> from)', async () => {
150
+ const from = Date.now()
151
+ const next = await computeNextRunAt({ mode: 'cron', cronExpression: '*/10 * * * *' }, from)
152
+ expect(next).not.toBeUndefined()
153
+ expect(next!).toBeGreaterThan(from)
154
+ })
155
+
156
+ it('cron 无效 → undefined', async () => {
157
+ const next = await computeNextRunAt({ mode: 'cron', cronExpression: 'invalid * *' }, Date.now())
158
+ expect(next).toBeUndefined()
159
+ })
160
+ })
161
+
133
162
  describe('computeNextRuns', () => {
134
163
  it('computes interval runs', async () => {
135
164
  const from = Date.now()