@zhushanwen/pi-scheduler 0.1.1 → 0.2.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
@@ -1,6 +1,20 @@
1
1
  # scheduler
2
2
 
3
- 定时任务调度扩展:按 duration(`5m` / `2h` / `1d`)间隔或 cron 表达式,在指定时间向 agent 注入消息。支持一次性提醒(once)、强制触发(force)、过期策略(expires)与持久化(重启后任务保留)。
3
+ 定时任务调度扩展:按 duration(`5m` / `2h` / `1d`)间隔或 cron 表达式,在指定时间向 agent 注入消息。支持一次性提醒(once)、强制触发(force)与过期策略(expires)。任务随 owner session 持久化,resume 后继续触发。
4
+
5
+ ## 产品定位
6
+
7
+ pi-scheduler 是 **session 存活期间的 AI 提醒器**——在 pi 进程运行、session 打开时,按计划向当前 session 注入消息。
8
+
9
+ **非系统级 cron**:
10
+
11
+ - pi 进程不开 = 不触发(与常驻后台的系统 cron daemon 不同,本扩展不是后台守护进程)
12
+ - 电脑睡眠 / 关机 = 不触发
13
+ - 不依赖系统 crontab,不注册任何开机自启
14
+
15
+ **任务归属创建它的 session**:任务物理存储在创建它的 session 的 JSONL 文件内,**只在 owner session 打开(继续对话 / resume)时才触发**。
16
+
17
+ > **这是设计决策,不是 bug(D9)**:本扩展从 cwd 级定时器改为 **session 级 AI 提醒器**——任务归属创建它的 session,只在 owner session 存活时触发。如果你每天开新 session,昨天建的"明早检查 CI"任务今天不会响,除非你 resume 昨天创建该任务的那个 session。这不是"任务丢了":任务随 session 持久化,session 不打开就不调度。**用户若发现「昨天建的任务今天没响」,这是预期行为**,请 resume 创建该任务的 session。
4
18
 
5
19
  ## 简介与安装
6
20
 
@@ -20,15 +34,26 @@ npm install @zhushanwen/pi-scheduler
20
34
 
21
35
  ```
22
36
  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 刷新)
37
+ ├─ PiSchedulerBackend(pi.sendMessage + 时间源 + appendEntry + getEntries)
38
+ └─ SchedulerRuntime(内存态 + 30s tick 调度 + 限流)
39
+ └─ SchedulerService(tool/command 唯一业务入口)
40
+ ├─ runtime.loadTasks(replay(getEntries()))重放 session JSONL 的 custom entry 折叠恢复任务
41
+ │ (仅 ownerSessionFile 匹配的加载,fork 副本被过滤)
42
+ ├─ runtime.startScheduler() ← 启动 30s tick
43
+ └─ 注册 scheduler widget(每个 tick 后刷新)
29
44
  ```
30
45
 
31
- `session_shutdown` 时执行 `runtime.persistSync()`(立即写盘)并停止 tick。任务状态因此随 session 持久化:关闭重开 session 后任务仍存在、到期仍会触发。
46
+ 运行时每次状态变更会 append 一条 custom entry 到创建任务的 session 的 JSONL(统一 `customType: pi-scheduler:task`,`op` 字段区分):
47
+
48
+ | op | 触发时机 | 携带数据 |
49
+ |----|---------|---------|
50
+ | `upsert` | 创建 / 更新任务 | task 全快照(含 nextRunAt 初值、ownerSessionFile) |
51
+ | `advance` | dispatch 成功后 | 推进后的 nextRunAt、本次执行 at / status |
52
+ | `toggle` | 启用 / 停用 | enabled |
53
+ | `delete` | 删除;once 触发后自动 delete | taskId |
54
+
55
+ - fork 出的 session 重放时按 `ownerSessionFile` 过滤,不加载、不执行继承的任务副本(原 session resume 照常)
56
+ - `session_shutdown` 停止 tick + 兜底执行延迟删除 cleanup(正常路径已由首个 turn_end 完成)——任务已 append 到 JSONL,无需额外写盘
32
57
 
33
58
  ## /schedule 命令用法
34
59
 
@@ -93,7 +118,7 @@ session_start
93
118
  |------|------|------|
94
119
  | `kind` | `recurring`(默认)/ `once` | recurring 每次触发后按 schedule 重算下次时间;once 触发一次后自动删除 |
95
120
  | `name` | 字符串 | 任务可读名称,缺省从 prompt 自动生成(≤30 字原样,超长截前 27 字加省略号) |
