@zhushanwen/pi-scheduler 0.5.2 → 0.8.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 CHANGED
@@ -228,7 +228,7 @@ npx vitest run src/__tests__/<file>.test.ts # 单个文件
228
228
 
229
229
  测试策略:
230
230
 
231
- - **依赖反转**:`SchedulerRuntime` 只依赖 `SchedulerBackend` 接口(`sendMessage` / `appendEntry` / `now`),不触碰 FS/pi。测试注入 `MockSchedulerBackend`(`src/backend.ts` 同文件 export)实现零副作用测试
231
+ - **依赖反转**:`SchedulerRuntime` 只依赖 `SchedulerBackend` 接口(`sendMessage` / `appendEntry` / `now`),不触碰 FS/pi。测试注入 `MockSchedulerBackend`(`src/__tests__/mock-backend.ts`,ext-simplify-08 L9 自 `src/backend.ts` 迁出的测试专用实现)实现零副作用测试
232
232
  - **纯函数**:`parseDuration` / `formatDuration` / `parseSchedule` / `computeNextRunAt` / `computeNextRuns`(`src/parsing.ts`)无副作用,可直接断言
233
233
  - **重放折叠**:custom entry 折叠协议(upsert / advance / toggle / delete,含 nextRunAt 重放恢复、fork owner 过滤)
234
234
  - **旧 store 导入**:rename `.imported` 原子收敛(单成功者、崩溃恢复)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.5.2",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "xyz-agent": {
@@ -30,7 +30,6 @@
30
30
  ],
31
31
  "peerDependencies": {
32
32
  "@earendil-works/pi-coding-agent": "^0.84.4",
33
- "croner": "^9.0.0",
34
33
  "typebox": "*"
35
34
  },
36
35
  "peerDependenciesMeta": {
@@ -39,14 +38,11 @@
39
38
  },
40
39
  "typebox": {
41
40
  "optional": true
42
- },
43
- "croner": {
44
- "optional": true
45
41
  }
46
42
  },
47
43
  "dependencies": {
48
- "@xyz-agent/session-delivery": "0.3.1",
49
- "@zhushanwen/pi-ext-guards": "0.3.0",
44
+ "croner": "^9.0.0",
45
+ "@zhushanwen/pi-ext-guards": "0.4.0",
50
46
  "@zhushanwen/pi-extension-logger": "0.6.0"
51
47
  },
52
48
  "scripts": {
@@ -59,7 +59,7 @@ description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)
59
59
  - **5 字段**(分 时 日 月 周):自动在最前面补秒字段 `0`,变成 6 字段。例 `*/10 * * * *` → `0 */10 * * * *`(每 10 分钟)。
60
60
  - **6 字段**(秒 分 时 日 月 周):原样使用。
61
61
  - 其他字段数(<5 或 >6)视为无效。
62
- - 底层用 `croner` 库(peerDependency,运行时动态 `import('croner')`;未安装时 cron 任务全部解析失败,interval 不受影响)。
62
+ - 底层用 `croner` 库(dependencies + 静态 import,解析器随包安装恒在盘;cron 解析失败即表达式无效,interval 不受影响)。
63
63
  - 创建时即校验表达式有效性(算不出下次执行时间 → `INVALID_SCHEDULE`);运行中表达式失效(极少见,如月份边界)→ 任务被停用并记 `lastError='cron expression invalid'`。
64
64
 
65
65
  示例:`*/30 * * * *`(每 30 分)、`0 9 * * 1-5`(工作日早 9 点)、`0 0 * * *`(每天 0 点)。
