@zhushanwen/pi-scheduler 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "pi": {
7
7
  "extensions": [
8
8
  "./index.ts"
9
9
  ],
10
- "skills": []
10
+ "skills": [
11
+ "./skills"
12
+ ]
11
13
  },
12
14
  "keywords": [
13
15
  "pi-package"
@@ -19,6 +21,7 @@
19
21
  "files": [
20
22
  "index.ts",
21
23
  "src/**/*.ts",
24
+ "skills/",
22
25
  "vitest.config.ts"
23
26
  ],
24
27
  "peerDependencies": {
@@ -0,0 +1,191 @@
1
+ ---
2
+ name: scheduler-ext-config
3
+ description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)时加载。说明任务创建/管理方式(/schedule 命令与 schedule 工具)、调度格式(interval duration 与 cron)、数据存储机制(session JSONL 的 append-only event sourcing,无独立配置文件)、旧版 store 迁移、运行限制。触发词:配置定时任务、scheduler 配置、定时调度、cron 任务、interval 任务、scheduler 存储、scheduler 数据在哪、定时任务排查、scheduler-ext-config。"
4
+ ---
5
+
6
+ # scheduler 使用与存储指南
7
+
8
+ > @zhushanwen/pi-scheduler:定时任务调度扩展。任务到期时向当前 session 注入一条 message(`deliverAs: 'followUp'` + `triggerTurn: true`),唤醒 agent 开新一轮 turn 处理。
9
+
10
+ **重要前提**:scheduler **没有独立的配置文件**。任务通过命令/工具交互创建,数据以 append-only event sourcing 方式存储在 session JSONL 中(见下文「数据存储位置」)。排查「任务存哪 / 为什么 resume 后任务变了 / fork 后任务是否继承」都必须基于此模型理解,不要去找独立的 `scheduler.json`(那是已废弃的旧版格式,仅迁移探测时使用)。
11
+
12
+ ## 如何创建/管理定时任务
13
+
14
+ 两条入口,底层都走 `SchedulerService`(单一业务实现,无双轨):
15
+
16
+ ### 1. `/schedule` slash 命令(用户/AI 直接输入)
17
+
18
+ | 用法 | 作用 |
19
+ |------|------|
20
+ | `/schedule <schedule> <prompt>` | 创建 recurring 任务(默认) |
21
+ | `/schedule once <delay> <prompt>` | 创建一次性任务(执行一次后自动删除) |
22
+ | `/schedule cron '<cron表达式>' <prompt>` | 创建 cron 任务(**必须用引号**包裹,否则空格会被 tokenize 拆散) |
23
+ | `/schedule list` | 列出全部任务(按 nextRunAt 排序) |
24
+ | `/schedule on <id>` / `/schedule off <id>` | 启用 / 禁用某任务 |
25
+ | `/schedule rm <id>` | 删除某任务 |
26
+ | `/schedule run <id>` | 立即触发一次某任务 |
27
+
28
+ - 无参数 `/schedule`:当前返回「TUI 未实现」提示,用 `/schedule list` 查看任务。
29
+ - `on`/`off`/`rm`/`run` 的 `<id>` 支持命令补全(`getArgumentCompletions` 会列出 `id · name · schedule`)。
30
+ - cron 表达式含空格,**必须用单/双引号**包成一个 token,例:`/schedule cron '*/10 * * * *' 跑测试`。
31
+
32
+ ### 2. `schedule` / `schedule_control` 工具(AI 调用)
33
+
34
+ - **`schedule`**(创建):参数 `prompt`(必填,到期注入的消息)、`schedule`(必填,duration 或 cron)、`kind`(`once`/`recurring`,默认 `recurring`)、`name`(可选,缺省从 prompt 自动截取前 30 字)、`expires`(可选,默认 7 天;传 `"never"` 关闭过期)、`force`(可选,默认 `false`)。
35
+ - **`schedule_control`**(管理):`action` = `list`/`toggle`/`delete`/`run`,`id`(toggle/delete/run 必填),`enabled`(toggle 必填)。
36
+ - 两个工具的返回都是结构化 `{content: [{type:'text', text}], details, isError?}`,业务失败返回 `isError:true` + `details.errorCode`(不抛异常)。
37
+
38
+ > 创建/管理操作无需 agent idle——只有**到期 dispatch** 才受 idle/速率限制约束(见「运行限制与 dispatch 行为」)。
39
+
40
+ ## 调度格式
41
+
42
+ `parseSchedule` 的分流规则:**输入不含空格 → duration(interval 模式);含空格 → cron(cron 模式)**。
43
+
44
+ ### interval(duration 字符串)
45
+
46
+ 格式 `<数字><单位>`,单位不区分大小写、支持单复数:
47
+
48
+ | 单位 | 别名 | 毫秒 |
49
+ |------|------|------|
50
+ | `s` | `sec`/`second`/`seconds` | 1000 |
51
+ | `m` | `min`/`minute`/`minutes` | 60_000 |
52
+ | `h` | `hr`/`hour`/`hours` | 3_600_000 |
53
+ | `d` | `day`/`days` | 86_400_000 |
54
+
55
+ 示例:`5m`、`2h`、`1d`、`30seconds`。正则 `/^(\d+)\s*(s|sec|...)$/i`,不匹配则解析失败。
56
+
57
+ ### cron(cron 表达式)
58
+
59
+ - **5 字段**(分 时 日 月 周):自动在最前面补秒字段 `0`,变成 6 字段。例 `*/10 * * * *` → `0 */10 * * * *`(每 10 分钟)。
60
+ - **6 字段**(秒 分 时 日 月 周):原样使用。
61
+ - 其他字段数(<5 或 >6)视为无效。
62
+ - 底层用 `croner` 库(peerDependency,运行时动态 `import('croner')`;未安装时 cron 任务全部解析失败,interval 不受影响)。
63
+ - 创建时即校验表达式有效性(算不出下次执行时间 → `INVALID_SCHEDULE`);运行中表达式失效(极少见,如月份边界)→ 任务被停用并记 `lastError='cron expression invalid'`。
64
+
65
+ 示例:`*/30 * * * *`(每 30 分)、`0 9 * * 1-5`(工作日早 9 点)、`0 0 * * *`(每天 0 点)。
66
+
67
+ ## 数据存储位置(排查必读)
68
+
69
+ **当前版本采用 session JSONL 的 append-only event sourcing,没有独立数据文件。**
70
+
71
+ - 任务的所有变更以 custom entry 写入**创建该任务的 session 的 JSONL 文件**:
72
+ - 调用 `pi.appendEntry('pi-scheduler:task', op)`,`customType` 固定为 `pi-scheduler:task`。
73
+ - op 有四种:`upsert`(创建,携带全量 `TaskSnapshot`)、`advance`(recurring dispatch 成功后推进 `nextRunAt`)、`toggle`(启用/禁用)、`delete`(删除 / once 执行后 / 过期清理)。
74
+ - session 启动时(`session_start` 事件),`PiSchedulerBackend.loadTasks()` 调 `replayFoldEntries` 折叠当前 session 的全部 `pi-scheduler:task` custom entries,重放出当前任务状态。**append-only 不做全量 persist**——没有「保存」动作,每次操作即时 append。
75
+ - 因此「任务存哪」的答案是:**创建它的那个 session 的 JSONL 文件**。该文件位于 pi agent 目录下(`getAgentDir()` 读 `PI_CODING_AGENT_DIR`,默认 `~/.pi/agent`;xyz-agent 数据目录隔离时指向隔离目录如 `~/.xyz-agent/...`)。
76
+
77
+ ### owner 隔离(fork 行为)
78
+
79
+ - 每个 `upsert` op 顶层带 `ownerSessionFile`(创建任务时所属 session 的 JSONL 路径)。
80
+ - `replayFoldEntries` 重放时**过滤掉 owner 不是当前 session 的任务**(防 fork/branch 继承导致同一逻辑任务跨 session 重复触发)。
81
+ - 含义:在 session A 创建的任务,fork 出 session B 后,B 的 replay 看不到 A 的任务(owner 不匹配)。任务「归属」于创建它的 session。
82
+
83
+ ### 为什么找不到 `scheduler.json`
84
+
85
+ 当前版本**不写** `scheduler.json`。如果你在文档或旧讨论里看到 `scheduler.json`,那是指**已废弃的旧版 store 格式**(npm 0.1.1 及更早),仅用于一次性迁移探测(见下文「旧版数据迁移」)。不要试图手动编辑或查找该文件来管理当前任务。
86
+
87
+ ## 旧版数据迁移
88
+
89
+ 旧版(npm ≤ 0.1.1)用独立 store 文件,按 **cwd 隔离**存储:
90
+
91
+ ```
92
+ <agentDir>/scheduler/<root>/<segments>/scheduler.json
93
+ ```
94
+
95
+ - `<agentDir>` = `getAgentDir()`(候选 1)或 `~/.pi/agent`(候选 2,旧版硬编码)。
96
+ - `<root>` = cwd 根盘符 sanitize:mac/linux 的 `/` → `root`;Windows `C:\` → `c`(非字母数字转 `-`,trim 首尾,小写)。
97
+ - `<segments>` = cwd 去根盘符后的路径段,按 `path.sep` 拆分。例 cwd `/Users/foo/project` → `Users/foo/project`。
98
+ - 完整示例(mac):`~/.pi/agent/scheduler/root/Users/foo/project/scheduler.json`。
99
+
100
+ **迁移机制**(`importLegacyStore`,session_start 时自动执行,无需用户介入):
101
+
102
+ 1. 双候选探测:优先 `getAgentDir()` 路径,不存在则 fallback `~/.pi/agent/scheduler/...`(兼容 xyz-agent 数据目录隔离前的旧版写入位置)。
103
+ 2. 原子 rename `scheduler.json` → `scheduler.json.imported` 独占迁移;rename 抛 ENOENT 说明并发/崩溃已被别人处理,走 `.imported` 残留恢复。
104
+ 3. 读取 `.imported`,逐任务 `appendEntry('pi-scheduler:task', upsert)` 写入当前 session(owner 归属当前 session)。
105
+ 4. 删除 `.imported`:**新 session 首次 flush 前(尚未收到 assistant 消息)延迟删除**,由首个 `turn_end` / `session_shutdown` 确认 flush 后再删(防未 flush 即退出导致任务永久丢失 + 源文件已毁)。
106
+ 5. 迁移失败(read/parse/append 异常)整体降级:`console.warn` + 不阻断 session 启动,`.imported` 保留供下次重试。
107
+
108
+ 迁移是一次性的:迁移完成后旧 `scheduler.json` 已被 rename 走并删除,后续 session 不再有旧格式数据。
109
+
110
+ ## 运行限制与 dispatch 行为
111
+
112
+ | 限制 | 值 | 含义 |
113
+ |------|-----|------|
114
+ | 每 session 任务数上限 | 50(`MAX_TASKS`) | 超出创建报错 `Task limit reached` |
115
+ | dispatch 速率 | 6 次/分钟(`RATE_LIMIT_PER_MINUTE`) | 滑动窗口计数,超出则当前 tick 跳过、下个 tick 重试 |
116
+ | tick 间隔 | 30 秒(`TICK_INTERVAL_MS`) | 每 30 秒检查一次到期任务 |
117
+ | 默认过期 | 7 天(`DEFAULT_EXPIRY_MS`) | 仅 recurring;`expires="never"` 关闭 |
118
+ | 历史记录 | 最近 20 条(`HISTORY_LIMIT`) | 每任务的执行历史 |
119
+
120
+ dispatch 触发条件(`dispatchTask`):
121
+
122
+ - **非 force 任务**:仅在 `ctx.isIdle() && !ctx.hasPendingMessages()` 时触发;否则**延迟到下个 tick**(不丢弃,标记 `pending`,下个 30s tick 重试)。
123
+ - **force=true 任务**:即使 agent busy 也立即触发(用于必须准点执行的场景)。
124
+ - dispatch 成功后:recurring 推进 `nextRunAt` 并 append `advance`;once 删除任务并 append `delete`;失败(`sendMessage` 抛错)记 `lastStatus='failed'` 不 rethrow,下个 tick 重试(transient 失败重试语义,不 append advance)。
125
+ - 注入的消息:`{content: task.prompt, customType: 'pi-scheduler:dispatched', display: true}`,`deliverAs: 'followUp'` + `triggerTurn: true`(排进 followUp 队列并唤醒 agent 开新 turn)。
126
+
127
+ ## 任务数据结构
128
+
129
+ `ScheduledTask`(内存态,`types.ts`)核心字段:
130
+
131
+ - `id`:8 位 hex,自动生成。
132
+ - `name`:可读名称(用户指定或从 prompt 自动截取前 30 字)。
133
+ - `prompt`:到期注入的 message 内容。
134
+ - `kind`:`once` | `recurring`。
135
+ - `schedule`:`{mode:'cron', cronExpression}` | `{mode:'interval', intervalMs}`。
136
+ - `enabled`:是否启用。
137
+ - `force`:是否在 agent busy 时强制 dispatch。
138
+ - `createdAt` / `nextRunAt` / `expiresAt?`:时间戳(ms)。
139
+ - `runCount` / `lastRunAt?` / `lastStatus?`(`success`|`failed`)/ `lastError?`:执行统计。
140
+ - `history`:最近 20 条 `ExecutionRecord`(`{at, status, snippet?}`,snippet 为 agent 回复前 100 字)。
141
+ - `ownerSessionFile?`:归属 session JSONL 路径(fork 过滤用,非持久化业务字段)。
142
+ - `pending?`:运行时标记「到期待 dispatch」,非持久化(与 `enabled` 正交)。
143
+
144
+ 持久化写入 session JSONL 的是 `TaskSnapshot`(剥离 `ownerSessionFile`/`pending` 后的 15 字段)。
145
+
146
+ ## 示例
147
+
148
+ 创建一个每 5 分钟检查构建状态的任务:
149
+
150
+ ```
151
+ /schedule 5m 检查当前项目的构建状态,失败则报告原因
152
+ ```
153
+
154
+ 创建 2 小时后的一次性提醒:
155
+
156
+ ```
157
+ /schedule once 2h 提醒我 review 这个 PR
158
+ ```
159
+
160
+ 创建每 30 分钟跑测试的 cron 任务(注意引号):
161
+
162
+ ```
163
+ /schedule cron '*/30 * * * *' 跑一次 vitest 并报告结果
164
+ ```
165
+
166
+ 创建工作日早 9 点的早会提醒(不过期、force):
167
+
168
+ ```
169
+ /schedule cron '0 9 * * 1-5' 早会时间到了,总结昨天进展和今天计划
170
+ ```
171
+ (如需 force + 不过期,用 `schedule` 工具传 `force:true, expires:"never"`,命令行暂未暴露这两个开关)
172
+
173
+ 列出并禁用某任务:
174
+
175
+ ```
176
+ /schedule list
177
+ /schedule off abc12345
178
+ ```
179
+
180
+ AI 通过工具创建(force + 永不过期):
181
+
182
+ ```
183
+ schedule({ prompt: "...", schedule: "1h", kind: "recurring", force: true, expires: "never", name: " hourly-check" })
184
+ ```
185
+
186
+ ## 备注
187
+
188
+ - **croner 依赖**:cron 模式依赖 `croner`(peerDependency)。未安装时所有 cron 任务解析失败(返回 `INVALID_SCHEDULE`),interval 任务不受影响。集成方(如 xyz-agent mandatory 安装)需确保 `croner` 可用。
189
+ - **数据目录隔离**:任务存储在 `getAgentDir()` 指向的 session JSONL。xyz-agent 通过 `XYZ_AGENT_DATA_DIR` / `PI_CODING_AGENT_DIR` 隔离实例时,任务随 session 落在隔离目录,与 `~/.pi/agent` 互不干扰。
190
+ - **无配置 schema 可编辑**:scheduler 的所有状态都由运行时命令产生,没有可手动编辑的配置文件。要「批量预置任务」只能在 session 内逐条创建(或迁移旧 store)。
191
+ - **TUI 管理器未实现**:无参 `/schedule` 当前只返回提示,任务管理请用 `list`/`on`/`off`/`rm`/`run` 子命令或 `schedule_control` 工具。
@@ -1,4 +1,6 @@
1
1
  import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
2
4
 
3
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
6
 
@@ -281,3 +283,29 @@ describe('importLegacyStore', () => {
281
283
  expect(cleanup).toBeUndefined()
282
284
  })
283
285
  })
286
+
287
+ // ── getLegacyStorePath 双候选探测(合并 feat-auto-name-session-refactor 4b5513b5e 后落实)──
288
+ // 候选 1:getAgentDir()(PI_CODING_AGENT_DIR 隔离目录);候选 2:已发布版 npm 0.1.1 硬编码
289
+ // ~/.pi/agent。getAgentDir() 每次调用读 process.env(非模块加载缓存),stubEnv 后直接调用即可。
290
+ // fs 已 mock(模块顶部 vi.mock('node:fs')),existsSync 控制探测命中。
291
+ describe('getLegacyStorePath 双候选探测', () => {
292
+ afterEach(() => {
293
+ vi.unstubAllEnvs()
294
+ })
295
+
296
+ it('TC10a: PI_CODING_AGENT_DIR 隔离目录下旧 store 存在 → 优先 getAgentDir() 路径', () => {
297
+ vi.stubEnv('PI_CODING_AGENT_DIR', '/tmp/iso-agent')
298
+ const isoPath = path.join('/tmp/iso-agent', 'scheduler', 'root', 'fake', 'workspace', 'scheduler.json')
299
+ vi.mocked(fs.existsSync).mockImplementation(p => p === isoPath)
300
+
301
+ expect(getLegacyStorePath(cwd)).toBe(isoPath)
302
+ })
303
+
304
+ it('TC10b: 隔离目录下旧 store 不存在 → fallback 已发布版 ~/.pi/agent 路径', () => {
305
+ vi.stubEnv('PI_CODING_AGENT_DIR', '/tmp/iso-agent')
306
+ vi.mocked(fs.existsSync).mockReturnValue(false)
307
+ const legacyPath = path.join(os.homedir(), '.pi', 'agent', 'scheduler', 'root', 'fake', 'workspace', 'scheduler.json')
308
+
309
+ expect(getLegacyStorePath(cwd)).toBe(legacyPath)
310
+ })
311
+ })
@@ -0,0 +1,171 @@
1
+ // src/__tests__/index-generation.test.ts
2
+ //
3
+ // G1(S9 review 修复 / R3-M1 模块级化)集成单测:index.ts 装配点为每代 SchedulerRuntime
4
+ // 注入代际检测回调(模块级 sessionGeneration 比对),使 stale 分诊不依赖 pi 错误文案。
5
+ //
6
+ // 两类拓扑:
7
+ // - 同闭包重复 fire session_start(rpc-mode bindExtensions 重调等次要路径)——第一组用例
8
+ // 在同一 factory 闭包上连续触发验证代数接线。
9
+ // - factory 重跑(生产主路径:pi 每次 session 替换 newSession/fork/switchSession 都重跑
10
+ // factory 函数体,loader.ts extensionCache 只缓存 factory 函数对象不缓存执行结果)——
11
+ // 装配级用例模拟两次独立 factory() 调用(各自新闭包、共享模块级代数),实证模块级变量
12
+ // 跨 factory 重跑保留:第二代 session_start 递增模块级计数器后,第一代 runtime 的
13
+ // isCtxStale 翻转为 true 且其泄漏 timer 在下个 tick 前置检查自停。闭包级实现(R3-M1
14
+ // 修复前)在此拓扑下恒 false——正是被 R3 实测证伪的生产失效路径。
15
+ //
16
+ // 本文件用 InstrumentedRuntime(继承真实 SchedulerRuntime,仅记录构造第三参 isCtxStale)
17
+ // 捕获注入回调。InstrumentedRuntime 全部行为继承父类,不影响装配链本身(F1 停旧 timer
18
+ // 等行为由 index-session-start.test.ts U4 锚定,此处不重复)。
19
+
20
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22
+
23
+ // 与 index-session-start.test.ts 同款(MF-3):mock 掉 importer,装配路径仍被调用、FS 副作用为零。
24
+ vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
25
+
26
+ // vi.mock factory 会被提升,跨模块共享状态必须经 vi.hoisted。
27
+ const { isCtxStaleCaptures, runtimeInstances } = vi.hoisted(() => ({
28
+ isCtxStaleCaptures: [] as Array<(() => boolean) | undefined>,
29
+ runtimeInstances: [] as Array<{ stopScheduler(): void }>,
30
+ }))
31
+
32
+ // InstrumentedRuntime 继承真实实现(loadTasks/onAfterTick/startScheduler 均真实执行),
33
+ // 仅捕获构造第三参并登记实例(afterEach 统一停 timer,避免真实 setInterval 残留)。
34
+ vi.mock('../runtime.js', async (importOriginal) => {
35
+ const actual = await importOriginal<typeof import('../runtime.js')>()
36
+ const RealSchedulerRuntime = actual.SchedulerRuntime
37
+ class InstrumentedRuntime extends RealSchedulerRuntime {
38
+ constructor(
39
+ backend: ConstructorParameters<typeof RealSchedulerRuntime>[0],
40
+ ctx: ConstructorParameters<typeof RealSchedulerRuntime>[1],
41
+ isCtxStale?: () => boolean,
42
+ ) {
43
+ super(backend, ctx, isCtxStale)
44
+ isCtxStaleCaptures.push(isCtxStale)
45
+ runtimeInstances.push(this)
46
+ }
47
+ }
48
+ return { ...actual, SchedulerRuntime: InstrumentedRuntime }
49
+ })
50
+
51
+ import schedulerExtension from '../index.js'
52
+
53
+ const TICK_INTERVAL_MS = 30_000
54
+
55
+ /**
56
+ * 最小 fake pi:与 index-session-start.test.ts 同款,覆盖 factory 消费的 API 面
57
+ * (on 捕获事件 handler 供手动触发;registerTool/registerCommand/sendMessage/appendEntry 兜底)。
58
+ */
59
+ function createMockPi(): {
60
+ pi: ExtensionAPI
61
+ events: Map<string, (...args: unknown[]) => void>
62
+ } {
63
+ const events = new Map<string, (...args: unknown[]) => void>()
64
+ const pi = {
65
+ registerTool: vi.fn(),
66
+ registerCommand: vi.fn(),
67
+ on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
68
+ sendMessage: vi.fn(),
69
+ appendEntry: vi.fn(),
70
+ } as unknown as ExtensionAPI
71
+ return { pi, events }
72
+ }
73
+
74
+ /** 最小 fake ctx:覆盖 session_start 装配链读到的全部字段(backend 构造 / runtime 构造 / refreshWidget)。 */
75
+ function createFakeCtx(sessionFile: string): ExtensionContext {
76
+ return {
77
+ cwd: '/test-index-generation',
78
+ isIdle: () => true,
79
+ hasPendingMessages: () => false,
80
+ ui: { setWidget: vi.fn() },
81
+ sessionManager: {
82
+ getEntries: () => [],
83
+ getSessionFile: () => sessionFile,
84
+ },
85
+ } as unknown as ExtensionContext
86
+ }
87
+
88
+ describe('G1: index.ts 代际接线(S9)', () => {
89
+ beforeEach(() => {
90
+ isCtxStaleCaptures.length = 0
91
+ runtimeInstances.length = 0
92
+ })
93
+
94
+ afterEach(() => {
95
+ for (const rt of runtimeInstances) {
96
+ rt.stopScheduler()
97
+ }
98
+ })
99
+
100
+ it('每次 session_start 注入 isCtxStale:当前代 false、任意前代 true(跨三代)', () => {
101
+ const { pi, events } = createMockPi()
102
+ schedulerExtension(pi)
103
+ const sessionStart = events.get('session_start')
104
+ expect(sessionStart).toBeDefined()
105
+
106
+ // 第 1 代
107
+ sessionStart!({ type: 'session_start', reason: 'startup' }, createFakeCtx('/test/gen-1.json'))
108
+ expect(isCtxStaleCaptures[0]).toBeTypeOf('function')
109
+ expect(isCtxStaleCaptures[0]!()).toBe(false) // 当前代未替换
110
+
111
+ // 第 2 代(session 替换):第 1 代自此 stale,第 2 代为当前代
112
+ sessionStart!({ type: 'session_start', reason: 'new_session' }, createFakeCtx('/test/gen-2.json'))
113
+ expect(isCtxStaleCaptures[0]!()).toBe(true)
114
+ expect(isCtxStaleCaptures[1]!()).toBe(false)
115
+
116
+ // 第 3 代(resume 重入):前两代均 stale
117
+ sessionStart!({ type: 'session_start', reason: 'resume' }, createFakeCtx('/test/gen-3.json'))
118
+ expect(isCtxStaleCaptures[0]!()).toBe(true)
119
+ expect(isCtxStaleCaptures[1]!()).toBe(true)
120
+ expect(isCtxStaleCaptures[2]!()).toBe(false)
121
+ })
122
+
123
+ // ── R3-M1/R3-S5:factory 重跑装配级用例(生产主路径拓扑)──
124
+ // pi 每次 session 替换都重跑 factory 函数体(新闭包)。本用例模拟两次独立 factory()
125
+ // 调用 + 各 fire 一次 session_start,实证「模块级 sessionGeneration 跨 factory 重跑保留」:
126
+ // 闭包级实现(修复前)在第二代 session_start 后 firstStale() 仍恒 false——测试即失败。
127
+ it('factory 重跑:第二次 factory 执行 + session_start 后,第一代 runtime isCtxStale 为 true 且 tick 前置自停', async () => {
128
+ vi.useFakeTimers()
129
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
130
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
131
+ try {
132
+ // 第一代:独立 factory 执行 + session_start 装配(runtime1 真实 startScheduler)
133
+ const first = createMockPi()
134
+ schedulerExtension(first.pi)
135
+ const firstSessionStart = first.events.get('session_start')
136
+ expect(firstSessionStart).toBeDefined()
137
+ firstSessionStart!({ type: 'session_start', reason: 'startup' }, createFakeCtx('/test/factory-rerun-1.json'))
138
+ const firstStale = isCtxStaleCaptures[0]!
139
+ expect(firstStale).toBeTypeOf('function')
140
+ expect(firstStale()).toBe(false) // 第一代是当前代
141
+
142
+ // 第二代:再次独立执行 factory(模拟 newSession/fork/switchSession 的 factory 重跑,
143
+ // 新闭包的 service 为 null——第一代闭包的 service 对它不可见,F1 停不到第一代 timer,
144
+ // 复现「F1 未能触达、只剩 G1 前置检查」的泄漏路径)+ session_start
145
+ const second = createMockPi()
146
+ schedulerExtension(second.pi)
147
+ const secondSessionStart = second.events.get('session_start')
148
+ expect(secondSessionStart).toBeDefined()
149
+ secondSessionStart!({ type: 'session_start', reason: 'new_session' }, createFakeCtx('/test/factory-rerun-2.json'))
150
+
151
+ // 核心断言:模块级计数器被第二代闭包的 session_start 递增,第一代 runtime 的
152
+ // isCtxStale 生效(闭包级实现在此恒 false——R3-M1 修复的生产失效路径)
153
+ expect(firstStale()).toBe(true)
154
+ expect(isCtxStaleCaptures[1]!()).toBe(false) // 第二代是当前代
155
+
156
+ // tick 前置自停:第一代 runtime 的泄漏 timer 在下个 tick 被代际前置检查拦截
157
+ // (G1-b 的生产路径验证:warn "tick stopped" + timer 自停,后续 tick 不再发生)
158
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
159
+ const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
160
+ expect(warnText).toContain('tick stopped')
161
+ expect(warnText).not.toContain('tick error')
162
+
163
+ const warnCountAfterSelfStop = warnSpy.mock.calls.length
164
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 warn
165
+ expect(warnSpy.mock.calls.length).toBe(warnCountAfterSelfStop)
166
+ } finally {
167
+ warnSpy.mockRestore()
168
+ vi.useRealTimers()
169
+ }
170
+ })
171
+ })
@@ -0,0 +1,107 @@
1
+ // src/__tests__/index-session-start.test.ts
2
+ //
3
+ // F1 集成单测(crash-fix U4):session_start 多发/重入时先停上一代 runtime 的 tick interval。
4
+ // 排查结论:dispatch 的 await sendMessage 窗口与 session 替换交错时,旧 session_shutdown 可能
5
+ // 永远等不到 → 旧 30s tick timer 泄漏 → 下一 tick 的 refreshWidget 访问 stale ctx.ui 抛错 →
6
+ // unhandledRejection → pi 主进程 exit 1。F1 在 session_start 开头幂等 stopScheduler,从源头消灭。
7
+ //
8
+ // 行为断言口径(验收 U4):第二次 session_start 后 advance 30s,tick 引起的 widget 刷新
9
+ // 增量恰 +1(只有新 runtime 的 timer 在跑);F1 缺失时两个 timer 都活着,增量为 +2。
10
+ // 观测面说明:session_start 硬编码 new PiSchedulerBackend(now() = Date.now(),无法注入计数),
11
+ // 故用 onAfterTick → refreshWidget → ctx.ui.setWidget 的调用计数作为 tick 发生次数的
12
+ // 行为观测面(每个 tick 恰好一次,与 backend.now 计数等价)。
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16
+
17
+ // 与 sdk-contract.test.ts 同款(MF-3):session_start 会真实执行 importLegacyStore(ctx.cwd, ...),
18
+ // 触碰用户真实 FS(~/.pi/agent/scheduler/... 的 renameSync/existsSync 探测)。mock 掉 importer
19
+ // 模块,装配路径仍被调用、FS 副作用为零。
20
+ vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
21
+
22
+ import schedulerExtension from '../index.js'
23
+
24
+ const TICK_INTERVAL_MS = 30_000
25
+
26
+ /**
27
+ * 最小 fake pi:覆盖 index.ts factory + commands.ts 注册路径消费的 API 面
28
+ * (on / registerTool / registerCommand / sendMessage / appendEntry)。
29
+ * on 捕获事件 handler 供手动触发;sendMessage/appendEntry 为 vi.fn 兜底(本套件无任务 dispatch)。
30
+ */
31
+ function createMockPi(): {
32
+ pi: ExtensionAPI
33
+ events: Map<string, (...args: unknown[]) => void>
34
+ } {
35
+ const events = new Map<string, (...args: unknown[]) => void>()
36
+ const pi = {
37
+ registerTool: vi.fn(),
38
+ registerCommand: vi.fn(),
39
+ on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
40
+ sendMessage: vi.fn(),
41
+ appendEntry: vi.fn(),
42
+ } as unknown as ExtensionAPI
43
+ return { pi, events }
44
+ }
45
+
46
+ /**
47
+ * 最小 fake ctx:覆盖 session_start 装配链读到的全部字段——PiSchedulerBackend 构造
48
+ * (sessionManager)、SchedulerRuntime 构造(isIdle/hasPendingMessages)、importLegacyStore
49
+ * (cwd,已 mock)、refreshWidget(ui.setWidget)。
50
+ * setWidget 以独立引用导出:session_start 初始渲染 + 每次 tick 末 onAfterTick 各调一次,
51
+ * 是「哪个 runtime 的 timer 还在 tick」的行为观测面。
52
+ */
53
+ function createFakeCtx(sessionFile: string): {
54
+ ctx: ExtensionContext
55
+ setWidget: ReturnType<typeof vi.fn>
56
+ } {
57
+ const setWidget = vi.fn()
58
+ const ctx = {
59
+ cwd: '/test-index-session-start',
60
+ isIdle: () => true,
61
+ hasPendingMessages: () => false,
62
+ ui: { setWidget },
63
+ sessionManager: {
64
+ getEntries: () => [],
65
+ getSessionFile: () => sessionFile,
66
+ },
67
+ } as unknown as ExtensionContext
68
+ return { ctx, setWidget }
69
+ }
70
+
71
+ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
72
+ beforeEach(() => {
73
+ vi.useFakeTimers()
74
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
75
+ })
76
+
77
+ afterEach(() => {
78
+ vi.useRealTimers()
79
+ })
80
+
81
+ it('U4: 双 session_start 后旧 timer 已停——advance 30s tick 引起的刷新恰 +1 而非 +2', async () => {
82
+ const { pi, events } = createMockPi()
83
+ schedulerExtension(pi)
84
+ const sessionStart = events.get('session_start')
85
+ expect(sessionStart).toBeDefined()
86
+
87
+ // 第一次 session_start:runtime1 + timer1 启动,初始渲染 1 次
88
+ const first = createFakeCtx('/test/session-1.json')
89
+ sessionStart!({ type: 'session_start', reason: 'startup' }, first.ctx)
90
+ expect(first.setWidget).toHaveBeenCalledTimes(1)
91
+
92
+ // 前置因果锚点:advance 30s,timer1 正常 tick 一次(排除「timer 从未启动」的假绿)
93
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
94
+ expect(first.setWidget).toHaveBeenCalledTimes(2)
95
+
96
+ // 第二次 session_start(session 替换):F1 在装配新 runtime 前停掉 timer1
97
+ const second = createFakeCtx('/test/session-2.json')
98
+ sessionStart!({ type: 'session_start', reason: 'new_session' }, second.ctx)
99
+ expect(second.setWidget).toHaveBeenCalledTimes(1) // runtime2 初始渲染
100
+
101
+ // 行为断言(验收口径):再 advance 30s,只有 runtime2 的 timer 触发一次 tick——
102
+ // F1 缺失时 timer1/timer2 都活着,first 与 second 的 spy 各 +1(合计 +2)
103
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS)
104
+ expect(second.setWidget).toHaveBeenCalledTimes(2) // 恰 +1:新 runtime 正常调度
105
+ expect(first.setWidget).toHaveBeenCalledTimes(2) // 旧 runtime 的 tick 不再发生(timer 已停)
106
+ })
107
+ })
@@ -162,6 +162,77 @@ describe('SchedulerRuntime', () => {
162
162
  })
163
163
  })
164
164
 
165
+ // ── R3-S1:dispatchTask in-flight 守卫 ──
166
+ // tick 为 fire-and-forget:tick1 的 await sendMessage 挂起超过 TICK_INTERVAL_MS(如 pi
167
+ // 卡死)时,tick2 的 step2 再标 pending → step3 对同一 task 并发第二个 dispatch → 同一
168
+ // prompt 双注入(force 任务绕过 isIdle gate 直接受影响)。守卫:同任务在途(Set<taskId>)
169
+ // 时 skip 本轮并 warn;不同任务不受影响。
170
+ describe('dispatchTask in-flight 守卫(R3-S1)', () => {
171
+ beforeEach(() => {
172
+ vi.useFakeTimers()
173
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
174
+ })
175
+
176
+ afterEach(() => {
177
+ vi.useRealTimers()
178
+ })
179
+
180
+ it('sendMessage 挂起期间下一 tick 同任务被跳过:不双注入、warn in-flight、完成后 runCount=1', async () => {
181
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
182
+ let resolveSend: (() => void) | undefined
183
+ const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
184
+ backend.sendMessage = vi.fn(() => sendPromise)
185
+
186
+ const task = await runtime.addTask('in-flight', { mode: 'interval', intervalMs: 60000 }, { force: true })
187
+ task.nextRunAt = Date.now() - 1000
188
+
189
+ // tick1:dispatch 进入 sendMessage 挂起(同步段已置 in-flight 标记)
190
+ const tick1 = runtime.tickScheduler()
191
+ expect(backend.sendMessage).toHaveBeenCalledTimes(1)
192
+ expect(task.pending).toBe(true) // dispatch 未完成,pending 未清
193
+
194
+ // tick2(模拟 30s 后 pi 仍卡死):同任务再标 pending → step3 dispatchTask 命中
195
+ // in-flight 守卫 → skip + warn(修复前:同一 prompt 双注入)
196
+ await runtime.tickScheduler()
197
+ expect(backend.sendMessage).toHaveBeenCalledTimes(1)
198
+ const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
199
+ expect(warnText).toContain('already in flight')
200
+
201
+ // 放行挂起的 sendMessage:tick1 正常收尾(状态推进恰好一次)
202
+ resolveSend!()
203
+ await tick1
204
+ expect(backend.sendMessage).toHaveBeenCalledTimes(1)
205
+ expect(task.runCount).toBe(1)
206
+ expect(task.pending).toBe(false)
207
+ warnSpy.mockRestore()
208
+ })
209
+
210
+ it('挂起 dispatch 只挡同任务:其他任务在下一 tick 正常 dispatch 不受影响', async () => {
211
+ let resolveSend: (() => void) | undefined
212
+ const sendPromise = new Promise<void>(resolve => { resolveSend = resolve })
213
+ backend.sendMessage = vi.fn(() => sendPromise)
214
+
215
+ const task1 = await runtime.addTask('first', { mode: 'interval', intervalMs: 60000 }, { force: true })
216
+ const task2 = await runtime.addTask('second', { mode: 'interval', intervalMs: 60000 }, { force: true })
217
+ task1.nextRunAt = Date.now() - 1000
218
+ task2.nextRunAt = Date.now() - 1000
219
+
220
+ // tick1:task1 dispatch 挂起(step3 顺序 await,task2 尚未轮到)
221
+ const tick1 = runtime.tickScheduler()
222
+ expect(backend.sendMessage).toHaveBeenCalledTimes(1)
223
+
224
+ // tick2:task1 命中守卫跳过,task2 无在途 → 正常 dispatch(守卫按 taskId 粒度隔离)。
225
+ // 注:tick2 挂起在 task1 的 await dispatchTask(守卫命中即 resolved promise)与 task2
226
+ // 的 await sendMessage 上,先放行再 await,完成态统一断言
227
+ const tick2 = runtime.tickScheduler()
228
+ resolveSend!()
229
+ await Promise.all([tick1, tick2])
230
+ expect(backend.sendMessage).toHaveBeenCalledTimes(2)
231
+ expect(task1.runCount).toBe(1)
232
+ expect(task2.runCount).toBe(1)
233
+ })
234
+ })
235
+
165
236
  // ── M10b:rate-limit ──
166
237
  // dispatchTask 受 RATE_LIMIT_PER_MINUTE=6 限制。前 6 次成功(sendMessage 被调),
167
238
  // 第 7 次被 hasDispatchCapacity 拒绝(dispatchTimestamps.length 已达 6)。
@@ -463,4 +534,199 @@ describe('SchedulerRuntime', () => {
463
534
  expect(task.expiresAt).toBeUndefined()
464
535
  })
465
536
  })
537
+
538
+ // ── F2:tick 错误分诊(crash-fix)──
539
+ // startScheduler 的 interval 回调对 fire-and-forget 的 tickScheduler() 加 catch:
540
+ // stale 类错误(session 替换后泄漏 timer 访问 stale ctx)→ warn "tick stopped" + stopScheduler
541
+ // 自停;其他错误 → warn "tick error" 继续调度。修复前 tick 内异常无人接住 →
542
+ // unhandledRejection → pi 主进程 exit 1。
543
+ describe('tick 错误分诊(F2)', () => {
544
+ const TICK_INTERVAL_MS = 30_000
545
+
546
+ beforeEach(() => {
547
+ vi.useFakeTimers()
548
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
549
+ })
550
+
551
+ afterEach(() => {
552
+ runtime.stopScheduler()
553
+ vi.useRealTimers()
554
+ })
555
+
556
+ it('U1: stale 错误 → warn "tick stopped" + timer 自停,后续 tick 不再发生', async () => {
557
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
558
+ const nowSpy = vi.spyOn(backend, 'now')
559
+ runtime.onAfterTick(() => {
560
+ throw new Error('This extension ctx is stale after session replacement or reload.')
561
+ })
562
+
563
+ runtime.startScheduler()
564
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:stale 抛 → catch 分诊 → 自停
565
+
566
+ const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
567
+ expect(warnText).toContain('tick stopped')
568
+ expect(warnText).not.toContain('tick error')
569
+
570
+ const countAfterSelfStop = nowSpy.mock.calls.length
571
+ expect(countAfterSelfStop).toBeGreaterThan(0) // tick1 确实跑过(排除「timer 未启动」假绿)
572
+
573
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 60s:timer 已停,无新 tick
574
+ expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop) // now 计数不再增长
575
+ warnSpy.mockRestore()
576
+ nowSpy.mockRestore()
577
+ })
578
+
579
+ it('U2: 非 stale 错误 → warn "tick error" 且调度继续(advance 两次 now 计数 +2)', async () => {
580
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
581
+ const nowSpy = vi.spyOn(backend, 'now')
582
+ runtime.onAfterTick(() => {
583
+ throw new Error('boom')
584
+ })
585
+
586
+ runtime.startScheduler()
587
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:warn 但不停
588
+
589
+ const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
590
+ expect(warnText).toContain('tick error')
591
+ expect(warnText).not.toContain('tick stopped')
592
+
593
+ const countAfterFirstTick = nowSpy.mock.calls.length
594
+ expect(countAfterFirstTick).toBeGreaterThan(0)
595
+
596
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 2 个后续 tick 照常
597
+ expect(nowSpy.mock.calls.length).toBe(countAfterFirstTick + 2)
598
+ warnSpy.mockRestore()
599
+ nowSpy.mockRestore()
600
+ })
601
+
602
+ it('U5: stopScheduler 幂等——连续调用两次不抛、无副作用', () => {
603
+ runtime.startScheduler()
604
+ expect(() => {
605
+ runtime.stopScheduler()
606
+ runtime.stopScheduler()
607
+ }).not.toThrow()
608
+ })
609
+ })
610
+
611
+ // ── G1:代际检测分诊(S9 review 修复 / R3-M1 模块级化)──
612
+ // isCtxStale(index.ts 注入的模块级代数比对)为主判,替代对 pi 错误文案的依赖:
613
+ // - G1-a:in-flight tick 内代际翻转 + 任意非文案错误 → catch 分诊走 stale 自停(不依赖文案)
614
+ // - G1-b:代际翻转后(无任何错误)泄漏 timer 在下个 tick 前置检查自停,不进入 tick
615
+ // - G1-c:显式注入 isCtxStale=false + 非 stale 错误 → 仍 "tick error" 继续调度(不误伤)
616
+ // - G1-d:isCtxStale=false 但错误文案含 stale 片段 → 文案兜底仍自停(覆盖 reload 盲区:
617
+ // clearExtensionCache 后 jiti 重 import 全新模块环境,旧闭包的模块级代数冻结不再递增,
618
+ // 只剩文案能识别 stale;模块级方案的装配级验证见 index-generation.test.ts factory 重跑用例)
619
+ describe('G1: 代际检测分诊(S9)', () => {
620
+ const TICK_INTERVAL_MS = 30_000
621
+ let staleFlag: boolean
622
+ let genRuntime: SchedulerRuntime
623
+
624
+ beforeEach(() => {
625
+ vi.useFakeTimers()
626
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
627
+ staleFlag = false
628
+ genRuntime = new SchedulerRuntime(backend, mockCtx, () => staleFlag)
629
+ })
630
+
631
+ afterEach(() => {
632
+ genRuntime.stopScheduler()
633
+ vi.useRealTimers()
634
+ })
635
+
636
+ it('G1-a: in-flight tick 期间代际翻转 + 非文案错误 → warn "tick stopped" + 自停(不依赖 pi 错误文案)', async () => {
637
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
638
+ const nowSpy = vi.spyOn(backend, 'now')
639
+ // tick 内先翻世代(模拟 session 替换交错发生在 dispatch await 窗口),再抛与
640
+ // pi 文案完全无关的错误——旧实现按文案分诊会误判为普通错误继续调度(若 pi 改文案)
641
+ genRuntime.onAfterTick(() => {
642
+ staleFlag = true
643
+ throw new Error('some unexpected failure')
644
+ })
645
+
646
+ genRuntime.startScheduler()
647
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:catch 分诊走 G1 代际 → 自停
648
+
649
+ const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
650
+ expect(warnText).toContain('tick stopped')
651
+ expect(warnText).not.toContain('tick error')
652
+
653
+ const countAfterSelfStop = nowSpy.mock.calls.length
654
+ expect(countAfterSelfStop).toBeGreaterThan(0) // tick1 确实跑过(排除「timer 未启动」假绿)
655
+
656
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,无新 tick
657
+ expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop)
658
+ warnSpy.mockRestore()
659
+ nowSpy.mockRestore()
660
+ })
661
+
662
+ it('G1-b: 代际翻转后泄漏 timer 在下个 tick 前置检查自停——不进入 tick(backend.now 零调用)', async () => {
663
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
664
+ const nowSpy = vi.spyOn(backend, 'now')
665
+ genRuntime.startScheduler()
666
+ // session 替换:代际翻转(F1 未能触达的泄漏 timer 场景;无任何错误发生)
667
+ staleFlag = true
668
+
669
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:前置检查命中 → 自停
670
+
671
+ const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
672
+ expect(warnText).toContain('tick stopped')
673
+ expect(warnText).not.toContain('tick error')
674
+ // 前置检查在 tickScheduler 之前拦截:tick 本体未执行(now 零调用,无 dispatch/append)
675
+ expect(nowSpy).not.toHaveBeenCalled()
676
+
677
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // timer 已停,仍零调用
678
+ expect(nowSpy).not.toHaveBeenCalled()
679
+ warnSpy.mockRestore()
680
+ nowSpy.mockRestore()
681
+ })
682
+
683
+ it('G1-c: isCtxStale 注入但返回 false + 非 stale 错误 → warn "tick error" 且调度继续', async () => {
684
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
685
+ const nowSpy = vi.spyOn(backend, 'now')
686
+ // staleFlag 恒 false(beforeEach 初始化):代际未翻转,注入存在不改变分诊结果
687
+ genRuntime.onAfterTick(() => {
688
+ throw new Error('boom')
689
+ })
690
+
691
+ genRuntime.startScheduler()
692
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:非 stale → warn 继续调度
693
+
694
+ const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
695
+ expect(warnText).toContain('tick error')
696
+ expect(warnText).not.toContain('tick stopped')
697
+
698
+ const countAfterFirstTick = nowSpy.mock.calls.length
699
+ expect(countAfterFirstTick).toBeGreaterThan(0)
700
+
701
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2) // 2 个后续 tick 照常
702
+ expect(nowSpy.mock.calls.length).toBe(countAfterFirstTick + 2)
703
+ warnSpy.mockRestore()
704
+ nowSpy.mockRestore()
705
+ })
706
+
707
+ it('G1-d: isCtxStale 返回 false 但错误文案含 stale 片段 → 文案兜底仍自停(覆盖 reload 盲区)', async () => {
708
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
709
+ const nowSpy = vi.spyOn(backend, 'now')
710
+ // reload 场景模拟:factory 重跑后旧闭包代际计数不再递增(staleFlag 恒 false),
711
+ // 只有错误文案能识别 stale——兜底支必须独立于代际检测生效
712
+ genRuntime.onAfterTick(() => {
713
+ throw new Error('This extension ctx is stale after session replacement or reload.')
714
+ })
715
+
716
+ genRuntime.startScheduler()
717
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS) // tick1:文案兜底 → 自停
718
+
719
+ const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
720
+ expect(warnText).toContain('tick stopped')
721
+ expect(warnText).not.toContain('tick error')
722
+
723
+ const countAfterSelfStop = nowSpy.mock.calls.length
724
+ expect(countAfterSelfStop).toBeGreaterThan(0)
725
+
726
+ await vi.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2)
727
+ expect(nowSpy.mock.calls.length).toBe(countAfterSelfStop)
728
+ warnSpy.mockRestore()
729
+ nowSpy.mockRestore()
730
+ })
731
+ })
466
732
  })
@@ -188,7 +188,7 @@ describe('SchedulerService', () => {
188
188
  expect(result).toEqual({
189
189
  success: false,
190
190
  errorCode: 'DISPATCH_SKIPPED',
191
- message: `Task ${id} not dispatched (busy, disabled, or rate-limited).`,
191
+ message: `Task ${id} not dispatched (busy, disabled, rate-limited, or a dispatch already in flight).`,
192
192
  })
193
193
  })
194
194
 
package/src/importer.ts CHANGED
@@ -2,26 +2,26 @@ import * as fs from 'node:fs'
2
2
  import * as os from 'node:os'
3
3
  import * as path from 'node:path'
4
4
 
5
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
6
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
6
7
 
7
8
  import type { ScheduledTask, SchedulerEntryOp, SchedulerStore, TaskSnapshot } from './types.js'
8
9
 
9
10
  /**
10
- * 获取旧 store 文件路径:~/.pi/agent/scheduler/<root>/<segments>/scheduler.json
11
+ * 获取旧 store 文件路径:scheduler/<root>/<segments>/scheduler.json,双候选探测。
11
12
  *
12
13
  * 内联自 store.ts getStorePath——store.ts 本 wave 删除后此函数是旧路径的唯一推导实现,
13
14
  * 不能 import 已删 store。workspace 路径隔离,不同 cwd 存不同文件。
14
15
  *
15
16
  * export 供测试推导期望路径(断言 renameSync/unlinkSync 参数)。
16
17
  *
17
- * ⚠️ 路径硬编码说明:硬编码 `~/.pi/agent/scheduler/` 是因为已发布版(npm 0.1.1)的 store.ts
18
- * 即用此路径,旧数据确在此处,必须按此路径探测才能迁移。注意分支 `feat-auto-name-session-refactor`
19
- * commit 4b5513b5e store 路径改为 getAgentDir()(读 PI_CODING_AGENT_DIR,用于 xyz-agent
20
- * 数据目录隔离);该分支合并后此处需改为双候选路径探测(先 getAgentDir() fallback ~/.pi/agent),
21
- * 否则 xyz-agent 隔离目录下的旧任务探测不到。
18
+ * 双候选探测(合并 feat-auto-name-session-refactor 分支 4b5513b5e 后落实):
19
+ * 候选 1 用 pi 的 getAgentDir()(读 PI_CODING_AGENT_DIR,xyz-agent 数据目录隔离时指向隔离目录),
20
+ * 候选 2 是已发布版(npm 0.1.1)store.ts 的硬编码 `~/.pi/agent/scheduler/`。探测规则:
21
+ * 候选 1 存在则用候选 1(隔离实例的旧任务),否则 fallback 候选 2(0.1.1 版写入的真实数据源,
22
+ * 必须按此路径探测才能迁移)。getAgentDir() 未设置 env 时默认 ~/.pi/agent,两候选同路径,探测无副作用。
22
23
  */
23
24
  export function getLegacyStorePath(cwd: string): string {
24
- const home = os.homedir()
25
25
  const resolved = path.resolve(cwd)
26
26
  const parsed = path.parse(resolved)
27
27
  const segments = resolved.slice(parsed.root.length)
@@ -30,7 +30,9 @@ export function getLegacyStorePath(cwd: string): string {
30
30
  .replaceAll(/[^a-zA-Z0-9]+/g, '-')
31
31
  .replaceAll(/^-+|-+$/g, '')
32
32
  .toLowerCase() || 'root'
33
- return path.join(home, '.pi', 'agent', 'scheduler', root, ...segments, 'scheduler.json')
33
+ const agentDirPath = path.join(getAgentDir(), 'scheduler', root, ...segments, 'scheduler.json')
34
+ const legacyPath = path.join(os.homedir(), '.pi', 'agent', 'scheduler', root, ...segments, 'scheduler.json')
35
+ return fs.existsSync(agentDirPath) ? agentDirPath : legacyPath
34
36
  }
35
37
 
36
38
  /**
package/src/index.ts CHANGED
@@ -17,6 +17,22 @@ import {
17
17
  } from './tool.js'
18
18
  import { renderSchedulerWidget } from './widget.js'
19
19
 
20
+ // G1(代际检测,S9/R3-M1):session 代际计数器。必须声明在模块级而非 factory 体内:
21
+ // pi 每次 session 替换(newSession/fork/switchSession)都重跑 extension factory 函数体
22
+ // (loader.ts loadExtension 无条件 `await factory(api)`;extensionCache 只缓存 factory
23
+ // 函数对象、不缓存执行结果)——闭包级声明每次重跑即重置,各代 runtime 的 isCtxStale 恒
24
+ // false(R3 实测证伪的回归)。模块级声明下,extensionCache 命中期间(同 cwd 未 reload)
25
+ // factory 是同一函数对象、共享同一模块环境绑定,计数器跨 factory 重跑保留递增:新闭包的
26
+ // session_start 递增本计数器,各代 runtime 构造时捕获的代数从此小于模块值 → isCtxStale
27
+ // 生效——stale 分诊不依赖 pi 错误文案(Error message 非契约 API,pi 升级改文案即静默失效)。
28
+ //
29
+ // 残余盲区(reload):显式 reload / cwd 变化触发 clearExtensionCache → jiti 重新 import
30
+ // (moduleCache:false)产生全新模块环境,本计数器随新环境重置;旧闭包引用的是旧模块环境的
31
+ // 绑定,永不再递增 → 其 isCtxStale 恒 false。该盲区由两道既有防线覆盖:pi 在替换前 await
32
+ // fire session_shutdown(F1 stopScheduler 主防线,teardownCurrent)+ runtime 侧
33
+ // STALE_CTX_MARKER 文案兜底(F2 catch 分诊)。
34
+ let sessionGeneration = 0
35
+
20
36
  /**
21
37
  * pi-scheduler extension factory。
22
38
  * 注册 schedule + schedule_control 两个 tool、/schedule command、session 事件。
@@ -41,13 +57,24 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
41
57
  }
42
58
 
43
59
  pi.on('session_start', (_event, ctx: ExtensionContext) => {
60
+ // G1:先递增模块级代数再装配——自此同模块环境内所有前代 runtime 的 isCtxStale 返回
61
+ // true(stale)。myGeneration 是本 handler 的代数,注入的比对闭包读实时模块级
62
+ // sessionGeneration 与之比较(factory 重跑的新闭包与本闭包共享同一模块绑定)。
63
+ sessionGeneration += 1
64
+ const myGeneration = sessionGeneration
65
+ // F1(治本):session 替换/重入时先停上一代 runtime 的 tick interval——dispatch 的 await sendMessage
66
+ // 窗口与 session 替换交错时旧 session_shutdown 可能永远等不到(timer 泄漏源头)。stopScheduler 幂等,
67
+ // shutdown 已停过再停一次无副作用。
68
+ service?.runtime.stopScheduler()
44
69
  // 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
45
70
  const backend = new PiSchedulerBackend(ctx, pi)
46
71
  // 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
47
72
  // append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
48
73
  // ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
49
74
  importCleanup = importLegacyStore(ctx.cwd, pi, ctx.sessionManager.getSessionFile())
50
- const runtime = new SchedulerRuntime(backend, ctx)
75
+ // G1:注入代际比对(本 runtime 建立时的代数 vs 实时代数),供 tick 前置检查与
76
+ // F2 catch 分诊判定 stale——不依赖 pi 错误文案。
77
+ const runtime = new SchedulerRuntime(backend, ctx, () => sessionGeneration !== myGeneration)
51
78
  runtime.loadTasks(backend.loadTasks())
52
79
  // W2:tick 后回调刷新 widget(替代独立 widgetTimer + setInterval,节奏对齐 TICK_INTERVAL_MS)
53
80
  runtime.onAfterTick(() => refreshWidget(ctx))
package/src/runtime.ts CHANGED
@@ -16,6 +16,16 @@ const RATE_LIMIT_PER_MINUTE = 6
16
16
  const TICK_INTERVAL_MS = 30_000
17
17
  const DEFAULT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
18
18
  const HISTORY_LIMIT = 20 // 与 replayFoldEntries 的裁剪上限一致(advance 折叠 / dispatch 累积共用)
19
+ // pi ExtensionRunner 在 session 替换后访问 stale ctx 时抛出的错误文案片段。
20
+ // 兜底通道(防御纵深):G1 模块级代际检测(isCtxStale)为主判,覆盖同模块环境内的 session
21
+ // 替换路径(newSession/fork/switchSession:extensionCache 命中,factory 重跑但模块环境共享,
22
+ // 模块级代数被新闭包递增);本子串覆盖代际盲区——显式 reload / cwd 变化触发
23
+ // clearExtensionCache 后 jiti 重新 import 产生全新模块环境,旧闭包引用的模块级代数冻结
24
+ // 不再递增,isCtxStale 恒 false,此时除 session_shutdown teardown 主防线外只剩错误文案
25
+ // 能识别 stale。
26
+ // 注意:pi 非契约 API(Error message 非稳定接口),pi 升级需回归验证 runtime.test.ts 的
27
+ // U1 / G1-d 文案锚定用例;文案变更时此兜底失效,后果为 timer 泄漏 + 每 30s warn(不 crash)。
28
+ const STALE_CTX_MARKER = 'stale after session replacement'
19
29
 
20
30
  export class SchedulerRuntime {
21
31
  private tasks: Map<string, ScheduledTask> = new Map()
@@ -24,17 +34,26 @@ export class SchedulerRuntime {
24
34
  private tickTimer: ReturnType<typeof setInterval> | null = null
25
35
  private dispatchTimestamps: number[] = []
26
36
  private onAfterTickCallback: (() => void) | null = null
37
+ private readonly isCtxStale: (() => boolean) | undefined
38
+ // R3-S1:同任务 dispatch 在途标记(Set<taskId>),见 dispatchTask 注释
39
+ private readonly dispatchesInFlight = new Set<string>()
27
40
 
28
41
  /**
29
42
  * 依赖反转构造:backend 承担 appendEntry/pi.sendMessage/时间源,runtime 只持有内存态。
30
43
  * 不触碰任何 FS / session JSONL(测试可用 MockSchedulerBackend 零副作用注入)。
44
+ *
45
+ * isCtxStale(G1 代际检测,S9/R3-M1):返回 true 表示本 runtime 建立时的 session 已被
46
+ * 替换。index.ts 装配点注入(模块级代数比对,R3-M1),使 stale 分诊不依赖 pi 错误文案;
47
+ * 缺省(不注入)恒视为非 stale——纯 runtime 单测与旧装配路径行为不变。
31
48
  */
32
49
  constructor(
33
50
  backend: SchedulerBackend,
34
51
  ctx: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
52
+ isCtxStale?: () => boolean,
35
53
  ) {
36
54
  this.backend = backend
37
55
  this.ctx = ctx
56
+ this.isCtxStale = isCtxStale
38
57
  }
39
58
 
40
59
  // ── 任务 CRUD ──
@@ -160,7 +179,30 @@ export class SchedulerRuntime {
160
179
 
161
180
  startScheduler(): void {
162
181
  if (this.tickTimer) return
163
- this.tickTimer = setInterval(() => void this.tickScheduler(), TICK_INTERVAL_MS)
182
+ this.tickTimer = setInterval(() => {
183
+ // G1(代际前置检查,S9):本 runtime 所属 session 已被替换 → timer 属泄漏资源,
184
+ // 自停退场且不进入本轮 tick(不触碰捕获的 stale ctx)。主防线是 F1(session_start
185
+ // 停旧 timer),此处覆盖 F1 未能触达的泄漏路径——且不依赖「stale ctx 访问恰好抛错」
186
+ // 或 pi 错误文案,代际一翻转即可静默退场。
187
+ if (this.isCtxStale?.()) {
188
+ this.retireStaleTimer()
189
+ return
190
+ }
191
+ // F2(防御兜底):fire-and-forget 的 tick 链路必须自带 catch——tick 内任何异常
192
+ // (典型:session 替换后泄漏 timer 的 onAfterTick → refreshWidget 访问 stale ctx.ui 抛错)
193
+ // 若无人接住即 unhandledRejection,直接崩掉 pi 主进程。分诊:G1 模块级代数比对为主判
194
+ // (契约内,不受 pi 文案变更影响),STALE_CTX_MARKER 子串为兜底(覆盖 reload 产生全新
195
+ // 模块环境后旧闭包代数冻结、isCtxStale 恒 false 的盲区)。stale 类错误说明本 runtime
196
+ // 所属 session 已被替换,timer 属泄漏资源,自停退场;其他错误仅告警,不终止调度。
197
+ void this.tickScheduler().catch((err: unknown) => {
198
+ const message = err instanceof Error ? err.message : String(err)
199
+ if (this.isCtxStale?.() || message.includes(STALE_CTX_MARKER)) {
200
+ this.retireStaleTimer()
201
+ } else {
202
+ console.warn(`[scheduler] tick error: ${message}`)
203
+ }
204
+ })
205
+ }, TICK_INTERVAL_MS)
164
206
  }
165
207
 
166
208
  stopScheduler(): void {
@@ -170,6 +212,16 @@ export class SchedulerRuntime {
170
212
  }
171
213
  }
172
214
 
215
+ /**
216
+ * stale 自停退场(G1 前置检查与 F2 catch 分诊共用):warn 观测口径与 crash-fix 一致
217
+ * (含 "tick stopped",U1 断言锚定)+ stopScheduler(幂等)。timer 自停后调度由
218
+ * session_start 重建的新一代 runtime 接管。
219
+ */
220
+ private retireStaleTimer(): void {
221
+ console.warn(`[scheduler] tick stopped: stale extension ctx (session replaced); timer self-retired`)
222
+ this.stopScheduler()
223
+ }
224
+
173
225
  /**
174
226
  * 注册 tick 后回调(W2)。index.ts 注册 refreshWidget 替代独立 widgetTimer——
175
227
  * 每次 tickScheduler 末尾调用,对齐 TICK_INTERVAL_MS 刷新 widget。
@@ -218,15 +270,38 @@ export class SchedulerRuntime {
218
270
 
219
271
  /**
220
272
  * dispatch 单个任务。返回 true 表示真的发送了 message,false 表示 no-op
221
- * (task disabled / rate-limited / 非 force 且 busy)。
222
- * sendMessage 抛错时记录 failed 状态但不 rethrow,让 tick 继续处理其他任务。
273
+ * (task disabled / 已有同任务在途 / rate-limited / 非 force 且 busy)。
223
274
  *
224
- * 持久化(append-only):recurring 成功推进 nextRunAt append advance(status='success' CL8);
225
- * once 成功 append delete。失败 dispatch append(CL7 重试语义,transient 失败 nextRunAt 未推进)。
275
+ * R3-S1 in-flight 守卫:tick fire-and-forget,若 tick1 的 `await backend.sendMessage`
276
+ * 挂起超过 TICK_INTERVAL_MS(如 pi 卡死),tick2 step2 会再标 pending、step3 对同一
277
+ * task 并发第二个 dispatch → 同一 prompt 双注入(force 任务绕过 isIdle gate 直接受影响)。
278
+ * 参照 subagent-workflow resumesInFlight 模式:入口同步置位、finally 清除(覆盖 gate /
279
+ * rate-limit / sendMessage 抛错 / 成功推进全部退出路径);命中时 skip 本轮并 warn
280
+ * (不 throw——tick 继续处理其他任务,本任务 pending 保留到下轮重试)。
226
281
  */
227
282
  async dispatchTask(task: ScheduledTask): Promise<boolean> {
228
283
  if (!task.enabled) return false
284
+ if (this.dispatchesInFlight.has(task.id)) {
285
+ console.warn(`[scheduler] dispatch already in flight for task ${task.id}; skipping this tick`)
286
+ return false
287
+ }
288
+ this.dispatchesInFlight.add(task.id)
289
+ try {
290
+ return await this.dispatchTaskInner(task)
291
+ } finally {
292
+ this.dispatchesInFlight.delete(task.id)
293
+ }
294
+ }
229
295
 
296
+ /**
297
+ * dispatch 本体(dispatchTask 守卫置位后执行;runTaskNow 与 tick step3 共用入口,
298
+ * 手动 run-now 与挂起中的 tick dispatch 并发时同样被守卫拦截)。
299
+ * sendMessage 抛错时记录 failed 状态但不 rethrow,让 tick 继续处理其他任务。
300
+ *
301
+ * 持久化(append-only):recurring 成功推进 nextRunAt → append advance(status='success' CL8);
302
+ * once 成功 → append delete。失败 dispatch 不 append(CL7 重试语义,transient 失败 nextRunAt 未推进)。
303
+ */
304
+ private async dispatchTaskInner(task: ScheduledTask): Promise<boolean> {
230
305
  // 检查 force 或 idle
231
306
  if (!task.force) {
232
307
  if (!this.ctx.isIdle() || this.ctx.hasPendingMessages()) {
package/src/service.ts CHANGED
@@ -133,7 +133,7 @@ export class SchedulerService {
133
133
  /**
134
134
  * 立即执行任务。语义细分:
135
135
  * 任务不存在 → TASK_NOT_FOUND;任务存在但 dispatch no-op
136
- * (disabled / busy / rate-limited)→ DISPATCH_SKIPPED。
136
+ * (disabled / busy / rate-limited / 同任务 dispatch 在途)→ DISPATCH_SKIPPED。
137
137
  * 修复了旧实现把 no-op 误报为 not found 的混同。
138
138
  */
139
139
  async run(id: string | undefined): Promise<ServiceResult> {
@@ -148,7 +148,7 @@ export class SchedulerService {
148
148
  return {
149
149
  success: false,
150
150
  errorCode: 'DISPATCH_SKIPPED',
151
- message: `Task ${id} not dispatched (busy, disabled, or rate-limited).`,
151
+ message: `Task ${id} not dispatched (busy, disabled, rate-limited, or a dispatch already in flight).`,
152
152
  }
153
153
  }
154
154
  return { success: true, message: `Task ${id} executed.` }