96
- | `expires` | duration 字符串 / `never` | recurring 任务的过期时间:`now + duration`;`never` 永不过期;缺省 7 天。**once 任务不设过期**(触发即删,expires 忽略) |
121
+ | `expires` | duration 字符串 / `never` | recurring 任务的过期时间:`now + duration`;`never` 永不过期;缺省 7 天。**once 任务忽略 expires 参数**(触发即删,传不传都不生效) |
97
122
  | `force` | `true` / `false`(默认) | `true` 时即使 agent 忙(非 idle 或有 pending 消息)也强制 dispatch;`false` 时忙则延迟到下次 tick |
98
123
 
99
124
  ## 示例
@@ -122,7 +147,7 @@ session_start
122
147
  {"prompt": "deploy staging", "schedule": "*/10 * * * *", "force": true}
123
148
  ```
124
149
 
125
- **永不过期**(tool 调用:长期任务不设 7 天默认过期):
150
+ **永不过期**(tool 调用:长期 recurring 任务不设 7 天默认过期):
126
151
 
127
152
  ```json
128
153
  {"prompt": "monthly report", "schedule": "1d", "expires": "never"}
@@ -138,25 +163,61 @@ session_start
138
163
  | 默认过期 | **7 天**(`DEFAULT_EXPIRY_MS`) | recurring 任务缺省 `expires` 时;`expires: 'never'` 关闭 |
139
164
  | once 任务 | 触发后自动删除 | 不参与后续调度 |
140
165
  | cron 失效 | 任务停用 + `lastStatus=failed` + `lastError='cron expression invalid'` | 不会用 `now()` 兜底导致每 tick 重触发死循环 |
141
- | persist 失败 | `console.warn` + 内存态保留 + `lastError='persist failed'` | 不打断调度,下次 tick 继续尝试 |
142
166
  | 忙时 dispatch | 非 force 任务在 agent 忙(非 idle / 有 pending 消息)时跳过,延迟到下次 tick | force=true 可绕过 |
143
- | history | 保留最近 **20** 条执行记录 | 超出丢弃最旧 |
144
- | 持久化 | 每次变更写盘(session_shutdown 强制同步写) | 重启后任务保留 |
167
+ | history | 保留最近 **20** 条执行记录 | 超出丢弃最旧;重放折叠时同样裁剪 |
168
+ | 持久化 | custom entry append 到 session JSONL(dispatch 成功后立即 append advance 记录执行) | 任务随 owner session 持久化,resume 后重放恢复,无需额外写盘 |
169
+ | 交付语义 | **at-least-once**(至少一次) | dispatch 成功后内存更新 nextRunAt 并 append advance;append 之前若进程崩溃可能重复注入一次(无精确一次保证,可接受) |
170
+ | 延迟写入窗口 | 新 session 首 turn 内建任务后进程崩溃可能丢失 | pi 延迟写入:首条 assistant 消息前不 flush。窗口窄、概率极低、无恢复手段 |
171
+ | 触发条件 | pi 进程需存活且 session 打开 | 电脑睡眠 / pi 进程未运行 = 不触发(非系统 cron,无后台守护) |
145
172
 
146
173
  错误语义:创建时 schedule 解析失败 → `INVALID_SCHEDULE`;`run`/`toggle`/`delete` 引用不存在的 id → `TASK_NOT_FOUND`;`run` 时任务 disabled / busy / rate-limited → `DISPATCH_SKIPPED`(message 含 `busy, disabled, or rate-limited`)。
147
174
 
148
175
  ## 数据存储位置
149
176
 
150
- 任务存储为单个 JSON 文件,按 workspace 路径隔离(不同 cwd 存不同文件):
177
+ ### 当前机制
151
178
 