@@ -185,7 +185,7 @@ schedule({ prompt: "...", schedule: "1h", kind: "recurring", force: true, expire
185
185
 
186
186
  ## 备注
187
187
 
188
- - **croner 依赖**:cron 模式依赖 `croner`(peerDependency)。未安装时所有 cron 任务解析失败(返回 `INVALID_SCHEDULE`),interval 任务不受影响。集成方(如 xyz-agent mandatory 安装)需确保 `croner` 可用。
188
+ - **croner 依赖**:cron 模式依赖 `croner`(dependencies,随包自动安装 + 静态 import,2026-09 0.6.0 起;此前为 optional peer,独立安装形态下 cron 任务会静默失败)。cron 解析失败(`INVALID_SCHEDULE`)= 表达式无效,不存在「解析器未安装」形态。
189
189
  - **数据目录隔离**:任务存储在 `getAgentDir()` 指向的 session JSONL。xyz-agent 通过 `XYZ_AGENT_DATA_DIR` / `PI_CODING_AGENT_DIR` 隔离实例时,任务随 session 落在隔离目录,与 `~/.pi/agent` 互不干扰。
190
190
  - **无配置 schema 可编辑**:scheduler 的所有状态都由运行时命令产生,没有可手动编辑的配置文件。要「批量预置任务」只能在 session 内逐条创建(或迁移旧 store)。
191
191
  - **TUI 管理器未实现**:无参 `/schedule` 当前只返回提示,任务管理请用 `list`/`on`/`off`/`rm`/`run` 子命令或 `schedule_control` 工具。
@@ -5,13 +5,13 @@
5
5
  * (1) 同一 taskId 的 dispatchTask 并发调用 → 第二次立即返回 false + logger.warn
6
6
  * (2) 第一次 dispatchTask 完成后(finally 清除)→ 第二次正常执行
7
7
  *
8
- * 断言 delivery send 调用总次数为 1(拦截场景)或 2(串行场景)。
8
+ * 断言 backend.sendMessage 调用总次数为 1(拦截场景)或 2(串行场景)。
9
9
  */
10
10
  import { describe, expect, it, vi } from 'vitest'
11
11
 
12
12
  import { getLogger } from '@zhushanwen/pi-extension-logger'
13
13
 
14
- import { MockSchedulerBackend } from '../backend.js'
14
+ import { MockSchedulerBackend } from './mock-backend.js'
15
15
  import { SchedulerRuntime } from '../runtime.js'
16
16
 
17
17
  describe('U4_DISPATCH_INFLIGHT: 调用方 in-flight 守卫', () => {
@@ -25,13 +25,9 @@ describe('U4_DISPATCH_INFLIGHT: 调用方 in-flight 守卫', () => {
25
25
  })
26
26
  const backend = new MockSchedulerBackend()
27
27
  backend.sendMessage = vi.fn(() => sendPromise)
28
- const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
28
+ const runtime = new SchedulerRuntime(backend)
29
29
 
30
- const task = await runtime.addTask(
31
- 'inflight-test',
32
- { mode: 'interval', intervalMs: 60_000 },
33
- { force: true },
34
- )
30
+ const task = await runtime.addTask('inflight-test', { mode: 'interval', intervalMs: 60_000 })
35
31
 
36
32
  // 第一次 dispatch(挂起)
37
33
  const first = runtime.dispatchTask(task)
@@ -58,13 +54,9 @@ describe('U4_DISPATCH_INFLIGHT: 调用方 in-flight 守卫', () => {
58
54
 
59
55
  it('(2) 第一次完成后 → 第二次 dispatchTask 正常执行(send 调 2 次)', async () => {
60
56
  const backend = new MockSchedulerBackend()
61
- const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
57
+ const runtime = new SchedulerRuntime(backend)
62
58
 
63
- const task = await runtime.addTask(
64
- 'serial-inflight',
65
- { mode: 'interval', intervalMs: 60_000 },
66
- { force: true },
67
- )
59
+ const task = await runtime.addTask('serial-inflight', { mode: 'interval', intervalMs: 60_000 })
68
60
 
69
61
  // 第一次 dispatch(串行等待完成)
70
62
  const first = await runtime.dispatchTask(task)
@@ -80,30 +72,4 @@ describe('U4_DISPATCH_INFLIGHT: 调用方 in-flight 守卫', () => {
80
72
  // send 被调 2 次(串行完成)
81
73
  expect(backend.sentMessages).toHaveLength(2)
82
74
  })
83
-
84
- it('(3) 非 force + 有 delivery handle 时,in-flight 守卫同样拦截并发 dispatch', async () => {
85
- const warnSpy = vi.spyOn(getLogger('scheduler'), 'warn').mockImplementation(() => {})
86
- const backend = new MockSchedulerBackend()
87
- backend.deliveryHandle = {
88
- send: vi.fn(),
89
- sendChecked: vi.fn(),
90
- flush: vi.fn(),
91
- depth: vi.fn(() => 0),
92
- dispose: vi.fn(),
93
- } as any
94
- const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
95
-
96
- const task = await runtime.addTask('delivery-inflight', { mode: 'interval', intervalMs: 60_000 })
97
-
98
- const first = runtime.dispatchTask(task)
99
- const second = await runtime.dispatchTask(task)
100
-
101
- expect(await first).toBe(true)
102
- expect(second).toBe(false)
103
- expect(backend.deliveryHandle.send).toHaveBeenCalledTimes(1)
104
-
105
- const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
106
- expect(warnText).toContain('already in flight')
107
- warnSpy.mockRestore()
108
- })
109
75
  })
@@ -1,11 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { MockSchedulerBackend } from '../backend.js'
3
+ import { MockSchedulerBackend } from './mock-backend.js'
4
4
  import { SchedulerRuntime } from '../runtime.js'
5
5
  import type { SchedulerEntryOp, TaskSnapshot } from '../types.js'
6
6
 
7
- const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
8
-
9
7
  /** 构造 base task 快照(upsert op 用)。 */
10
8
  function snapshot(overrides: Partial<TaskSnapshot> = {}): TaskSnapshot {
11
9
  return {
@@ -15,8 +13,7 @@ function snapshot(overrides: Partial<TaskSnapshot> = {}): TaskSnapshot {
15
13
  kind: 'recurring',
16
14
  schedule: { mode: 'interval', intervalMs: 60000 },
17
15
  enabled: true,
18
- force: false,
19
- createdAt: 0,
16
+ createdAt: 0,
20
17
  nextRunAt: 100,
21
18
  runCount: 0,
22
19
  history: [],
@@ -103,7 +100,7 @@ describe('MockSchedulerBackend', () => {
103
100
  it('TC2: SchedulerRuntime with MockSchedulerBackend constructs and dispatches via mock', async () => {
104
101
  const backend = new MockSchedulerBackend()
105
102
  // 构造不抛错(无需 cwd/pi/store mock)
106
- const runtime = new SchedulerRuntime(backend, mockCtx)
103
+ const runtime = new SchedulerRuntime(backend)
107
104
  const task = await runtime.addTask('probe', { mode: 'interval', intervalMs: 60000 })
108
105
  expect(task).toBeDefined()
109
106
  // addTask 后 append upsert op(append-only,零 FS)
@@ -1,6 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { MockSchedulerBackend } from '../backend.js'
3
+ import { MockSchedulerBackend } from './mock-backend.js'
4
4
  import { executeScheduleCommand, registerScheduleCommand } from '../commands.js'
5
5
  import { SchedulerRuntime } from '../runtime.js'
6
6
  import { SchedulerService } from '../service.js'
@@ -27,7 +27,7 @@ describe('/schedule command', () => {
27
27
  }
28
28
  const backend = new MockSchedulerBackend()
29
29
  service = new SchedulerService(
30
- new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false }),
30
+ new SchedulerRuntime(backend),
31
31
  () => backend.now(),
32
32
  )
33
33
  registerScheduleCommand(mockPi as never, () => service)
@@ -104,6 +104,7 @@ describe('/schedule command', () => {
104
104
  // ── 子命令路由:run ──
105
105
 
106
106
  it('run executes task', async () => {
107
+ // run 子命令的 dispatch 行为锚定 steer 直投路径
107
108
  const created = await service.create('test', '5m')
108
109
  const result = await executeScheduleCommand(service, `run ${created.data!.task.id}`)
109
110
  expect(result).toContain('executed')
@@ -5,8 +5,8 @@
5
5
  // 此文件集中测 cron 相关导出:normalizeCronExpression / computeNextCronRunAt /
6
6
  // computeNextCronRuns / parseSchedule(cron) / computeNextRuns(cron)。
7
7
  //
8
- // croner 包是动态 import 的,测试时已安装(package.json peerDep croner ^9.0.0),
9
- // getCroner() 能正常返回模块,故 cron 路径在此可真实执行。
8
+ // croner 是 dependencies + 静态 import(ext-simplify-08 D2),解析链同步化,
9
+ // cron 路径在此真实执行(无需 mock)。
10
10
 
11
11
  import { describe, expect, it } from 'vitest'
12
12
 
@@ -60,16 +60,14 @@ describe('computeNextCronRuns', () => {
60
60
  describe('parseSchedule (cron 分支)', () => {
61
61
  it('5 字段 cron 表达式补秒字段', async () => {
62
62
  const result = await parseSchedule('*/10 * * * *')
63
- expect(result).toEqual({
64
- spec: { mode: 'cron', cronExpression: '0 */10 * * * *' },
65
- })
63
+ expect(result).toEqual({ mode: 'cron', cronExpression: '0 */10 * * * *' })
66
64
  })
67
65
 
68
66
  it('工作日 9 点 cron 表达式返回 cron mode', async () => {
69
67
  const result = await parseSchedule('0 9 * * 1-5')
70
68
  expect(result).toBeDefined()
71
- expect(result!.spec.mode).toBe('cron')
72
- expect(result!.spec).toEqual({ mode: 'cron', cronExpression: '0 0 9 * * 1-5' })
69
+ expect(result!.mode).toBe('cron')
70
+ expect(result!).toEqual({ mode: 'cron', cronExpression: '0 0 9 * * 1-5' })
73
71
  })
74
72
 
75
73
  it('含空格但非法的表达式返回 undefined', async () => {
@@ -40,7 +40,6 @@ function makeTask(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
40
40
  kind: 'recurring',
41
41
  schedule: { mode: 'interval', intervalMs: 60000 },
42
42
  enabled: true,
43
- force: false,
44
43
  createdAt: 1000,
45
44
  nextRunAt: 2000,
46
45
  runCount: 0,
@@ -13,9 +13,11 @@
13
13
  // isCtxStale 翻转为 true 且其泄漏 timer 在下个 tick 前置检查自停。闭包级实现(R3-M1
14
14
  // 修复前)在此拓扑下恒 false——正是被 R3 实测证伪的生产失效路径。
15
15
  //
16
- // 本文件用 InstrumentedRuntime(继承真实 SchedulerRuntime,仅记录构造第三参 isCtxStale)
17
- // 捕获注入回调。InstrumentedRuntime 全部行为继承父类,不影响装配链本身(F1 停旧 timer
18
- // 等行为由 index-session-start.test.ts U4 锚定,此处不重复)。
16
+ // 本文件用 InstrumentedRuntime(继承真实 SchedulerRuntime,记录构造参 isCtxStale)
17
+ // 捕获注入回调与装配事实。InstrumentedRuntime 全部行为继承父类,不影响装配链本身
18
+ // (F1 停旧 timer 等行为由 index-session-start.test.ts U4 锚定,此处不重复)。
19
+ // steer 直投模型(scheduler-steer-direct-dispatch):装配点不再创建 delivery handle,
20
+ // runtime 构造仅收 backend + isCtxStale 两参。
19
21
 
20
22
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
21
23
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -40,17 +42,16 @@ const { isCtxStaleCaptures, runtimeInstances } = vi.hoisted(() => ({
40
42
  }))
41
43
 
42
44
  // InstrumentedRuntime 继承真实实现(loadTasks/onAfterTick/startScheduler 均真实执行),
43
- // 仅捕获构造第三参并登记实例(afterEach 统一停 timer,避免真实 setInterval 残留)。
45
+ // 仅捕获构造参(isCtxStale)并登记实例(afterEach 统一停 timer,避免真实 setInterval 残留)。
44
46
  vi.mock('../runtime.js', async (importOriginal) => {
45
47
  const actual = await importOriginal<typeof import('../runtime.js')>()
46
48
  const RealSchedulerRuntime = actual.SchedulerRuntime
47
49
  class InstrumentedRuntime extends RealSchedulerRuntime {
48
50
  constructor(
49
51
  backend: ConstructorParameters<typeof RealSchedulerRuntime>[0],
50
- ctx: ConstructorParameters<typeof RealSchedulerRuntime>[1],
51
52
  isCtxStale?: () => boolean,
52
53
  ) {
53
- super(backend, ctx, isCtxStale)
54
+ super(backend, isCtxStale)
54
55
  isCtxStaleCaptures.push(isCtxStale)
55
56
  runtimeInstances.push(this)
56
57
  }
@@ -45,8 +45,8 @@ function createMockPi(): {
45
45
 
46
46
  /**
47
47
  * 最小 fake ctx:覆盖 session_start 装配链读到的全部字段——PiSchedulerBackend 构造
48
- * (sessionManager)、SchedulerRuntime 构造(isIdle/hasPendingMessages)、importLegacyStore
49
- * (cwd,已 mock)、refreshWidget(ui.setWidget)。
48
+ * (sessionManager)、runtime 装配(构造器直传——
49
+ * L2/L3)、importLegacyStore(cwd,已 mock)、refreshWidget(ui.setWidget)。
50
50
  * setWidget 以独立引用导出:session_start 初始渲染 + 每次 tick 末 onAfterTick 各调一次,
51
51
  * 是「哪个 runtime 的 timer 还在 tick」的行为观测面。
52
52
  */
@@ -0,0 +1,58 @@
1
+ import type { SchedulerBackend } from '../backend.js'
2
+ import { replayFoldEntries, type SchedulerEntryLike } from '../replay.js'
3
+ import type { ScheduledTask, SchedulerEntryOp } from '../types.js'
4
+
5
+ // 测试专用 mock(ext-simplify-08 L9:从 backend.ts 迁出——导出面即测试面的收敛,
6
+ // 生产模块不再携带测试脚手架;MockSchedulerBackend/SentMessage 零生产引用)。
7
+
8
+ export interface SentMessage {
9
+ msg: { content: string; customType: string; display: boolean }
10
+ opts?: { deliverAs?: 'steer'; triggerTurn?: boolean }
11
+ }
12
+
13
+ /**
14
+ * Mock 后端:零 session/FS 副作用,记录 sendMessage/appendEntry 调用,支持注入固定时间与 fake entries。
15
+ *
16
+ * 能力:
17
+ * - sentMessages: 每次 sendMessage 的 {msg, opts} 记录
18
+ * - appendedOps: 每次 appendEntry 收到的 SchedulerEntryOp(测 runtime 各 op 断言)
19
+ * - fakeEntries / fakeSessionFile: loadTasks 经 replayFoldEntries 的注入源(测 backend→replay 委托)
20
+ * - nowValue: now() 返回固定值,缺省 Date.now()
21
+ * - appendError: 注入后 appendEntry 抛该错(测 ER-APPEND-FAIL 捕获路径)
22
+ */
23
+ export class MockSchedulerBackend implements SchedulerBackend {
24
+ sentMessages: SentMessage[] = []
25
+ appendedOps: SchedulerEntryOp[] = []
26
+ fakeEntries: SchedulerEntryLike[] = []
27
+ fakeSessionFile: string | undefined = '/test/session.json'
28
+ nowValue: number | undefined
29
+ appendError: Error | null = null
30
+
31
+ async sendMessage(
32
+ msg: { content: string; customType: string; display: boolean },
33
+ opts?: { deliverAs?: 'steer'; triggerTurn?: boolean },
34
+ ): Promise<void> {
35
+ this.sentMessages.push({ msg, opts })
36
+ }
37
+
38
+ appendEntry(op: SchedulerEntryOp): void {
39
+ if (this.appendError) throw this.appendError
40
+ this.appendedOps.push(op)
41
+ }
42
+
43
+ getSessionFile(): string | undefined {
44
+ return this.fakeSessionFile
45
+ }
46
+
47
+ now(): number {
48
+ return this.nowValue ?? Date.now()
49
+ }
50
+
51
+ /**
52
+ * 读路径(非接口成员,与 PiSchedulerBackend.loadTasks 对称):经 replayFoldEntries 折叠
53
+ * fakeEntries + fakeSessionFile 恢复任务。测试用它验证 backend→replay 委托(TC-W-BACKEND-REPLAY)。
54
+ */
55
+ loadTasks(): ScheduledTask[] {
56
+ return [...replayFoldEntries(this.fakeEntries, this.fakeSessionFile).values()]
57
+ }
58
+ }
@@ -100,9 +100,7 @@ describe('normalizeCronExpression', () => {
100
100
  describe('parseSchedule', () => {
101
101
  it('parses duration to interval mode', async () => {
102
102
  const result = await parseSchedule('5m')
103
- expect(result).toEqual({
104
- spec: { mode: 'interval', intervalMs: 300_000 },
105
- })
103
+ expect(result).toEqual({ mode: 'interval', intervalMs: 300_000 })
106
104
  })
107
105
 
108
106
  it('returns undefined for invalid duration', async () => {
@@ -119,7 +117,7 @@ describe('parseSchedule', () => {
119
117
  it('parses valid cron expression to cron mode', async () => {
120
118
  const result = await parseSchedule('0 9 * * 1-5')
121
119
  expect(result).toBeDefined()
122
- expect(result!.spec.mode).toBe('cron')
120
+ expect(result!.mode).toBe('cron')
123
121
  })
124
122
 
125
123
  it('returns undefined for invalid cron with spaces', async () => {
@@ -84,7 +84,7 @@ describe('property: parseDuration/parseSchedule round-trip', () => {
84
84
  // 无空格字符串 → 恒走 duration 分支 → interval mode + 同一 intervalMs
85
85
  const parsed = await parseSchedule(text)
86
86
  expect(parsed).toBeDefined()
87
- expect(parsed!.spec).toEqual({ mode: 'interval', intervalMs: expectedMs })
87
+ expect(parsed!).toEqual({ mode: 'interval', intervalMs: expectedMs })
88
88
  },
89
89
  ),
90
90
  )
@@ -37,7 +37,6 @@ function snapshot(overrides: Partial<TaskSnapshot> = {}): TaskSnapshot {
37
37
  kind: 'recurring',
38
38
  schedule: { mode: 'interval', intervalMs: 60000 },
39
39
  enabled: true,
40
- force: false,
41
40
  createdAt: 0,
42
41
  nextRunAt: 100,
43
42
  runCount: 0,