@zhushanwen/pi-scheduler 0.10.4 → 0.11.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 +1 -1
- package/package.json +3 -3
- package/skills/scheduler-ext-config/SKILL.md +2 -2
- package/src/__tests__/U4-MODEL-SWITCH.test.ts +2 -2
- package/src/__tests__/ack-contract.test.ts +10 -10
- package/src/__tests__/ack-provider.test.ts +1 -1
- package/src/__tests__/ack-turn.test.ts +4 -4
- package/src/__tests__/active-path.test.ts +4 -4
- package/src/__tests__/create-form-component.test.ts +1 -1
- package/src/__tests__/importer.test.ts +1 -1
- package/src/__tests__/index-generation.test.ts +4 -4
- package/src/__tests__/mock-backend.ts +1 -1
- package/src/__tests__/replay.test.ts +1 -1
- package/src/__tests__/runtime.test.ts +9 -9
- package/src/__tests__/sdk-contract.test.ts +58 -4
- package/src/__tests__/service.test.ts +1 -1
- package/src/__tests__/session-tree-registration.test.ts +3 -3
- package/src/__tests__/summary.test.ts +135 -0
- package/src/__tests__/types.test.ts +1 -1
- package/src/__tests__/widget-push.test.ts +3 -3
- package/src/__tests__/widget.test.ts +11 -7
- package/src/ack-notify.ts +1 -1
- package/src/ack-provider.ts +2 -2
- package/src/ack-turn.ts +1 -1
- package/src/backend.ts +5 -5
- package/src/create-form-component.ts +1 -1
- package/src/format.ts +1 -1
- package/src/i18n.ts +19 -19
- package/src/importer.ts +1 -1
- package/src/index.ts +25 -9
- package/src/interaction.ts +1 -1
- package/src/render-result.ts +46 -0
- package/src/runtime.ts +23 -23
- package/src/tool.ts +72 -0
- package/src/types.ts +7 -7
- package/src/widget.ts +5 -5
package/README.md
CHANGED
|
@@ -208,7 +208,7 @@ pi -p "/schedule 5m 'check build'"
|
|
|
208
208
|
| 默认过期 | **7 天**(`DEFAULT_EXPIRY_MS`) | recurring 任务缺省 `expires` 时;`expires: 'never'` 关闭 |
|
|
209
209
|
| once 任务 | 触发后自动删除 | 不参与后续调度 |
|
|
210
210
|
| cron 失效 | 任务停用 + `lastStatus=failed` + `lastError='cron expression invalid'` | 不会用 `now()` 兜底导致每 tick 重触发死循环 |
|
|
211
|
-
| 忙时 dispatch | steer 直投(scheduler-steer-direct-dispatch) | busy 时消息插入当前 turn、idle 时开新 turn(`{deliverAs:'steer', triggerTurn:true}`),受理即记账不排队;同任务 in-flight
|
|
211
|
+
| 忙时 dispatch | steer 直投(scheduler-steer-direct-dispatch) | busy 时消息插入当前 turn、idle 时开新 turn(`{deliverAs:'steer', triggerTurn:true}`),受理即记账不排队;同任务 in-flight 检查防双投 |
|
|
212
212
|
| history | 保留最近 **20** 条执行记录 | 超出丢弃最旧;重放折叠时同样裁剪 |
|
|
213
213
|
| 持久化 | custom entry append 到 session JSONL(dispatch 成功后立即 append advance 记录执行) | 任务随 owner session 持久化,resume 后重放恢复,无需额外写盘 |
|
|
214
214
|
| 交付语义 | **at-least-once**(至少一次) | dispatch 成功后内存更新 nextRunAt 并 append advance;append 之前若进程崩溃可能重复注入一次(无精确一次保证,可接受) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"taiji": {
|
|
@@ -51,10 +51,10 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"croner": "^9.0.0",
|
|
54
|
-
"@zhushanwen/extension-protocol": "0.
|
|
54
|
+
"@zhushanwen/extension-protocol": "0.23.0",
|
|
55
55
|
"@zhushanwen/pi-ext-guards": "0.5.0",
|
|
56
56
|
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
57
|
-
"@zhushanwen/pi-llm-shared": "0.
|
|
57
|
+
"@zhushanwen/pi-llm-shared": "0.12.0",
|
|
58
58
|
"@zhushanwen/pi-session-path": "0.1.0"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
@@ -35,7 +35,7 @@ description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)
|
|
|
35
35
|
- **`schedule_control`**(管理):`action` = `list`/`toggle`/`delete`/`run`,`id`(toggle/delete/run 必填),`enabled`(toggle 必填)。
|
|
36
36
|
- 两个工具的返回都是结构化 `{content: [{type:'text', text}], details}`;业务失败以异常抛出(pi 只对 execute throw 置 `isError:true`,错误 message 作为 toolResult content 返回),不通过返回值表达失败。
|
|
37
37
|
|
|
38
|
-
> 创建/管理操作不受 agent 运行状态约束;**到期 dispatch** 也不等待 agent idle(steer 直投,busy 时插入当前 turn),仅受速率限制/同任务 in-flight
|
|
38
|
+
> 创建/管理操作不受 agent 运行状态约束;**到期 dispatch** 也不等待 agent idle(steer 直投,busy 时插入当前 turn),仅受速率限制/同任务 in-flight 检查约束(见「运行限制与 dispatch 行为」)。
|
|
39
39
|
|
|
40
40
|
### 3. 创建方式(触发反转)
|
|
41
41
|
|
|
@@ -127,7 +127,7 @@ dispatch 行为(`dispatchTask`,scheduler-steer-direct-dispatch 直投模型
|
|
|
127
127
|
|
|
128
128
|
- **steer 直投**:到期任务 `backend.sendMessage(..., {deliverAs: 'steer', triggerTurn: true})` 直接投递——agent busy 时消息插入当前 turn(立即被模型看到)、idle 时开新 turn。不排队、不等 idle、不丢弃。
|
|
129
129
|
- **受理即记账**:`sendMessage` 是 fire-and-forget(返回 void),await 立即通过,`nextRunAt` 调用即推进——无「入队未终态」窗口。
|
|
130
|
-
- **同任务 in-flight
|
|
130
|
+
- **同任务 in-flight 检查**:该任务 dispatch 在途(如 `/schedule run` 与 tick dispatch 并发)时本 tick 跳过,防双投。
|
|
131
131
|
- **模型切换**(`task.model` 设定且 ≠ 会话当前模型):sendMessage 前 `setModel` 切到目标任务模型、记未决切换记录,事件恢复(turn_end + isIdle 复核)后切回原模型;切换失败降级——照常 dispatch(模型字段不阻塞核心调度)。
|
|
132
132
|
- dispatch 成功后:recurring 推进 `nextRunAt` 并 append `advance`;once 删除任务并 append `delete`;失败(`sendMessage` 同步抛错,如 session 关闭)记 `lastStatus='failed'` 不 rethrow,下个 tick 重试(transient 失败重试语义,不 append advance)。
|
|
133
133
|
- 注入的消息:`{content: task.prompt, customType: 'pi-scheduler:dispatched', display: true}`,投递选项 `{deliverAs: 'steer', triggerTurn: true}`。
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 任务的 setModel 排队等前序模型 op 完成(任意两个 setModel 不并发、双记录不叠写)
|
|
15
15
|
* - tick 事实查询兜正确性(原 2-tick 计数对账已按 ADR-0122 删除):严格先于同 tick
|
|
16
16
|
* dispatch 循环;记录存在 + idle 即恢复(agent_settled 丢失时由查询收敛——拉保证
|
|
17
|
-
*
|
|
17
|
+
* 正确、事件只优化延迟);未决记录检查(无记录时模型漂移 = 用户自主行为不动作)
|
|
18
18
|
* - 降级与接管副作用:setModel false 不阻塞 dispatch;sendMessage 抛错 catch 先恢复;
|
|
19
19
|
* 模型语义异常只走日志(不进持久化词表)
|
|
20
20
|
*
|
|
@@ -40,7 +40,7 @@ const ORIG = 'prov-a/model-1'
|
|
|
40
40
|
const TASK_M = 'prov-b/model-2'
|
|
41
41
|
const OTHER_M = 'prov-c/model-3'
|
|
42
42
|
|
|
43
|
-
/**
|
|
43
|
+
/** 模型控制接口 mock:setModelCalls 记录调用序(切换/恢复时序断言的核心观测点) */
|
|
44
44
|
class ModelOpsMock implements SchedulerModelOps {
|
|
45
45
|
currentRef: string | undefined = ORIG
|
|
46
46
|
idle = true
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// ack
|
|
1
|
+
// ack 契约范围测试(dev-flow u-foundation 单元)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// 1. SchedulerBackend
|
|
3
|
+
// 本单元只交付「依赖反转契约范围」,不含 ack 业务逻辑,故测试只证明三件事:
|
|
4
|
+
// 1. SchedulerBackend 接口扩大(registerProvider/unregisterProvider/getEntries)后可被纯对象
|
|
5
5
|
// 夹具实现(isIdle/getCurrentModel 是 PiSchedulerBackend 类方法、非接口成员);
|
|
6
6
|
// 2. PiSchedulerBackend 对 pi / ctx 的转发语义(含缺字段兜底);
|
|
7
7
|
// 3. ack 契约常量/判别值锚定(防后续单元依赖字符串时不发现改动)。
|
|
@@ -24,14 +24,14 @@ import {
|
|
|
24
24
|
type SchedulerProviderOverride,
|
|
25
25
|
} from '../types.js'
|
|
26
26
|
|
|
27
|
-
// ── 1.
|
|
27
|
+
// ── 1. 接口扩大可被纯对象夹具实现(类型完备)──
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* 纯对象夹具:只实现接口成员(sendMessage/appendEntry/getSessionFile/now +
|
|
31
31
|
* registerProvider/unregisterProvider/getEntries)。
|
|
32
32
|
* `satisfies SchedulerBackend` 的**编译期**效力以「测试被 typecheck」为前提——
|
|
33
33
|
* `extensions/tsconfig.json` 的 exclude 覆盖全部测试目录,常态 `extensions:typecheck`
|
|
34
|
-
* 不覆盖本文件(若将来把测试纳入 typecheck
|
|
34
|
+
* 不覆盖本文件(若将来把测试纳入 typecheck,这行才是编译期契约完备证据);当前至少保证
|
|
35
35
|
* 运行期结构契约。
|
|
36
36
|
*/
|
|
37
37
|
function createFixtureBackend(): SchedulerBackend {
|
|
@@ -65,8 +65,8 @@ function createFixtureBackend(): SchedulerBackend {
|
|
|
65
65
|
} satisfies SchedulerBackend
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
describe('SchedulerBackend ack
|
|
69
|
-
it('夹具实现既有 +
|
|
68
|
+
describe('SchedulerBackend ack 接口扩大(纯对象夹具)', () => {
|
|
69
|
+
it('夹具实现既有 + 新增全部方法且类型完备', () => {
|
|
70
70
|
const backend = createFixtureBackend()
|
|
71
71
|
|
|
72
72
|
expect(backend.getEntries()).toHaveLength(1)
|
|
@@ -83,7 +83,7 @@ describe('SchedulerBackend ack 扩面(纯对象夹具)', () => {
|
|
|
83
83
|
|
|
84
84
|
// ── 2. PiSchedulerBackend 转发 / 归一 / 兜底 ──
|
|
85
85
|
|
|
86
|
-
/** 最小假 pi
|
|
86
|
+
/** 最小假 pi 的记录侧。 */
|
|
87
87
|
interface FakePi {
|
|
88
88
|
pi: Pick<
|
|
89
89
|
ExtensionAPI,
|
|
@@ -118,7 +118,7 @@ function createFakePi(): FakePi {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
describe('PiSchedulerBackend ack
|
|
121
|
+
describe('PiSchedulerBackend ack 接口扩大转发', () => {
|
|
122
122
|
it('registerProvider 转发 pi 并零转换透传 (providerId, config)', () => {
|
|
123
123
|
const { pi, providerCalls } = createFakePi()
|
|
124
124
|
const ctx: SchedulerBackendCtx = {
|
|
@@ -230,7 +230,7 @@ describe('ack 契约常量与类型', () => {
|
|
|
230
230
|
expect<AckAvailability>({ available: true }).toEqual({ available: true })
|
|
231
231
|
|
|
232
232
|
// 可通知形态只有两种:其余 E 形态(e1/e2/e4/e5/e6/e8b)不发用户通知——真实轮已
|
|
233
|
-
// 应答或与落盘无关,提示即反向撒谎(设计 §3.4
|
|
233
|
+
// 应答或与落盘无关,提示即反向撒谎(设计 §3.4);它们在日志侧用字符串标签。
|
|
234
234
|
const notifyReasons: AckNotifyReason[] = ['e3-no-turn', 'e8-no-base']
|
|
235
235
|
expect(notifyReasons).toHaveLength(2)
|
|
236
236
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ack 合成流 + 可用性判据测试(dev-flow u-ack-provider 单元)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 本单元只交付三件事的纯函数部分:
|
|
4
4
|
// 1. buildAckStreamSimple —— 零 token 合成流(字段 = 会话真实模型 / usage 全 0 /
|
|
5
5
|
// aborted 走 error 终止 / onCalled 返回前同步自撤);
|
|
6
6
|
// 2. computeAckAvailability —— 预计算 + fail-closed 的可用性判据(内置含 radius);
|
|
@@ -322,11 +322,11 @@ it('session 边界:未启动 ack 轮且文件不存在 ⇒ 补发通知;ack
|
|
|
322
322
|
expect(b.notifyCalls).toHaveLength(0)
|
|
323
323
|
})
|
|
324
324
|
|
|
325
|
-
it('E3 后同会话再次创建可重试(无定时器清 pending
|
|
325
|
+
it('E3 后同会话再次创建可重试(无定时器清 pending,检查只认 window)', async () => {
|
|
326
326
|
const h = makeHarness({ builtinIds: new Set(['anthropic']) })
|
|
327
327
|
await startAck(h.controller)
|
|
328
328
|
expect(h.captured.sent).toHaveLength(1)
|
|
329
|
-
// 触发器未启动轮次 ⇒ window 从未置位 ⇒
|
|
329
|
+
// 触发器未启动轮次 ⇒ window 从未置位 ⇒ 检查不挡,下一次创建重新注入。
|
|
330
330
|
await h.controller.maybeStartAck({
|
|
331
331
|
task: { ...TASK, id: 't2' },
|
|
332
332
|
model: MODEL,
|
|
@@ -399,9 +399,9 @@ it('E3 后同会话再次创建可重试(无定时器清 pending,守卫只
|
|
|
399
399
|
})
|
|
400
400
|
})
|
|
401
401
|
|
|
402
|
-
// ── 回归用例(一致性审查 F2/F3 +
|
|
402
|
+
// ── 回归用例(一致性审查 F2/F3 + 去定时器后的检查语义)──
|
|
403
403
|
|
|
404
|
-
describe('ack-turn
|
|
404
|
+
describe('ack-turn 检查与归因回归', () => {
|
|
405
405
|
beforeEach(() => {
|
|
406
406
|
vi.useFakeTimers()
|
|
407
407
|
resetAckState()
|
|
@@ -79,9 +79,9 @@ function makeCtx(initialEntries: BranchEntry[], initialLeafId: string | null) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/** 捕获 pi.on 注册的 handler +
|
|
83
|
-
* on 字段仅满足 ExtensionAPI 结构完整性:session_tree
|
|
84
|
-
* (index.ts),backend 构造不再调 pi.on
|
|
82
|
+
/** 捕获 pi.on 注册的 handler + 副作用记录侧(sendMessage / appendEntry vi.fn)。
|
|
83
|
+
* on 字段仅满足 ExtensionAPI 结构完整性:session_tree 注册点已迁至 factory 顶层
|
|
84
|
+
* (index.ts),backend 构造不再调 pi.on(注册点契约见 session-tree-registration.test.ts)。 */
|
|
85
85
|
function makePi() {
|
|
86
86
|
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
|
87
87
|
const sendMessage = vi.fn(async () => {})
|
|
@@ -201,7 +201,7 @@ describe('session_tree 重折叠(U6c)', () => {
|
|
|
201
201
|
expect(runtime.getTask('t2')).toBeDefined()
|
|
202
202
|
|
|
203
203
|
// 撤回:树回退(label 锚落尾 + leafId 指向它)→ session_tree 事件 → 重折叠
|
|
204
|
-
//
|
|
204
|
+
// (生产注册点在 factory 顶层 index.ts,此处直接驱动委托体)
|
|
205
205
|
setTree([...preRevoke, labelEntry('L', 'e1')], 'L')
|
|
206
206
|
backend.refoldSessionTree()
|
|
207
207
|
|
|
@@ -394,7 +394,7 @@ describe('ScheduleCreateComponent — Submit 确认门与回传', () => {
|
|
|
394
394
|
})
|
|
395
395
|
})
|
|
396
396
|
|
|
397
|
-
it('S-5: 终态后继续按键 no-op(resolved
|
|
397
|
+
it('S-5: 终态后继续按键 no-op(resolved 检查)', () => {
|
|
398
398
|
const { c, result } = make()
|
|
399
399
|
fillRecurringToSubmit(c)
|
|
400
400
|
c.handleInput(ENTER)
|
|
@@ -243,7 +243,7 @@ describe('importLegacyStore', () => {
|
|
|
243
243
|
expect(fs.unlinkSync).not.toHaveBeenCalled()
|
|
244
244
|
})
|
|
245
245
|
|
|
246
|
-
// ── TC8b:cleanup 幂等 + unlink ENOENT
|
|
246
|
+
// ── TC8b:cleanup 幂等 + unlink ENOENT 检查(MF-2,R2 修复)──
|
|
247
247
|
it('TC8b: cleanup 幂等(已删后 no-op)+ unlink 仅静默 ENOENT,其他 fs 错误向上抛', () => {
|
|
248
248
|
const taskA = makeTask({ id: 'ffff' })
|
|
249
249
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
@@ -39,7 +39,7 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
|
39
39
|
vi.mock('../importer.js', () => ({ importLegacyStore: vi.fn(() => vi.fn()) }))
|
|
40
40
|
|
|
41
41
|
// u-p2b 接线断言:index.ts 的 refreshWidget 必须走结构化/双模入口 `setSchedulerWidget`
|
|
42
|
-
// (取代旧 `renderSchedulerWidget` TUI 单形态)——mock
|
|
42
|
+
// (取代旧 `renderSchedulerWidget` TUI 单形态)——mock 捕获调用(任务数组直传)。
|
|
43
43
|
const { setSchedulerWidgetMock } = vi.hoisted(() => ({ setSchedulerWidgetMock: vi.fn() }))
|
|
44
44
|
vi.mock('../widget.js', async (importOriginal) => {
|
|
45
45
|
const actual = await importOriginal<typeof import('../widget.js')>()
|
|
@@ -55,7 +55,7 @@ const { isCtxStaleCaptures, runtimeInstances, stopSchedulerCalls } = vi.hoisted(
|
|
|
55
55
|
|
|
56
56
|
// InstrumentedRuntime 继承真实实现(loadTasks/onAfterTick/startScheduler 均真实执行),
|
|
57
57
|
// 仅捕获构造参(isCtxStale)并登记实例(afterEach 统一停 timer,避免真实 setInterval 残留);
|
|
58
|
-
// stopScheduler 调用按序记录(F1
|
|
58
|
+
// stopScheduler 调用按序记录(F1 验收断言范围:新装配前旧实例必须被停)。
|
|
59
59
|
vi.mock('../runtime.js', async (importOriginal) => {
|
|
60
60
|
const actual = await importOriginal<typeof import('../runtime.js')>()
|
|
61
61
|
const RealSchedulerRuntime = actual.SchedulerRuntime
|
|
@@ -210,7 +210,7 @@ describe('G1: index.ts 代际接线(S9)', () => {
|
|
|
210
210
|
// 下一 tick 的 refreshWidget 访问 stale ctx.ui 抛错 → unhandledRejection → pi 主进程 exit 1。
|
|
211
211
|
// F1 在 session_start 开头幂等 stopScheduler,从源头消灭。
|
|
212
212
|
//
|
|
213
|
-
// 行为断言口径(验收 U4
|
|
213
|
+
// 行为断言口径(验收 U4):观测范围 = InstrumentedRuntime 的 stopScheduler 调用序列
|
|
214
214
|
// (widget 推送是 D1 指纹跳推语义——任务集静态期间零推送,不能靠推送计数区分新旧 timer;
|
|
215
215
|
// stopScheduler 白盒断言直接锁定「新装配前旧实例必须被停」的 F1 语义本身)。
|
|
216
216
|
describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
@@ -294,7 +294,7 @@ describe('F1: session_start 停旧 runtime(crash-fix U4)', () => {
|
|
|
294
294
|
expect(setSchedulerWidgetMock).toHaveBeenCalledTimes(2) // runtime2 初始渲染(实例态重置,首帧必推)
|
|
295
295
|
expect(runtimeInstances).toHaveLength(2)
|
|
296
296
|
|
|
297
|
-
//
|
|
297
|
+
// 行为断言(验收标准):stopScheduler 恰被调一次且命中 runtime1;
|
|
298
298
|
// F1 缺失时旧 timer 不被停(数组为空或不含 runtime1)
|
|
299
299
|
expect(stopSchedulerCalls).toEqual([runtimeInstances[0]])
|
|
300
300
|
})
|
|
@@ -2,7 +2,7 @@ import type { SchedulerBackend, SchedulerProviderOverride } from '../backend.js'
|
|
|
2
2
|
import { replayFoldEntries, type SchedulerEntryLike } from '../replay.js'
|
|
3
3
|
import type { ScheduledTask, SchedulerEntryOp } from '../types.js'
|
|
4
4
|
|
|
5
|
-
// 测试专用 mock(ext-simplify-08 L9:从 backend.ts
|
|
5
|
+
// 测试专用 mock(ext-simplify-08 L9:从 backend.ts 迁出——导出范围即测试范围的收敛,
|
|
6
6
|
// 生产模块不再携带测试脚手架;MockSchedulerBackend/SentMessage 零生产引用)。
|
|
7
7
|
|
|
8
8
|
export interface SentMessage {
|
|
@@ -263,7 +263,7 @@ describe('replayFoldEntries', () => {
|
|
|
263
263
|
expect(a.nextRunAt).toBe(100) // 保持 upsert 快照值
|
|
264
264
|
})
|
|
265
265
|
|
|
266
|
-
// ── MF-2
|
|
266
|
+
// ── MF-2:检查按变体校验必填字段——损坏 entry 只跳过该条,不清空全部任务 ──
|
|
267
267
|
it('MF-2: op 合法但缺必填字段的损坏 entry 被跳过,其余任务保留', () => {
|
|
268
268
|
const session = '/s.json'
|
|
269
269
|
loggerMock.warn.mockClear()
|
|
@@ -138,14 +138,14 @@ describe('SchedulerRuntime', () => {
|
|
|
138
138
|
})
|
|
139
139
|
})
|
|
140
140
|
|
|
141
|
-
// ── R3-S1:dispatchTask in-flight
|
|
141
|
+
// ── R3-S1:dispatchTask in-flight 检查 ──
|
|
142
142
|
// tick 为 fire-and-forget:tick1 的 await sendMessage 挂起超过 TICK_INTERVAL_MS(如 pi
|
|
143
143
|
// 卡死)时,tick2 的 step2 再标 pending → step3 对同一 task 并发第二个 dispatch → 同一
|
|
144
|
-
// prompt
|
|
144
|
+
// prompt 双注入。检查:同任务在途(Set<taskId>)
|
|
145
145
|
// 时 skip 本轮并 warn;不同任务不受影响。
|
|
146
146
|
// 末两条为调用方 API 层验收:不经 tick,直接并发/串行调 dispatchTask,
|
|
147
|
-
//
|
|
148
|
-
describe('dispatchTask in-flight
|
|
147
|
+
// 验证检查在调用方入口同样拦截(warn + 第二次 false)与完成后放行。
|
|
148
|
+
describe('dispatchTask in-flight 检查(R3-S1)', () => {
|
|
149
149
|
beforeEach(() => {
|
|
150
150
|
vi.useFakeTimers()
|
|
151
151
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
@@ -170,7 +170,7 @@ describe('SchedulerRuntime', () => {
|
|
|
170
170
|
expect(task.pending).toBe(true) // dispatch 未完成,pending 未清
|
|
171
171
|
|
|
172
172
|
// tick2(模拟 30s 后 pi 仍卡死):同任务再标 pending → step3 dispatchTask 命中
|
|
173
|
-
// in-flight
|
|
173
|
+
// in-flight 检查 → skip + warn(修复前:同一 prompt 双注入)
|
|
174
174
|
await runtime.tickScheduler()
|
|
175
175
|
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
176
176
|
const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
|
|
@@ -198,8 +198,8 @@ describe('SchedulerRuntime', () => {
|
|
|
198
198
|
const tick1 = runtime.tickScheduler()
|
|
199
199
|
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
200
200
|
|
|
201
|
-
// tick2:task1
|
|
202
|
-
// 注:tick2 挂起在 task1 的 await dispatchTask
|
|
201
|
+
// tick2:task1 命中检查跳过,task2 无在途 → 正常 dispatch(检查按 taskId 粒度隔离)。
|
|
202
|
+
// 注:tick2 挂起在 task1 的 await dispatchTask(检查命中即 resolved promise)与 task2
|
|
203
203
|
// 的 await sendMessage 上,先放行再 await,完成态统一断言
|
|
204
204
|
const tick2 = runtime.tickScheduler()
|
|
205
205
|
resolveSend!()
|
|
@@ -210,7 +210,7 @@ describe('SchedulerRuntime', () => {
|
|
|
210
210
|
})
|
|
211
211
|
|
|
212
212
|
// ── 调用方 API 层 ──
|
|
213
|
-
it('
|
|
213
|
+
it('调用方检查:同一 taskId 并发 dispatchTask → 第二次被拦截(send 只调 1 次 + warn)', async () => {
|
|
214
214
|
loggerMock.warn.mockClear()
|
|
215
215
|
// 可控延迟的 sendMessage:让第一次 dispatch 挂起
|
|
216
216
|
let resolveSend: (() => void) | undefined
|
|
@@ -239,7 +239,7 @@ describe('SchedulerRuntime', () => {
|
|
|
239
239
|
await first
|
|
240
240
|
})
|
|
241
241
|
|
|
242
|
-
it('
|
|
242
|
+
it('调用方检查:第一次完成后 → 第二次 dispatchTask 正常执行(send 调 2 次)', async () => {
|
|
243
243
|
const task = await runtime.addTask('serial-inflight', { mode: 'interval', intervalMs: 60_000 })
|
|
244
244
|
|
|
245
245
|
// 第一次 dispatch(串行等待完成)
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// 1. tool 注册名为 schedule / schedule_control
|
|
6
6
|
// 2. tool 有 execute 函数字段(不是 handler/fn)
|
|
7
7
|
// 3. execute 是 async function(SDK 期望返回 Promise)
|
|
8
|
-
// 4. 错误路径 throw(W4:pi-agent-core
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// 4. 错误路径 throw(W4:execute throw → pi-agent-core agent-loop catch 置 isError:true
|
|
9
|
+
// ——pi-agent-core dist/agent-loop.js executePreparedToolCall,1.0.0 实读 catch :581-588;
|
|
10
|
+
// 1.0.0 起正常 return 的 isError 也被尊重——:579 isError: result.isError === true,
|
|
11
|
+
// 0.84.4 才硬编码 false、return 的错误轮曾被标成功——本包失败恒走 throw,行为不变)
|
|
11
12
|
//
|
|
12
13
|
// 不导入 SchedulerRuntime 的内部:只通过 index.ts 的 default export 测,
|
|
13
14
|
// 保证 tool 注册逻辑的入口契约。
|
|
@@ -203,7 +204,7 @@ describe('pi-scheduler SDK contract', () => {
|
|
|
203
204
|
const cleanup = vi.mocked(importLegacyStore).mock.results[0]!.value
|
|
204
205
|
expect(typeof cleanup).toBe('function')
|
|
205
206
|
|
|
206
|
-
// turn_end(首个 turn 完成,flush 已发生)→ cleanup 被调用——跨 session
|
|
207
|
+
// turn_end(首个 turn 完成,flush 已发生)→ cleanup 被调用——跨 session 双导入窗口封住点
|
|
207
208
|
await events.get('turn_end')!()
|
|
208
209
|
expect(cleanup).toHaveBeenCalledTimes(1)
|
|
209
210
|
|
|
@@ -212,3 +213,56 @@ describe('pi-scheduler SDK contract', () => {
|
|
|
212
213
|
expect(cleanup).toHaveBeenCalledTimes(2)
|
|
213
214
|
})
|
|
214
215
|
})
|
|
216
|
+
|
|
217
|
+
// ── execute 组装 details.summary(withScheduleSummary 包装线回归防线)──
|
|
218
|
+
// 纯函数(buildScheduleSummary / buildScheduleControlSummary)已被 summary.test.ts 直测;
|
|
219
|
+
// 本组护的是 index.ts execute 内的两处组装线(schedule → withScheduleSummary(result,
|
|
220
|
+
// buildScheduleSummary(...));schedule_control → buildScheduleControlSummary 同款)——
|
|
221
|
+
// 组装行丢失时 details.summary 缺失,GUI 收起态与 TUI 折叠态双双退化为原始 JSON,此处红。
|
|
222
|
+
|
|
223
|
+
/** execute 返回的 details 形状(组装线断言面:summary 恒为 string)。 */
|
|
224
|
+
interface SummaryDetails {
|
|
225
|
+
summary: unknown
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
describe('execute 组装 details.summary(withScheduleSummary 包装线)', () => {
|
|
229
|
+
/** 装配 → 触发 session_start,返回捕获的两 tool 与事件表。调用方 finally 里 fire session_shutdown 停 tick timer。 */
|
|
230
|
+
async function setupWithSession(): Promise<{
|
|
231
|
+
tools: CapturedTool[]
|
|
232
|
+
events: Map<string, (...args: unknown[]) => void>
|
|
233
|
+
fakeCtx: ExtensionContext
|
|
234
|
+
}> {
|
|
235
|
+
const { pi, tools, events } = createMockPi()
|
|
236
|
+
schedulerExtension(pi)
|
|
237
|
+
const fakeCtx = createFakeCtx()
|
|
238
|
+
await events.get('session_start')!({ type: 'session_start', reason: 'startup' }, fakeCtx)
|
|
239
|
+
return { tools, events, fakeCtx }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
it('schedule 创建 → details.summary = Scheduled "<name>" · every 5m(index.ts schedule 包装线)', async () => {
|
|
243
|
+
const { tools, events, fakeCtx } = await setupWithSession()
|
|
244
|
+
try {
|
|
245
|
+
const tool = tools.find(t => t.name === 'schedule')!
|
|
246
|
+
const result = await tool.execute('call-sum-1', { prompt: 'check build', schedule: '5m' }, undefined, undefined, fakeCtx)
|
|
247
|
+
// 期望文案锚点:summary.test.ts 直测口径(interval 规格化 every 5m + en-US)
|
|
248
|
+
expect((result.details as SummaryDetails).summary).toBe('Scheduled "check build" · every 5m')
|
|
249
|
+
} finally {
|
|
250
|
+
await events.get('session_shutdown')?.()
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('schedule_control list → details.summary = Listed N tasks(index.ts schedule_control 包装线)', async () => {
|
|
255
|
+
const { tools, events, fakeCtx } = await setupWithSession()
|
|
256
|
+
try {
|
|
257
|
+
// 先建 1 个任务,list 计数才有非零区分度
|
|
258
|
+
const scheduleTool = tools.find(t => t.name === 'schedule')!
|
|
259
|
+
await scheduleTool.execute('call-sum-2', { prompt: 'standup', schedule: '5m' }, undefined, undefined, fakeCtx)
|
|
260
|
+
|
|
261
|
+
const controlTool = tools.find(t => t.name === 'schedule_control')!
|
|
262
|
+
const result = await controlTool.execute('call-sum-3', { action: 'list' }, undefined, undefined, fakeCtx)
|
|
263
|
+
expect((result.details as SummaryDetails).summary).toBe('Listed 1 tasks')
|
|
264
|
+
} finally {
|
|
265
|
+
await events.get('session_shutdown')?.()
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
})
|
|
@@ -315,7 +315,7 @@ describe('SchedulerService', () => {
|
|
|
315
315
|
if (result.params === undefined || !('tasks' in result.params)) throw new Error('expected list params')
|
|
316
316
|
const line = renderResult('task.list', result.params, 'zh-CN').split('\n')[1]!
|
|
317
317
|
expect(line).toContain('上次: 失败×2')
|
|
318
|
-
// 命令变体保留相对时间(设计 D1-a
|
|
318
|
+
// 命令变体保留相对时间(设计 D1-a:命令层按需呈现侧不变)
|
|
319
319
|
expect(line).toMatch(/分钟后|分钟前|\d+m ago|in \d+m/)
|
|
320
320
|
// params 携带 locale-neutral 原始值(摘要不预渲染)
|
|
321
321
|
expect(result.params.tasks[0]!.lastExec).toEqual({ lastStatus: 'failed', recentFailures: 2 })
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/__tests__/session-tree-registration.test.ts
|
|
2
2
|
//
|
|
3
|
-
// U6c session_tree
|
|
3
|
+
// U6c session_tree 注册点契约:注册在 extension factory 顶层(每代恰一 handler),
|
|
4
4
|
// 同代 session_start 复发不累积。回归背景:注册曾在 PiSchedulerBackend 构造函数内,
|
|
5
5
|
// 而 pi RPC 模式下每次 session 替换 session_start 在同一代内触发两次
|
|
6
6
|
// (agent-session-runtime finishSessionReplacement 内部 rebindSession + RPC handler
|
|
@@ -79,7 +79,7 @@ function taskEntry(sessionFile: string, taskId: string) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/** fake ctx:entries 经容器可变(模拟撤回前后 swap);ui.setWidget
|
|
82
|
+
/** fake ctx:entries 经容器可变(模拟撤回前后 swap);ui.setWidget 观测范围。 */
|
|
83
83
|
function createFakeCtx(sessionFile: string, entriesBox: { entries: unknown[] }): ExtensionContext {
|
|
84
84
|
return {
|
|
85
85
|
cwd: '/test-tree-reg',
|
|
@@ -93,7 +93,7 @@ function createFakeCtx(sessionFile: string, entriesBox: { entries: unknown[] }):
|
|
|
93
93
|
} as unknown as ExtensionContext
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
describe('session_tree
|
|
96
|
+
describe('session_tree 注册点(factory 顶层恰一,同代 session_start 复发不累积)', () => {
|
|
97
97
|
it('factory 运行恰注册 1 个 session_tree handler', () => {
|
|
98
98
|
const { pi, handlers } = createAppendPi()
|
|
99
99
|
schedulerExtension(pi)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildScheduleControlSummary,
|
|
6
|
+
buildScheduleSummary,
|
|
7
|
+
withScheduleSummary,
|
|
8
|
+
type ScheduleControlParamsT,
|
|
9
|
+
type ScheduleParamsT,
|
|
10
|
+
} from '../tool.js'
|
|
11
|
+
import { renderScheduleToolResult } from '../render-result.js'
|
|
12
|
+
|
|
13
|
+
function toolResult(details: unknown, text = 'body'): { content: { type: 'text'; text: string }[]; details: unknown } {
|
|
14
|
+
return { content: [{ type: 'text', text }], details }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── buildScheduleSummary ─────────────────────────────
|
|
18
|
+
|
|
19
|
+
describe('buildScheduleSummary', () => {
|
|
20
|
+
const createParams: ScheduleParamsT = { prompt: 'standup', schedule: '5m', name: 'Standup reminder' }
|
|
21
|
+
|
|
22
|
+
it('创建成功 → Scheduled "<name>" · <schedule>(interval 规格化文案,en-US)', () => {
|
|
23
|
+
const r = toolResult({
|
|
24
|
+
task: { name: 'Standup reminder', kind: 'recurring', schedule: { mode: 'interval', intervalMs: 300000 } },
|
|
25
|
+
nextRuns: [],
|
|
26
|
+
})
|
|
27
|
+
expect(buildScheduleSummary(createParams, r)).toBe('Scheduled "Standup reminder" · every 5m')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('cron 规格 → 原样表达式', () => {
|
|
31
|
+
const r = toolResult({
|
|
32
|
+
task: { name: 'Nightly', kind: 'recurring', schedule: { mode: 'cron', cronExpression: '*/10 * * * *' } },
|
|
33
|
+
nextRuns: [],
|
|
34
|
+
})
|
|
35
|
+
expect(buildScheduleSummary(createParams, r)).toBe('Scheduled "Nightly" · */10 * * * *')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('取消态 → Cancelled', () => {
|
|
39
|
+
expect(buildScheduleSummary(createParams, toolResult({ cancelled: true }))).toBe('Cancelled')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('details 形态漂移(无 task)→ 降级 Scheduled 不抛错', () => {
|
|
43
|
+
expect(buildScheduleSummary(createParams, toolResult({}))).toBe('Scheduled')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('task.schedule 非法形态 → 回落 params.schedule 原文', () => {
|
|
47
|
+
const r = toolResult({ task: { name: 'Odd', schedule: { mode: 'bogus' } } })
|
|
48
|
+
expect(buildScheduleSummary(createParams, r)).toBe('Scheduled "Odd" · 5m')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ── buildScheduleControlSummary ──────────────────────
|
|
53
|
+
|
|
54
|
+
describe('buildScheduleControlSummary', () => {
|
|
55
|
+
it('list → Listed N tasks', () => {
|
|
56
|
+
const params: ScheduleControlParamsT = { action: 'list' }
|
|
57
|
+
expect(buildScheduleControlSummary(params, toolResult({ tasks: [{}, {}] }))).toBe('Listed 2 tasks')
|
|
58
|
+
expect(buildScheduleControlSummary(params, toolResult({ tasks: [] }))).toBe('Listed 0 tasks')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('toggle enabled=false → Paused;enabled=true → Resumed', () => {
|
|
62
|
+
expect(
|
|
63
|
+
buildScheduleControlSummary({ action: 'toggle', id: 'ab12cd34', enabled: false }, toolResult({})),
|
|
64
|
+
).toBe('Paused task ab12cd34')
|
|
65
|
+
expect(
|
|
66
|
+
buildScheduleControlSummary({ action: 'toggle', id: 'ab12cd34', enabled: true }, toolResult({})),
|
|
67
|
+
).toBe('Resumed task ab12cd34')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('delete / run → 动作 + id', () => {
|
|
71
|
+
expect(buildScheduleControlSummary({ action: 'delete', id: 'ab12cd34' }, toolResult({}))).toBe(
|
|
72
|
+
'Deleted task ab12cd34',
|
|
73
|
+
)
|
|
74
|
+
expect(buildScheduleControlSummary({ action: 'run', id: 'ab12cd34' }, toolResult({}))).toBe(
|
|
75
|
+
'Ran task ab12cd34 now',
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ── withScheduleSummary ──────────────────────────────
|
|
81
|
+
|
|
82
|
+
describe('withScheduleSummary', () => {
|
|
83
|
+
it('details 追加 summary,现有字段保留', () => {
|
|
84
|
+
const r = toolResult({ tasks: [{}] })
|
|
85
|
+
const out = withScheduleSummary(r, 'Listed 1 tasks')
|
|
86
|
+
expect(out.details).toEqual({ tasks: [{}], summary: 'Listed 1 tasks' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('非对象 details → 防御替换为 { summary }', () => {
|
|
90
|
+
const r = toolResult('legacy-string')
|
|
91
|
+
const out = withScheduleSummary(r, 'Done')
|
|
92
|
+
expect(out.details).toEqual({ summary: 'Done' })
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ── renderScheduleToolResult(TUI renderResult)──────
|
|
97
|
+
|
|
98
|
+
const mockTheme = {
|
|
99
|
+
fg: (_color: string, text: string) => text,
|
|
100
|
+
bg: (_color: string, text: string) => text,
|
|
101
|
+
bold: (text: string) => text,
|
|
102
|
+
italic: (text: string) => text,
|
|
103
|
+
underline: (text: string) => text,
|
|
104
|
+
inverse: (text: string) => text,
|
|
105
|
+
strikethrough: (text: string) => text,
|
|
106
|
+
getFgAnsi: (_color: string) => '',
|
|
107
|
+
getBgAnsi: (_color: string) => '',
|
|
108
|
+
getColorMode: () => 'truecolor' as const,
|
|
109
|
+
getThinkingBorderColor: () => (text: string) => text,
|
|
110
|
+
getBashModeBorderColor: () => (text: string) => text,
|
|
111
|
+
} as unknown as Theme
|
|
112
|
+
|
|
113
|
+
function renderedText(result: unknown, expanded: boolean): string {
|
|
114
|
+
return renderScheduleToolResult(result, { expanded }, mockTheme)
|
|
115
|
+
.render(400)
|
|
116
|
+
.map((line) => line.trimEnd())
|
|
117
|
+
.join('\n')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe('renderScheduleToolResult', () => {
|
|
121
|
+
it('折叠态 → details.summary 一行', () => {
|
|
122
|
+
const r = withScheduleSummary(toolResult({ tasks: [{}] }, 'multi\nline'), 'Listed 1 tasks')
|
|
123
|
+
expect(renderedText(r, false)).toBe('Listed 1 tasks')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('展开态 → content 纯文本', () => {
|
|
127
|
+
const r = toolResult({}, 'line1\nline2')
|
|
128
|
+
expect(renderedText(r, true)).toBe('line1\nline2')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('无 summary(旧 entry)→ 折叠态回退 content 首行', () => {
|
|
132
|
+
const r = toolResult({}, 'Header\nbody')
|
|
133
|
+
expect(renderedText(r, false)).toBe('Header')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -4,7 +4,7 @@ import { snapshotToTask, toTaskSnapshot } from '../types.js'
|
|
|
4
4
|
import type { ScheduledTask, TaskSnapshot } from '../types.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* B2 转换器收敛(ext-simplify-17
|
|
7
|
+
* B2 转换器收敛(ext-simplify-17)等价检查:toTaskSnapshot canonical 取解构式,
|
|
8
8
|
* 替换 importer 旧显式 14 字段式——期望值按旧显式式的输出形状逐字段锚定
|
|
9
9
|
* (toEqual 严格比较键集合,解构式多带/漏带键即红),防字段演进中静默漂移。
|
|
10
10
|
* 既有间接断言(importer/runtime 测试的 objectContaining)不覆盖键集合精确性与
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// scheduler widget 推送(D1 指纹跳推)集成单测:
|
|
4
4
|
// 走 index.ts 真实装配链(session_start → runtime.onAfterTick → refreshWidget),
|
|
5
|
-
//
|
|
5
|
+
// 观测范围 = ctx.ui.setWidget 调用序列(每个实际推送恰一次调用——跳推判定在
|
|
6
6
|
// setSchedulerWidget 之前,setWidgetDual 的 TUI 分支与清屏分支都落 setWidget)。
|
|
7
7
|
//
|
|
8
8
|
// 行为契约(scheduler widget 推送修正设计 D1-b):
|
|
@@ -79,7 +79,7 @@ function createMockPi(): { pi: ExtensionAPI; events: Map<string, (...args: unkno
|
|
|
79
79
|
return { pi, events }
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/** fake ctx:entries 注入任务(owner = sessionFile);setWidget
|
|
82
|
+
/** fake ctx:entries 注入任务(owner = sessionFile);setWidget 独立导出作观测范围。 */
|
|
83
83
|
function createFakeCtx(sessionFile: string, entries: unknown[] = []): {
|
|
84
84
|
ctx: ExtensionContext
|
|
85
85
|
setWidget: ReturnType<typeof vi.fn>
|
|
@@ -153,7 +153,7 @@ describe('widget 推送判定(D1 指纹跳推)', () => {
|
|
|
153
153
|
stringifySpy.mockRestore()
|
|
154
154
|
|
|
155
155
|
expect(setWidget).toHaveBeenCalledTimes(2) // fail-open:宁可多推不可漏显
|
|
156
|
-
//
|
|
156
|
+
// 用户可见部分:fail-open 推出的帧内容正确(静态行完整,非空帧/异常串)
|
|
157
157
|
const frame = String((setWidget.mock.calls[1]![1] as string[])[0] ?? '')
|
|
158
158
|
expect(frame).toContain('keepalive probe')
|
|
159
159
|
const warnText = loggerMock.warn.mock.calls.map(c => String(c[0])).join('\n')
|