152
- ```
153
- ~/.pi/agent/scheduler/<root>/<segments>/scheduler.json
154
- ```
179
+ 任务存储为 custom entry,写入创建它的 session 的 JSONL 文件(统一 `customType: pi-scheduler:task`,`op` 字段区分 upsert / advance / toggle / delete)。任务物理归属于创建它的 session——`appendEntry` 把 entry 写入当前 session 的 JSONL,`getEntries()` 重放折叠恢复内存态。
180
+
181
+ ### append-only
182
+
183
+ custom entry 物理追加到 JSONL,不修改、不删除——pi 依赖 JSONL 物理保留来维持 session tree 的 parentId 链。重放时 per-taskId 按 entry 顺序折叠得到当前态:
184
+
185
+ - `upsert` → 任务以快照覆盖(last-write-wins,含 ownerSessionFile / nextRunAt 初值)
186
+ - `advance` → 推进 nextRunAt、记录本次执行(at / status)
187
+ - `toggle` → 切换 enabled
188
+ - `delete` → 该任务标记消失(once 触发后自动 delete,重放即不见)
189
+
190
+ 末态 = per taskId 最后一个非 delete op 的结果。
191
+
192
+ ### 不进入 LLM context
193
+
194
+ pi 的 context 构建对 custom entry 无 case(被过滤)——任务数据零污染对话上下文,不影响 token / 模型上下文。
195
+
196
+ ### 归属即结构性质
197
+
198
+ 任务物理存在于创建 session 的 JSONL 内。**session 文件删除 = 任务消失**,无残留、无需 GC、无分片文件。fork 出的 session 不加载继承的任务副本(ownerSessionFile 过滤),subagent 也不受主 session 任务干扰。
199
+
200
+ ### 旧版迁移
201
+
202
+ 升级前任务存在 cwd 共享的旧 store(`~/.pi/agent/scheduler/<cwd>/scheduler.json`)。升级后首个检测到旧文件的 session 原子 `rename` 为 `scheduler.json.imported`,逐任务 appendEntry upsert 到自己的 JSONL,然后删除 `.imported`(⚠️ 删除时机依赖 flush:resumed session 已落盘可立即删;新 session(pi 延迟写入,entries 仅内存)延迟到首个 `turn_end`(该轮 message_end 已全部持久化,flush 必已发生)确认 flush 后删,`session_shutdown` 兜底;未 flush 保留 `.imported` 供崩溃恢复重导入,避免源文件销毁 + 数据未落盘的双重丢失):
203
+
204
+ - **归属**:旧任务无 owner 信息,**归属首个完成导入的 session**(无更好近似)
205
+ - **过期任务立即触发**:导入后若 nextRunAt 已过期,**首个 tick 立即 dispatch**(once 立即注入、recurring 补跑)
206
+
207
+ ### entry 累积
208
+
209
+ recurring 长期 session 的 scheduler entry 会持续累积(每次 dispatch append 一条 advance)。量级可控:约 100B/条,1h 任务运行一年约 8760 条 ≈ 876KB。且 custom entry 不进 LLM context,不影响 token / 模型上下文。**不做物理裁剪**(append-only 约束 + advance 是 nextRunAt 正确性的必要记录,不可省)。未来若成问题,方向是等 pi 提供 compaction hook,不是本 extension 自建裁剪。
210
+
211
+ ## 依赖的 pi 行为清单
212
+
213
+ 本扩展的存储方案(custom entry event sourcing)依赖以下 pi 源码行为。这些是**实测存在但非 SDK 契约承诺**的隐式行为,pi 升级后需逐条复核:
155
214
 
156
- - `<root>`:路径根(如 `/` `root`)
157
- - `<segments>`:cwd 相对根的路径段(如 `/Users/me/project` `Users/me/project`)
215
+ 1. **`pi.appendEntry` / `ctx.sessionManager.getEntries()` 存在且 custom entry 不进 LLM context**:custom entry 在 pi 的 context 构建(`sessionEntryToContextMessages`)中无 case,被 flatMap 过滤,任务数据零污染对话上下文。若 pi 未来把 custom entry 纳入 context,会污染 token / 模型输入
216
+ 2. **fork(`forkFrom`)全文件复制 custom entry**:forkFrom 是全文件复制(含被放弃分支的 entries,无 fork 点概念),不是 fork 点路径复制。本扩展靠 owner 过滤兜底两条复制路径。若 pi 改为按分支选择性复制,fork 隔离逻辑需重新评估
217
+ 3. **`getEntries()` 返回全量 entries(不按当前分支过滤)**:实测 `getEntries()` 返回全部 fileEntries(session-manager.js:980-982),navigate 只改 leafId 指针不改 entries。因此任务不随 navigate 消失。若 pi 改为按 leafId / 分支过滤 getEntries,切换分支会导致任务丢失
218
+ 4. **navigate / 切换分支不改任务 entries**:navigate 只移动 leafId 指针,不增删 custom entry,任务 entries 跨分支稳定存在。若 pi 未来在 navigate 时裁剪 entries,任务持久性会破坏
158
219
 
159
- 删除文件即清除全部任务;损坏的 JSON 自动降级为空 store `console.warn`。
220
+ 任一条行为变更都需重新验证 design D1 / D2 断言与验收场景(尤其 resume、fork 场景)。
160
221
 
161
222
  ## 开发
162
223
 
@@ -167,9 +228,9 @@ npx vitest run src/__tests__/<file>.test.ts # 单个文件
167
228
 
168
229
  测试策略:
169
230
 
170
- - **依赖反转**:`SchedulerRuntime` 只依赖 `SchedulerBackend` 接口(`sendMessage` / `persist` / `now`),不触碰 FS/pi。测试注入 `MockSchedulerBackend`(`src/backend.ts` 同文件 export)实现零副作用测试:`sentMessages` 记录发送、`persistedStores` 记录持久化、`persistError` 注入失败、`nowValue` 固定时间源
231
+ - **依赖反转**:`SchedulerRuntime` 只依赖 `SchedulerBackend` 接口(`sendMessage` / `appendEntry` / `now`),不触碰 FS/pi。测试注入 `MockSchedulerBackend`(`src/backend.ts` 同文件 export)实现零副作用测试
171
232
  - **纯函数**:`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)持久化往返
233
+ - **重放折叠**:custom entry 折叠协议(upsert / advance / toggle / delete,含 nextRunAt 重放恢复、fork owner 过滤)
234
+ - **旧 store 导入**:rename `.imported` 原子收敛(单成功者、崩溃恢复)
174
235
 
175
- 扩展内部结构:`backend.ts`(后端抽象)→ `runtime.ts`(调度核心)→ `service.ts`(业务入口)→ `tool.ts`/`commands.ts`(tool 与 /schedule 命令适配层)→ `widget.ts`(状态栏 widget)。
236
+ 扩展内部结构:`backend.ts`(后端抽象)→ `replay.ts`(custom entry 重放折叠)→ `runtime.ts`(调度核心)→ `service.ts`(业务入口)→ `tool.ts` / `commands.ts`(tool 与 /schedule 命令适配层)→ `widget.ts`(状态栏 widget)→ `importer.ts`(旧 store 导入)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "pi": {
@@ -2,10 +2,28 @@ import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import { MockSchedulerBackend } from '../backend.js'
4
4
  import { SchedulerRuntime } from '../runtime.js'
5
- import type { SchedulerStore } from '../types.js'
5
+ import type { SchedulerEntryOp, TaskSnapshot } from '../types.js'
6
6
 
7
7
  const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
8
8
 
9
+ /** 构造 base task 快照(upsert op 用)。 */
10
+ function snapshot(overrides: Partial<TaskSnapshot> = {}): TaskSnapshot {
11
+ return {
12
+ id: 'aaa',
13
+ name: 'test',
14
+ prompt: 'p',
15
+ kind: 'recurring',
16
+ schedule: { mode: 'interval', intervalMs: 60000 },
17
+ enabled: true,
18
+ force: false,
19
+ createdAt: 0,
20
+ nextRunAt: 100,
21
+ runCount: 0,
22
+ history: [],
23
+ ...overrides,
24
+ }
25
+ }
26
+
9
27
  describe('MockSchedulerBackend', () => {
10
28
  it('records sendMessage calls', async () => {
11
29
  const backend = new MockSchedulerBackend()
@@ -22,17 +40,17 @@ describe('MockSchedulerBackend', () => {
22
40
  expect(backend.sentMessages[0]!.opts).toEqual({ deliverAs: 'followUp', triggerTurn: true })
23
41
  })
24
42
 
25
- it('records persist calls and throws injected persistError', async () => {
43
+ it('records appendEntry calls and throws injected appendError', () => {
26
44
  const backend = new MockSchedulerBackend()
27
- const store: SchedulerStore = { version: 1, tasks: [] }
45
+ const op: SchedulerEntryOp = { op: 'delete', taskId: 'aaa' }
28
46
 
29
- await backend.persist(store)
30
- expect(backend.persistedStores).toHaveLength(1)
31
- expect(backend.persistedStores[0]).toBe(store)
47
+ backend.appendEntry(op)
48
+ expect(backend.appendedOps).toHaveLength(1)
49
+ expect(backend.appendedOps[0]).toBe(op)
32
50
 
33
- // persistError 注入:persist 抛该错(ERR-6 语义——错误必须能传到调用栈)
34
- backend.persistError = new Error('disk full')
35
- await expect(backend.persist(store)).rejects.toThrow('disk full')
51
+ // appendError 注入:appendEntry 抛该错(ER-APPEND-FAIL 语义——错误必须能传到调用栈供 runtime 捕获)
52
+ backend.appendError = new Error('pi internal')
53
+ expect(() => backend.appendEntry(op)).toThrow('pi internal')
36
54
  })
37
55
 
38
56
  it('now() returns injected nowValue or Date.now()', () => {
@@ -42,6 +60,44 @@ describe('MockSchedulerBackend', () => {
42
60
  expect(backend.now()).toBe(123456)
43
61
  })
44
62
 
63
+ // ── TC-W-BACKEND-REPLAY:loadTasks 委托 replayFoldEntries(IF-BACKEND-REPLAY)──
64
+ it('TC-W-BACKEND-REPLAY: loadTasks 经 replayFoldEntries 恢复 owner 匹配的任务', () => {
65
+ const backend = new MockSchedulerBackend()
66
+ backend.fakeSessionFile = '/a.json'
67
+ backend.fakeEntries = [
68
+ // 非 scheduler entry:应被折叠忽略
69
+ { type: 'message', data: {} },
70
+ { type: 'custom', customType: 'other-ext', data: {} },
71
+ // owner 匹配的 upsert
72
+ {
73
+ type: 'custom',
74
+ customType: 'pi-scheduler:task',
75
+ data: { op: 'upsert', taskId: 'X', ownerSessionFile: '/a.json', task: snapshot({ id: 'X' }) },
76
+ },
77
+ // owner 不匹配的 upsert(fork 继承):应被过滤
78
+ {
79
+ type: 'custom',
80
+ customType: 'pi-scheduler:task',
81
+ data: { op: 'upsert', taskId: 'Y', ownerSessionFile: '/other.json', task: snapshot({ id: 'Y' }) },
82
+ },
83
+ ]
84
+
85
+ const tasks = backend.loadTasks()
86
+
87
+ expect(tasks).toHaveLength(1)
88
+ expect(tasks[0]!.id).toBe('X')
89
+ expect(tasks[0]!.ownerSessionFile).toBe('/a.json')
90
+ })
91
+
92
+ it('TC-W-BACKEND-REPLAY: getSessionFile 返回 fakeSessionFile(缺省值)', () => {
93
+ const backend = new MockSchedulerBackend()
94
+ expect(backend.getSessionFile()).toBe('/test/session.json')
95
+ backend.fakeSessionFile = '/custom.json'
96
+ expect(backend.getSessionFile()).toBe('/custom.json')
97
+ backend.fakeSessionFile = undefined
98
+ expect(backend.getSessionFile()).toBeUndefined()
99
+ })
100
+
45
101
  // ── TC2:new SchedulerRuntime(mockBackend) 可注入单测,零 FS ──
46
102
 
47
103
  it('TC2: SchedulerRuntime with MockSchedulerBackend constructs and dispatches via mock', async () => {
@@ -50,8 +106,9 @@ describe('MockSchedulerBackend', () => {
50
106
  const runtime = new SchedulerRuntime(backend, mockCtx)
51
107
  const task = await runtime.addTask('probe', { mode: 'interval', intervalMs: 60000 })
52
108
  expect(task).toBeDefined()
53
- // addTask persist 走了 mock backend(零 FS)
54
- expect(backend.persistedStores).toHaveLength(1)
109
+ // addTask append upsert op(append-only,零 FS)
110
+ expect(backend.appendedOps).toHaveLength(1)
111
+ expect(backend.appendedOps[0]!.op).toBe('upsert')
55
112
 
56
113
  await runtime.dispatchTask(task)
57
114
 
@@ -25,8 +25,10 @@ describe('/schedule command', () => {
25
25
  commandOpts = opts
26
26
  },
27
27
  }
28
+ const backend = new MockSchedulerBackend()
28
29
  service = new SchedulerService(
29
- new SchedulerRuntime(new MockSchedulerBackend(), { isIdle: () => true, hasPendingMessages: () => false }),
30
+ new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false }),
31
+ () => backend.now(),
30
32
  )
31
33
  registerScheduleCommand(mockPi as never, () => service)
32
34
  })
@@ -0,0 +1,283 @@
1
+ import * as fs from 'node:fs'
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { getLegacyStorePath, importLegacyStore } from '../importer.js'
6
+ import type { ScheduledTask } from '../types.js'
7
+
8
+ // vi.mock 必须在 import 之前提升。mock 工厂替换 node:fs 命名空间,
9
+ // importer.ts 内 `import * as fs` 拿到的就是这 4 个 vi.fn()。
10
+ vi.mock('node:fs', () => ({
11
+ renameSync: vi.fn(),
12
+ existsSync: vi.fn(),
13
+ readFileSync: vi.fn(),
14
+ unlinkSync: vi.fn(),
15
+ }))
16
+
17
+ const cwd = '/fake/workspace'
18
+ const storePath = getLegacyStorePath(cwd)
19
+ const importedPath = storePath + '.imported'
20
+ const sessionFile = '/x/sess.json'
21
+
22
+ /** 构造合法旧 store 任务(参考 types.ts ScheduleSpec / TaskKind 默认值)。 */
23
+ function makeTask(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
24
+ return {
25
+ id: 'task-1',
26
+ name: 'test task',
27
+ prompt: 'do something',
28
+ kind: 'recurring',
29
+ schedule: { mode: 'interval', intervalMs: 60000 },
30
+ enabled: true,
31
+ force: false,
32
+ createdAt: 1000,
33
+ nextRunAt: 2000,
34
+ runCount: 0,
35
+ history: [],
36
+ ...overrides,
37
+ }
38
+ }
39
+
40
+ /** 构造带 code 属性的 ENOENT 错误(模拟 fs.renameSync 源文件不存在)。 */
41
+ function enoentError(): Error & { code: string } {
42
+ return Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
43
+ }
44
+
45
+ describe('importLegacyStore', () => {
46
+ beforeEach(() => {
47
+ vi.clearAllMocks()
48
+ // 默认 implementation:rename 成功、existsSync false、readFileSync 空、unlink 无副作用
49
+ vi.mocked(fs.renameSync).mockImplementation(() => {})
50
+ vi.mocked(fs.existsSync).mockReturnValue(false)
51
+ vi.mocked(fs.readFileSync).mockReturnValue('{}')
52
+ vi.mocked(fs.unlinkSync).mockImplementation(() => {})
53
+ // 抑制 importer 的 console.log/warn 输出(断言用 spy)
54
+ vi.spyOn(console, 'log').mockImplementation(() => {})
55
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
56
+ })
57
+
58
+ afterEach(() => {
59
+ vi.restoreAllMocks()
60
+ })
61
+
62
+ // ── TC1:rename 原子单成功 + 逐任务 append upsert + 删 .imported ──
63
+ it('TC1: rename 成功独占 + 2 任务逐个 appendEntry upsert + 删 .imported', () => {
64
+ const taskA = makeTask({ id: 'aaaa', name: 'A' })
65
+ const taskB = makeTask({ id: 'bbbb', name: 'B' })
66
+ vi.mocked(fs.readFileSync).mockReturnValue(
67
+ JSON.stringify({ version: 1, tasks: [taskA, taskB] }),
68
+ )
69
+ const appendEntry = vi.fn()
70
+ // resumed session:sessionFile 已存在(pi flushed=true,append 即时落盘)→ 立即 unlink 安全
71
+ vi.mocked(fs.existsSync).mockImplementation(p => p === sessionFile)
72
+
73
+ importLegacyStore(cwd, { appendEntry }, sessionFile)
74
+ // 1) rename 原子独占:scheduler.json → scheduler.json.imported
75
+ expect(fs.renameSync).toHaveBeenCalledTimes(1)
76
+ expect(fs.renameSync).toHaveBeenCalledWith(storePath, importedPath)
77
+
78
+ // 2) appendEntry 调 2 次,customType + op 形状正确
79
+ expect(appendEntry).toHaveBeenCalledTimes(2)
80
+ expect(appendEntry).toHaveBeenNthCalledWith(1, 'pi-scheduler:task', {
81
+ op: 'upsert',
82
+ taskId: 'aaaa',
83
+ ownerSessionFile: sessionFile,
84
+ task: expect.objectContaining({ id: 'aaaa', name: 'A' }),
85
+ })
86
+ expect(appendEntry).toHaveBeenNthCalledWith(2, 'pi-scheduler:task', {
87
+ op: 'upsert',
88
+ taskId: 'bbbb',
89
+ ownerSessionFile: sessionFile,
90
+ task: expect.objectContaining({ id: 'bbbb', name: 'B' }),
91
+ })
92
+
93
+ // 3) task snapshot 不含 ownerSessionFile/pending(显式剥离)
94
+ const firstOp = appendEntry.mock.calls[0]![1] as { task: Record<string, unknown> }
95
+ expect(firstOp.task).not.toHaveProperty('ownerSessionFile')
96
+ expect(firstOp.task).not.toHaveProperty('pending')
97
+
98
+ // 4) unlinkSync 删 .imported
99
+ expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
100
+ expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
101
+
102
+ // 5) 进度日志(console.warn,项目 convention 禁 console.log)
103
+ expect(console.warn).toHaveBeenCalledWith(
104
+ expect.stringContaining(`imported 2 legacy tasks from ${importedPath}`),
105
+ )
106
+ })
107
+
108
+ // ── TC2:.imported 残留崩溃恢复幂等 ──
109
+ it('TC2: scheduler.json 不存在(rename ENOENT) + .imported 残留 → 读 .imported 导入 + 删 .imported', () => {
110
+ // 实现A:scheduler.json 不存在时 renameSync 抛 ENOENT → 走 handleImportedResidue
111
+ vi.mocked(fs.renameSync).mockImplementation(() => {
112
+ throw enoentError()
113
+ })
114
+ // .imported 存在 + resumed session(sessionFile 已存在 → 已落盘 → 立即删)
115
+ vi.mocked(fs.existsSync).mockImplementation(p => p === importedPath || p === sessionFile)
116
+ const taskA = makeTask({ id: 'cccc' })
117
+ vi.mocked(fs.readFileSync).mockReturnValue(
118
+ JSON.stringify({ version: 1, tasks: [taskA] }),
119
+ )
120
+ const appendEntry = vi.fn()
121
+
122
+ importLegacyStore(cwd, { appendEntry }, sessionFile)
123
+
124
+ expect(appendEntry).toHaveBeenCalledTimes(1)
125
+ expect(appendEntry).toHaveBeenCalledWith('pi-scheduler:task', {
126
+ op: 'upsert',
127
+ taskId: 'cccc',
128
+ ownerSessionFile: sessionFile,
129
+ task: expect.objectContaining({ id: 'cccc' }),
130
+ })
131
+ expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
132
+ })
133
+
134
+ // ── TC3:scheduler.json 与 .imported 都不存在 → no-op ──
135
+ it('TC3: 双不存在(rename ENOENT + .imported 不存在)→ no-op,不抛错', () => {
136
+ vi.mocked(fs.renameSync).mockImplementation(() => {
137
+ throw enoentError()
138
+ })
139
+ vi.mocked(fs.existsSync).mockReturnValue(false) // .imported 也不存在
140
+ const appendEntry = vi.fn()
141
+
142
+ expect(() => importLegacyStore(cwd, { appendEntry }, sessionFile)).not.toThrow()
143
+ expect(appendEntry).toHaveBeenCalledTimes(0)
144
+ expect(fs.unlinkSync).not.toHaveBeenCalled()
145
+ })
146
+
147
+ // ── TC4:并发单成功者——rename 抛 ENOENT + .imported 也不存在 → skip(S10 并发场景)──
148
+ it('TC4: 并发竞争——rename 抛 ENOENT + .imported 不存在 → 幂等 skip,不抛错', () => {
149
+ // 两进程并发:本进程 rename 时 scheduler.json 已被另一进程 rename 走 → ENOENT
150
+ vi.mocked(fs.renameSync).mockImplementation(() => {
151
+ throw enoentError()
152
+ })
153
+ vi.mocked(fs.existsSync).mockReturnValue(false) // 本进程也没看到 .imported
154
+ const appendEntry = vi.fn()
155
+
156
+ expect(() => importLegacyStore(cwd, { appendEntry }, sessionFile)).not.toThrow()
157
+ expect(appendEntry).toHaveBeenCalledTimes(0) // 别人已导入,本进程 skip
158
+ })
159
+
160
+ // ── TC5:currentSessionFile=undefined(--no-session 模式)→ 早 return skip ──
161
+ it('TC5: currentSessionFile=undefined → 整个 importer skip,不碰 fs', () => {
162
+ const appendEntry = vi.fn()
163
+
164
+ importLegacyStore(cwd, { appendEntry }, undefined)
165
+
166
+ expect(appendEntry).toHaveBeenCalledTimes(0)
167
+ expect(fs.renameSync).not.toHaveBeenCalled() // 早 return,不碰 fs
168
+ expect(fs.readFileSync).not.toHaveBeenCalled()
169
+ expect(fs.unlinkSync).not.toHaveBeenCalled()
170
+ })
171
+
172
+ // ── TC6:JSON 损坏降级(warn 不 rethrow)──
173
+ it('TC6: readFileSync 返回损坏 JSON → console.warn + 不 rethrow,appendEntry 0 次', () => {
174
+ vi.mocked(fs.readFileSync).mockReturnValue('not-json{')
175
+ const appendEntry = vi.fn()
176
+
177
+ expect(() => importLegacyStore(cwd, { appendEntry }, sessionFile)).not.toThrow()
178
+ expect(console.warn).toHaveBeenCalledWith(
179
+ expect.stringContaining('[scheduler] import failed'),
180
+ )
181
+ expect(appendEntry).toHaveBeenCalledTimes(0) // parse 失败,无 append
182
+ })
183
+
184
+ // ── TC7:owner 归属——upsert op.ownerSessionFile === currentSessionFile(非旧 store 路径)──
185
+ it('TC7: op.ownerSessionFile 严格等于传入的 currentSessionFile,非 getLegacyStorePath 推导路径', () => {
186
+ const ownSession = '/my/session.json'
187
+ const taskA = makeTask({ id: 'dddd' })
188
+ vi.mocked(fs.readFileSync).mockReturnValue(
189
+ JSON.stringify({ version: 1, tasks: [taskA] }),
190
+ )
191
+ const appendEntry = vi.fn()
192
+
193
+ importLegacyStore(cwd, { appendEntry }, ownSession)
194
+
195
+ expect(appendEntry).toHaveBeenCalledTimes(1)
196
+ const op = appendEntry.mock.calls[0]![1] as { ownerSessionFile: string }
197
+ expect(op.ownerSessionFile).toBe(ownSession)
198
+ expect(op.ownerSessionFile).not.toBe(storePath)
199
+ expect(op.ownerSessionFile).not.toBe(importedPath)
200
+ })
201
+
202
+ // ── TC8:新 session 未 flush → 延迟 unlink(IMPORT-FLUSH-GUARD,MF-1)──
203
+ it('TC8: 新 session(sessionFile 不存在)→ 不立即 unlink,返回 cleanup;flush 后删,未 flush 保留', () => {
204
+ const taskA = makeTask({ id: 'eeee' })
205
+ vi.mocked(fs.readFileSync).mockReturnValue(
206
+ JSON.stringify({ version: 1, tasks: [taskA] }),
207
+ )
208
+ vi.mocked(fs.existsSync).mockReturnValue(false) // 新 session:sessionFile 尚不存在(pi 未 flush,entries 仅内存)
209
+ const appendEntry = vi.fn()
210
+
211
+ const cleanup = importLegacyStore(cwd, { appendEntry }, sessionFile)
212
+
213
+ // append 已发生,但 unlink 未执行——数据可能仅内存,销毁源文件 = 永久丢失
214
+ expect(appendEntry).toHaveBeenCalledTimes(1)
215
+ expect(fs.unlinkSync).not.toHaveBeenCalled()
216
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('deferring'))
217
+
218
+ // 情形1:flush 已发生(sessionFile 出现,.imported 仍在)→ cleanup 删除 .imported
219
+ vi.mocked(fs.existsSync).mockImplementation(p => p === sessionFile || p === importedPath)
220
+ cleanup?.()
221
+ expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
222
+ expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
223
+
224
+ // 情形2:从未 flush → cleanup 保留 .imported(不 unlink、不抛错,供崩溃恢复重导入)
225
+ vi.mocked(fs.unlinkSync).mockClear()
226
+ vi.mocked(fs.existsSync).mockReturnValue(false)
227
+ expect(() => cleanup?.()).not.toThrow()
228
+ expect(fs.unlinkSync).not.toHaveBeenCalled()
229
+ })
230
+
231
+ // ── TC8b:cleanup 幂等 + unlink ENOENT 守卫(MF-2,R2 修复)──
232
+ it('TC8b: cleanup 幂等(已删后 no-op)+ unlink 仅静默 ENOENT,其他 fs 错误向上抛', () => {
233
+ const taskA = makeTask({ id: 'ffff' })
234
+ vi.mocked(fs.readFileSync).mockReturnValue(
235
+ JSON.stringify({ version: 1, tasks: [taskA] }),
236
+ )
237
+ vi.mocked(fs.existsSync).mockReturnValue(false) // 新 session 未 flush → 延迟删除路径
238
+ const appendEntry = vi.fn()
239
+
240
+ const cleanup = importLegacyStore(cwd, { appendEntry }, sessionFile)!
241
+ expect(cleanup).toBeDefined()
242
+
243
+ // 情形1:flush 已发生 → 正常 unlink
244
+ vi.mocked(fs.existsSync).mockReturnValue(true)
245
+ cleanup()
246
+ expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
247
+ expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
248
+
249
+ // 情形2(MF-2):并发——另一进程已 unlink(unlinkSync 抛 ENOENT)→ 静默吞掉不抛
250
+ vi.mocked(fs.unlinkSync).mockClear()
251
+ vi.mocked(fs.unlinkSync).mockImplementation(() => {
252
+ throw enoentError()
253
+ })
254
+ expect(() => cleanup()).not.toThrow()
255
+ expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
256
+
257
+ // 情形3(MF-2):非 ENOENT fs 错误(EACCES)→ 不吞,向上抛(index.ts try/finally 兜底复位)
258
+ vi.mocked(fs.unlinkSync).mockImplementation(() => {
259
+ throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
260
+ })
261
+ expect(() => cleanup()).toThrow(/EACCES/)
262
+
263
+ // 情形4:幂等——.imported 已不存在(本进程或并发已删)→ no-op,不抛
264
+ vi.mocked(fs.unlinkSync).mockClear()
265
+ vi.mocked(fs.existsSync).mockReturnValue(false)
266
+ expect(() => cleanup()).not.toThrow()
267
+ expect(fs.unlinkSync).not.toHaveBeenCalled()
268
+ })
269
+
270
+ // ── TC9:0 任务空 store → 无持久化依赖,仍立即 unlink ──
271
+ it('TC9: 空 store(tasks 空数组)→ 0 次 append,直接 unlink .imported(无数据可丢)', () => {
272
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ version: 1, tasks: [] }))
273
+ vi.mocked(fs.existsSync).mockReturnValue(false) // 新 session 也不影响:0 任务无持久化依赖
274
+ const appendEntry = vi.fn()
275
+
276
+ const cleanup = importLegacyStore(cwd, { appendEntry }, sessionFile)
277
+
278
+ expect(appendEntry).toHaveBeenCalledTimes(0)
279
+ expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
280
+ expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
281
+ expect(cleanup).toBeUndefined()
282
+ })
283
+ })