@zhushanwen/pi-scheduler 0.9.2 → 0.10.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/package.json +11 -10
- package/src/__tests__/U4-MODEL-SWITCH.test.ts +62 -196
- package/src/__tests__/active-path.test.ts +253 -0
- package/src/__tests__/index-generation.test.ts +24 -23
- package/src/__tests__/runtime.test.ts +6 -14
- package/src/__tests__/session-tree-registration.test.ts +152 -0
- package/src/__tests__/widget-push.test.ts +15 -55
- package/src/ack-turn.ts +6 -6
- package/src/backend.ts +53 -3
- package/src/index.ts +48 -53
- package/src/runtime.ts +79 -183
- package/src/types.ts +1 -1
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
// src/__tests__/widget-push.test.ts
|
|
2
2
|
//
|
|
3
|
-
// scheduler widget
|
|
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
|
-
// 行为契约(scheduler widget 推送修正设计 D1-b
|
|
9
|
-
// - 任务集指纹不变 → 跳推(时间流逝不是状态变化,静态期零推送——G1
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// 行为契约(scheduler widget 推送修正设计 D1-b):
|
|
9
|
+
// - 任务集指纹不变 → 跳推(时间流逝不是状态变化,静态期零推送——G1;跨原保活窗口
|
|
10
|
+
// 时长仍零推送,锁定原 D2 保活底线帧已随 idle reaper 退役不回归)
|
|
11
|
+
// - 空任务集同样静态期零推送(清屏帧只推一次)
|
|
12
12
|
// - fail-open:指纹计算异常即推送(宁可多推不可漏显)+ warn 留痕
|
|
13
13
|
// - locale 入指纹:切语言一次推送自愈(集成臂;kind 字段在 widget.test.ts 纯函数臂锁定)
|
|
14
14
|
//
|
|
15
|
-
// timer 红线:fake timers(vitest useFakeTimers
|
|
15
|
+
// timer 红线:fake timers(vitest useFakeTimers),静置用大步进 advance(fake 无墙钟成本)。
|
|
16
16
|
|
|
17
17
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
18
18
|
import { tmpdir } from 'node:os'
|
|
@@ -35,11 +35,10 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
|
35
35
|
}))
|
|
36
36
|
|
|
37
37
|
import schedulerExtension from '../index.js'
|
|
38
|
+
import { TICK_INTERVAL_MS } from '../runtime.js'
|
|
38
39
|
import { TASK_ENTRY_TYPE } from '../types.js'
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
/** 与 index.ts 的 WIDGET_KEEPALIVE_INTERVAL_MS 同值(10min;常量不导出,测试锚定同值)。 */
|
|
42
|
-
const KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000
|
|
40
|
+
/** 原保活窗口时长(10min;原 D2 已退役)——静态用例静置越过后断言仍零推送。 */
|
|
41
|
+
const RETIRED_KEEPALIVE_WINDOW_MS = 10 * 60 * 1000
|
|
43
42
|
const BASE = Date.parse('2026-01-01T00:00:00Z')
|
|
44
43
|
|
|
45
44
|
/** 构造一个远期到期的启用任务 upsert entry(owner = sessionFile,避免 fork owner 过滤)。 */
|
|
@@ -99,7 +98,7 @@ function createFakeCtx(sessionFile: string, entries: unknown[] = []): {
|
|
|
99
98
|
return { ctx, setWidget }
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
describe('widget 推送判定(D1
|
|
101
|
+
describe('widget 推送判定(D1 指纹跳推)', () => {
|
|
103
102
|
beforeEach(() => {
|
|
104
103
|
vi.useFakeTimers()
|
|
105
104
|
vi.setSystemTime(BASE)
|
|
@@ -110,7 +109,7 @@ describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () =>
|
|
|
110
109
|
vi.useRealTimers()
|
|
111
110
|
})
|
|
112
111
|
|
|
113
|
-
it('D1:
|
|
112
|
+
it('D1: 任务集静态期间零推送——静置越过原保活窗口仍零推送(D2 退役不回归)', async () => {
|
|
114
113
|
const { pi, events } = createMockPi()
|
|
115
114
|
schedulerExtension(pi)
|
|
116
115
|
const sessionStart = events.get('session_start')!
|
|
@@ -118,34 +117,12 @@ describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () =>
|
|
|
118
117
|
sessionStart({ type: 'session_start', reason: 'startup' }, ctx)
|
|
119
118
|
expect(setWidget).toHaveBeenCalledTimes(1) // 首帧必推(实例态重置)
|
|
120
119
|
|
|
121
|
-
//
|
|
122
|
-
await vi.advanceTimersByTimeAsync(
|
|
123
|
-
expect(setWidget).toHaveBeenCalledTimes(1)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('D2: 非空任务集跨保活窗口 → 强制推一帧,内容与首帧相同(静态面恒定)', async () => {
|
|
127
|
-
const { pi, events } = createMockPi()
|
|
128
|
-
schedulerExtension(pi)
|
|
129
|
-
const sessionStart = events.get('session_start')!
|
|
130
|
-
const { ctx, setWidget } = createFakeCtx('/test/wp-keepalive.json', [
|
|
131
|
-
taskEntry('/test/wp-keepalive.json'),
|
|
132
|
-
])
|
|
133
|
-
sessionStart({ type: 'session_start', reason: 'startup' }, ctx)
|
|
120
|
+
// 静置越过原保活窗口(10min + 2 个 tick):指纹相同 → 零推送(无保活底线帧)
|
|
121
|
+
await vi.advanceTimersByTimeAsync(RETIRED_KEEPALIVE_WINDOW_MS + 2 * TICK_INTERVAL_MS)
|
|
134
122
|
expect(setWidget).toHaveBeenCalledTimes(1)
|
|
135
|
-
const firstFrame = setWidget.mock.calls[0]![1]
|
|
136
|
-
|
|
137
|
-
// 静置越过保活窗口:下一 tick(保活到期后首个 30s 节拍)强制推一帧
|
|
138
|
-
await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
|
|
139
|
-
expect(setWidget).toHaveBeenCalledTimes(2)
|
|
140
|
-
// 保活帧纯粹为心跳:文本与首帧相同(D1 静态化后显示面不随时间变化)
|
|
141
|
-
expect(setWidget.mock.calls[1]![1]).toEqual(firstFrame)
|
|
142
|
-
|
|
143
|
-
// 第二个保活窗口同样恰好一帧(有界心跳,非恢复 30s 节拍)
|
|
144
|
-
await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
|
|
145
|
-
expect(setWidget).toHaveBeenCalledTimes(3)
|
|
146
123
|
})
|
|
147
124
|
|
|
148
|
-
it('
|
|
125
|
+
it('D1: 空任务集静态期零推送(清屏帧只推一次)', async () => {
|
|
149
126
|
const { pi, events } = createMockPi()
|
|
150
127
|
schedulerExtension(pi)
|
|
151
128
|
const sessionStart = events.get('session_start')!
|
|
@@ -154,28 +131,11 @@ describe('widget 推送判定(D1 指纹跳推 + D2 保活底线帧)', () =>
|
|
|
154
131
|
expect(setWidget).toHaveBeenCalledTimes(1) // 首帧 = 清屏帧(setWidget(key, undefined))
|
|
155
132
|
expect(setWidget.mock.calls[0]![1]).toBeUndefined()
|
|
156
133
|
|
|
157
|
-
// 静置 30min
|
|
134
|
+
// 静置 30min:空任务集指纹稳定 → 零推送
|
|
158
135
|
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
|
|
159
136
|
expect(setWidget).toHaveBeenCalledTimes(1)
|
|
160
137
|
})
|
|
161
138
|
|
|
162
|
-
it('D2: 全 disabled 的任务集仍保活(任务存在即调度意图)', async () => {
|
|
163
|
-
const { pi, events } = createMockPi()
|
|
164
|
-
schedulerExtension(pi)
|
|
165
|
-
const sessionStart = events.get('session_start')!
|
|
166
|
-
const file = '/test/wp-disabled.json'
|
|
167
|
-
const { ctx, setWidget } = createFakeCtx(file, [taskEntry(file, { enabled: false })])
|
|
168
|
-
sessionStart({ type: 'session_start', reason: 'startup' }, ctx)
|
|
169
|
-
expect(setWidget).toHaveBeenCalledTimes(1)
|
|
170
|
-
|
|
171
|
-
await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS + TICK_INTERVAL_MS)
|
|
172
|
-
expect(setWidget).toHaveBeenCalledTimes(2) // disabled 不豁免保活(re-enable 后须可执行)
|
|
173
|
-
// 用户可见面:保活帧携带完整静态行(计数段在场——TUI 最近任务行只取 enabled 任务,
|
|
174
|
-
// 全 disabled 时行 = 计数段,这是既有 enabled 过滤行为)
|
|
175
|
-
const frame = String((setWidget.mock.calls[1]![1] as string[])[0] ?? '')
|
|
176
|
-
expect(frame).toBe('[scheduler] 0 scheduled')
|
|
177
|
-
})
|
|
178
|
-
|
|
179
139
|
it('fail-open: 指纹计算异常 → 该 tick 照常推送 + warn 留痕', async () => {
|
|
180
140
|
const { pi, events } = createMockPi()
|
|
181
141
|
schedulerExtension(pi)
|
package/src/ack-turn.ts
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// ① 建任务成功 ⇒ maybeStartAck:只读检查未落盘 + 空闲 + 可用,注入 custom 触发器
|
|
12
12
|
// (triggerTurn);写盘兜底判定收敛在 session 边界;
|
|
13
13
|
// ② 触发器的 message_start 到达(本轮首次模型请求之前——pi-agent-core
|
|
14
|
-
// agent-loop.js
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// agent-loop.js 初始 prompt 逐条 emit message_start/message_end 先于
|
|
15
|
+
// runLoop → streamAssistantResponse 首次模型请求;直达链锚 PS-08,
|
|
16
|
+
// verifiedWith 以 pi-semantics.json 为准)⇒ 同步 registerProvider 覆写当前 provider 的 streamSimple;
|
|
17
17
|
// ③ 我们的 streamSimple 被调用 ⇒ 返回 stream 之前同步注销覆写(one-shot 自撤),
|
|
18
18
|
// 本轮内被 steer/followUp drain 出的后续请求与后续轮次全部走真实 provider;
|
|
19
19
|
// ④ turn_end / session 边界 ⇒ 安全网注销(幂等)。
|
|
@@ -344,9 +344,9 @@ export function createAckTurnController(deps: AckTurnDeps): AckTurnController {
|
|
|
344
344
|
/**
|
|
345
345
|
* 落盘判据①:会话内已有 assistant 消息 ⇒ pi 已 flush,任务必然已落盘。
|
|
346
346
|
*
|
|
347
|
-
* 反向推论承重锚(语义登记 docs/pi-semantics.json PS-14
|
|
348
|
-
* fileEntries 再同步 _persist
|
|
349
|
-
* hasAssistant 命中即走同步 wx
|
|
347
|
+
* 反向推论承重锚(语义登记 docs/pi-semantics.json PS-14,verifiedWith 以该登记为准):
|
|
348
|
+
* pi _appendEntry 先 push 进 fileEntries 再同步 _persist,assistant 在列时
|
|
349
|
+
* hasAssistant 命中即走同步 wx 全量补写分支(0.84.4 无异步 flush)——getEntries
|
|
350
350
|
* 读到 assistant 的时刻磁盘必已含全部内存 entry,无异步窗口。
|
|
351
351
|
*
|
|
352
352
|
* entry 形状来自 replay 的 SchedulerEntryLike(无 role 字段声明),故用 `in` 收窄到
|
package/src/backend.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { filterActivePath } from '@zhushanwen/pi-session-path'
|
|
2
3
|
|
|
3
4
|
import { replayFoldEntries, type SchedulerEntryLike } from './replay.js'
|
|
4
5
|
import { TASK_ENTRY_TYPE } from './types.js'
|
|
@@ -70,6 +71,14 @@ export interface SchedulerBackend {
|
|
|
70
71
|
* 不触发任何写入(项目规则 #6)。
|
|
71
72
|
*/
|
|
72
73
|
getEntries(): SchedulerEntryLike[]
|
|
74
|
+
/**
|
|
75
|
+
* U6c:session_tree(树回退)重折叠订阅。生产注册面在 extension factory 顶层
|
|
76
|
+
* (index.ts:pi.on('session_tree') 每代恰一,事件经 activeBackend 委托
|
|
77
|
+
* PiSchedulerBackend.refoldSessionTree),事件到达时按活跃路径重折叠任务集并回调
|
|
78
|
+
* listener——落点是 SchedulerRuntime 的内存任务 Map(runtime 构造时订阅,backend
|
|
79
|
+
* 不能反向持有 runtime)。测试替身可不实现(构造侧 ?. 订阅为 no-op,既有单测路径不变)。
|
|
80
|
+
*/
|
|
81
|
+
onSessionTree?(listener: (tasks: ScheduledTask[]) => void): void
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/**
|
|
@@ -81,6 +90,14 @@ export interface SchedulerBackendCtx {
|
|
|
81
90
|
sessionManager: {
|
|
82
91
|
getEntries(): SchedulerEntryLike[]
|
|
83
92
|
getSessionFile(): string | undefined
|
|
93
|
+
/**
|
|
94
|
+
* 当前叶子 entry id(U6c 活跃路径回溯锚)。可选:真实 ExtensionContext 恒存在
|
|
95
|
+
* (ReadonlySessionManager Pick 清单含 getLeafId);最小 duck-typed ctx(单测)
|
|
96
|
+
* 缺失时按文件尾回退——session_start 时点与 pi 树重放规则(叶子 = 文件最后
|
|
97
|
+
* 一条 entry)等价,session_tree 时点由生产 ctx 携带精确锚。
|
|
98
|
+
* (pi 树重放规则实锚登记 pi-semantics PS-60:dist/core/session-manager.js:673 _buildIndex)
|
|
99
|
+
*/
|
|
100
|
+
getLeafId?(): string | null
|
|
84
101
|
}
|
|
85
102
|
/**
|
|
86
103
|
* 会话是否空闲(not streaming)。真实 ExtensionContext 保证存在(SDK
|
|
@@ -96,6 +113,14 @@ export interface SchedulerBackendCtx {
|
|
|
96
113
|
model?: SchedulerCurrentModel
|
|
97
114
|
}
|
|
98
115
|
|
|
116
|
+
// ── 活跃路径裁剪(U6c)──
|
|
117
|
+
|
|
118
|
+
// 活跃路径裁剪(leafId 沿 parentId 回溯,撤回后被撤子树的任务 op 不进折叠输入,
|
|
119
|
+
// 被撤任务不得恢复 pending、到点触发真实 turn——A14)收敛于
|
|
120
|
+
// @zhushanwen/pi-session-path 单一实现(四包同构副本收编,防御语义与回退口径见
|
|
121
|
+
// 该包 filterActivePath 注释;runtime 侧 entry-tree-builder 保持独立——extension
|
|
122
|
+
// 不能 import runtime 包)。
|
|
123
|
+
|
|
99
124
|
// ── 生产实现 ──
|
|
100
125
|
|
|
101
126
|
/**
|
|
@@ -111,22 +136,47 @@ export class PiSchedulerBackend implements SchedulerBackend {
|
|
|
111
136
|
ExtensionAPI,
|
|
112
137
|
'sendMessage' | 'appendEntry' | 'registerProvider' | 'unregisterProvider'
|
|
113
138
|
>
|
|
139
|
+
private sessionTreeListener: ((tasks: ScheduledTask[]) => void) | null = null
|
|
114
140
|
|
|
115
141
|
constructor(
|
|
116
142
|
ctx: SchedulerBackendCtx,
|
|
117
|
-
pi: Pick<
|
|
143
|
+
pi: Pick<
|
|
144
|
+
ExtensionAPI,
|
|
145
|
+
'sendMessage' | 'appendEntry' | 'registerProvider' | 'unregisterProvider'
|
|
146
|
+
>,
|
|
118
147
|
) {
|
|
119
148
|
this.ctx = ctx
|
|
120
149
|
this.pi = pi
|
|
121
150
|
}
|
|
122
151
|
|
|
152
|
+
/** U6c:SchedulerRuntime 构造时订阅重折叠回调(见 SchedulerBackend.onSessionTree)。 */
|
|
153
|
+
onSessionTree(listener: (tasks: ScheduledTask[]) => void): void {
|
|
154
|
+
this.sessionTreeListener = listener
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* U6c:session_tree(树回退)事件委托体,由 extension factory 顶层注册的 handler
|
|
159
|
+
* 经 activeBackend 调用(index.ts;不在本类构造函数注册,注册面理由见该处注释)。
|
|
160
|
+
* 纯重建体:只按活跃路径重折叠任务集(loadTasks)并回调 runtime 换 Map——不做
|
|
161
|
+
* 任何 sendMessage / appendEntry / tickTimer 启停(tick 常驻循环照常消费新任务集,
|
|
162
|
+
* 被撤子树任务不在集内即到点不触发,A14)。
|
|
163
|
+
*/
|
|
164
|
+
refoldSessionTree(): void {
|
|
165
|
+
this.sessionTreeListener?.(this.loadTasks())
|
|
166
|
+
}
|
|
167
|
+
|
|
123
168
|
/**
|
|
124
169
|
* 读路径:折叠当前 session 的 TASK_ENTRY_TYPE custom entries 恢复任务(非接口成员,
|
|
125
|
-
* 由装配点 session_start
|
|
170
|
+
* 由装配点 session_start / session_tree 重折叠调用)。replayFoldEntries 内部含 fork
|
|
171
|
+
* owner 过滤与异常兜底。U6c:折叠输入接活跃路径裁剪——从 leafId 沿 parentId 回溯,
|
|
172
|
+
* 被撤子树的任务 op 不再恢复 pending(否则被撤任务到点照常触发真实 turn,A14)。
|
|
126
173
|
*/
|
|
127
174
|
loadTasks(): ScheduledTask[] {
|
|
128
175
|
return [
|
|
129
|
-
...replayFoldEntries(
|
|
176
|
+
...replayFoldEntries(
|
|
177
|
+
filterActivePath(this.ctx.sessionManager),
|
|
178
|
+
this.ctx.sessionManager.getSessionFile(),
|
|
179
|
+
).values(),
|
|
130
180
|
]
|
|
131
181
|
}
|
|
132
182
|
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
import type { GuiContext } from '@zhushanwen/extension-protocol'
|
|
3
3
|
import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
|
|
4
4
|
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
@@ -7,7 +7,6 @@ import { createAckTurnController, type AckTurnController } from './ack-turn.js'
|
|
|
7
7
|
import { PiSchedulerBackend } from './backend.js'
|
|
8
8
|
import { registerScheduleCommand } from './commands.js'
|
|
9
9
|
import { formatSchedule } from './format.js'
|
|
10
|
-
import { MS_PER_MINUTE } from './parsing.js'
|
|
11
10
|
import { readUiLocale, t } from './i18n.js'
|
|
12
11
|
import { importLegacyStore } from './importer.js'
|
|
13
12
|
import { abortPendingScheduleForms } from './interaction.js'
|
|
@@ -40,26 +39,14 @@ import type { ScheduledTask } from './types.js'
|
|
|
40
39
|
// 绑定,永不再递增 → 其 isCtxStale 恒 false。该盲区由两道既有防线覆盖:pi 在替换前 await
|
|
41
40
|
// fire session_shutdown(F1 stopScheduler 主防线,teardownCurrent)+ runtime 侧
|
|
42
41
|
// STALE_CTX_MARKER 文案兜底(F2 catch 分诊)。
|
|
42
|
+
// 绑定,永不再递增 → 其 isCtxStale 恒 false。该盲区由两道既有防线覆盖:pi 在替换前 await
|
|
43
|
+
// fire session_shutdown(F1 stopScheduler 主防线,teardownCurrent)+ runtime 侧
|
|
44
|
+
// STALE_CTX_MARKER 文案兜底(F2 catch 分诊)。
|
|
43
45
|
let sessionGeneration = 0
|
|
44
46
|
|
|
45
47
|
/** 包内既有 logger(ack 编排的日志面与其它模块同源)。 */
|
|
46
48
|
const logger = getLogger('scheduler')
|
|
47
49
|
|
|
48
|
-
/**
|
|
49
|
-
* D2 保活底线帧间隔(10min):任务集静态期间每 ≥10min 强制推一帧(内容与上一帧相同,
|
|
50
|
-
* 纯粹为维持 rpc-client 入站全帧 touch `_lastActivityAt` 的心跳),防 idle reaper
|
|
51
|
-
* (DEFAULT_PI_RECLAIM_IDLE_MS,生产 2h,5min 一拍、严格大于判定)回收挂有定时任务的
|
|
52
|
-
* 会话——定时任务跨空闲期不停摆(G3)。
|
|
53
|
-
*
|
|
54
|
-
* **方案不变量:保活间隔 ≪ idle 回收阈值,须维持 ≥3 倍余量。** 生产 10min vs 2h = 12 倍;
|
|
55
|
-
* 保活帧实际到达受 tick(TICK_INTERVAL_MS=30s)驱动波动(+0~30s),余量须覆盖 tick 波动
|
|
56
|
-
* 与 reaper 拍相位。未来调整本值、idle 阈值或 tick 间隔任一侧,须重验 ≥3 倍余量并重跑
|
|
57
|
-
* C-场景(真机加速验收的加速值也须 ≥3 倍保活间隔,防零余量竞态随机误回收)。
|
|
58
|
-
*/
|
|
59
|
-
/** 保活间隔的分钟数(语义单位,避免裸魔数;乘 MS_PER_MINUTE 得毫秒间隔)。 */
|
|
60
|
-
const WIDGET_KEEPALIVE_MINUTES = 10
|
|
61
|
-
const WIDGET_KEEPALIVE_INTERVAL_MS = WIDGET_KEEPALIVE_MINUTES * MS_PER_MINUTE
|
|
62
|
-
|
|
63
50
|
/**
|
|
64
51
|
* pi-scheduler extension factory。
|
|
65
52
|
* 注册 schedule + schedule_control 两个 tool、/schedule command、session 事件。
|
|
@@ -70,6 +57,9 @@ const WIDGET_KEEPALIVE_INTERVAL_MS = WIDGET_KEEPALIVE_MINUTES * MS_PER_MINUTE
|
|
|
70
57
|
*/
|
|
71
58
|
export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
72
59
|
let service: SchedulerService | null = null
|
|
60
|
+
// U6c:当代 backend(session_start 装配点更新)。factory 顶层 session_tree handler
|
|
61
|
+
// 经它委托当代重折叠(注册面理由见下方 session_tree 注册点注释)。
|
|
62
|
+
let activeBackend: PiSchedulerBackend | null = null
|
|
73
63
|
// IMPORT-FLUSH-GUARD(MF-1):importLegacyStore 对未 flush 的新 session 返回延迟删除 .imported
|
|
74
64
|
// 的 cleanup——turn_end / session_shutdown 时执行:确认 flush(sessionFile 已出现)则删,
|
|
75
65
|
// 未 flush 保留供崩溃恢复重导入(否则未 flush 即退出 → 全部旧任务丢失且源文件已销毁)。
|
|
@@ -86,7 +76,6 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
86
76
|
// 空窗;模块级 vs 闭包级的语义差异在本包有实证(见上方 sessionGeneration 注释)。
|
|
87
77
|
// 生命周期:session_start 装配新会话实例时重置(refreshWidgetState 调用点)。
|
|
88
78
|
let lastWidgetFingerprint: string | null = null
|
|
89
|
-
let lastWidgetPushedAt = 0
|
|
90
79
|
|
|
91
80
|
const getService = (): SchedulerService => {
|
|
92
81
|
if (!service) throw new Error('Scheduler not initialized: session not started')
|
|
@@ -105,6 +94,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
105
94
|
service?.runtime.stopScheduler()
|
|
106
95
|
// 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
|
|
107
96
|
const backend = new PiSchedulerBackend(ctx, pi)
|
|
97
|
+
// U6c:同步给 factory 顶层 session_tree handler(下方注册)——同代 session_start
|
|
98
|
+
// 复发时本赋值覆盖为最新 backend,委托恒指当代 ctx 的实例。
|
|
99
|
+
activeBackend = backend
|
|
108
100
|
// 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
|
|
109
101
|
// append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
|
|
110
102
|
// ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
|
|
@@ -178,45 +170,56 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
178
170
|
})
|
|
179
171
|
|
|
180
172
|
// 注册 widget(SDK setWidget 第一重载:直接传 string[])。初始渲染一次,
|
|
181
|
-
// 后续随每次 tickScheduler 末尾的 onAfterTick
|
|
182
|
-
//
|
|
173
|
+
// 后续随每次 tickScheduler 末尾的 onAfterTick 回调刷新(推送频率由指纹跳推判定,
|
|
174
|
+
// 见 refreshWidget)。
|
|
183
175
|
// 新会话实例起点重置推送状态:首帧(含空任务清屏帧)必推,不继承前代指纹/时间戳。
|
|
184
176
|
lastWidgetFingerprint = null
|
|
185
|
-
lastWidgetPushedAt = 0
|
|
186
177
|
refreshWidget(ctx)
|
|
187
178
|
})
|
|
188
179
|
|
|
180
|
+
// U6c:树回退(撤回 __taiji_nav__ → navigateTree / 用户树跳转)后重折叠任务集。
|
|
181
|
+
// 注册在 factory 顶层而非 PiSchedulerBackend 构造函数:pi 的 on 是追加语义(loader.js
|
|
182
|
+
// on 实现 list.push,无去重——0.84.4 连 off 都没有,1.0.0 起返回 unsubscribe 但本处
|
|
183
|
+
// 不消费,handler 生命周期随 factory 代际收敛),且 RPC 模式下每次 session 替换
|
|
184
|
+
// session_start 在同一代内触发两次(agent-session-runtime finishSessionReplacement
|
|
185
|
+
// 内部 rebindSession 一路 + RPC handler 再 rebindSession 一路,均经 bindExtensions
|
|
186
|
+
// emit session_start)——构造函数注册会同代线性累积 handler(残留旧代 backend 在旧
|
|
187
|
+
// ctx 上重复执行幂等 loadTasks,handler 数随替换无界缓增)。factory 每代恰好运行一次
|
|
188
|
+
// (loader.js initializeExtension 每代新建 handlers Map 并重跑 factory),顶层注册
|
|
189
|
+
// 结构性保证每代恰一 handler;委托 activeBackend(session_start 装配点更新)保证折叠
|
|
190
|
+
// 输入恒为当代实例,事件早于首个 session_start 时 no-op。
|
|
191
|
+
// 纯重建体(goal/plan/todo 的 session_tree handler 同款):只按活跃路径重折叠任务集
|
|
192
|
+
// 并回调 runtime 换 Map——不做任何 sendMessage / appendEntry / tickTimer 启停(tick
|
|
193
|
+
// 常驻循环照常消费新任务集,被撤子树任务不在集内即到点不触发,A14)。
|
|
194
|
+
pi.on('session_tree', (_event: unknown, _ctx: ExtensionContext) => {
|
|
195
|
+
activeBackend?.refoldSessionTree()
|
|
196
|
+
})
|
|
197
|
+
|
|
189
198
|
// turn_end 单注册共用(U4 恢复挂点与 MF-1 cleanup 同事件):真实 pi 的 on 是 handler 列表
|
|
190
199
|
// 追加,但测试仿真 mock 为覆盖式单 handler,且同事件单注册与「listener 防重复注册」纪律一致。
|
|
191
|
-
|
|
200
|
+
// (U4 模型恢复不在本事件:D1 归属简化后恢复唯一事件通道 = agent_settled,见 runtime.handleRunSettled)
|
|
201
|
+
pi.on('turn_end', () => {
|
|
192
202
|
// IMPORT-FLUSH-GUARD(MF-1):延迟删除的主触发点——turn_end 前该轮所有 message_end 已持久化
|
|
193
203
|
// (agent-session.js _handleAgentEvent 在 message_end 处理中调 appendMessage 触发 flush),
|
|
194
204
|
// sessionFile 已出现 → cleanup 删 .imported;仍未 flush(无 assistant 消息的轮次)→ 静默保留,
|
|
195
205
|
// 下次 turn_end / session_shutdown 重试。cleanup 幂等(importer.ts importFromFile)。
|
|
196
206
|
importCleanup?.()
|
|
197
|
-
// U4 dispatch 模型切换恢复挂点(设计 D3 修订版):状态机、恢复动作与 stale 代际守卫都在
|
|
198
|
-
// SchedulerRuntime。`event?.` 容错:pi 契约 payload 恒在,测试仿真可无参调用,缺省不匹配不动作。
|
|
199
|
-
service?.runtime.handleTurnEnd(event?.turnIndex)
|
|
200
207
|
// ack 安全网注销(幂等):正常路径已在 streamSimple 调用点自撤,这里覆盖「覆写未被调用」
|
|
201
208
|
// 的轮次(E2)。
|
|
202
209
|
ackController?.handleTurnEnd()
|
|
203
210
|
})
|
|
204
211
|
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
pi.on('agent_start', () => service?.runtime.handleAgentStart())
|
|
209
|
-
pi.on('turn_start', (event) => service?.runtime.handleTurnStart(event?.turnIndex))
|
|
212
|
+
// message_start 仅剩 ack 触发器判别(u-ack-turn):只有我们注入的 custom 消息(前缀
|
|
213
|
+
// pi-scheduler-ack:)才同步武装覆写;外来/assistant 消息一律忽略。(U4 dispatch 归属匹配
|
|
214
|
+
// 已随 D1 归属简化删除)
|
|
210
215
|
pi.on('message_start', (event) => {
|
|
211
|
-
service?.runtime.handleMessageStart(event?.message)
|
|
212
|
-
// ack 触发器判别(u-ack-turn):只有我们注入的 custom 消息(前缀 pi-scheduler-ack:)
|
|
213
|
-
// 才同步武装覆写;外来/assistant 消息一律忽略。
|
|
214
216
|
ackController?.handleMessageStart(event?.message)
|
|
215
217
|
})
|
|
216
|
-
|
|
217
|
-
// agent_settled
|
|
218
|
-
//
|
|
219
|
-
// runtime
|
|
218
|
+
|
|
219
|
+
// U4 dispatch 模型切换恢复的唯一事件挂点:agent_settled = run 完全沉降(无 retry/
|
|
220
|
+
// compaction/queued continuation)后的 isIdle 复核兑现(D1 归属简化——不挂 agent_start/
|
|
221
|
+
// turn_start/message_start/turn_end/agent_end,runtime 无 turnIndex 归属状态机;end 后仍
|
|
222
|
+
// 可能有自动续跑 turn,恢复只认 settled,见 runtime.handleRunSettled 注释)。
|
|
220
223
|
pi.on('agent_settled', () => service?.runtime.handleRunSettled())
|
|
221
224
|
|
|
222
225
|
pi.on('session_shutdown', async () => {
|
|
@@ -246,8 +249,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
246
249
|
// 注册 schedule tool(触发反转:直建,不再弹确认表单——人侧表单入口在 /schedule 命令)。
|
|
247
250
|
// execute 内联闭包:从 SDK 全签名 (toolCallId, params, signal, onUpdate, ctx) 提取
|
|
248
251
|
// 转调 handleSchedule 直建流(预校验 → abort 检查 → service.create)。
|
|
249
|
-
// 错误路径 throw(W4):pi
|
|
250
|
-
// isError
|
|
252
|
+
// 错误路径 throw(W4):pi 对 execute throw 置 isError:true(agent-loop.js:581-588;
|
|
253
|
+
// 1.0.0 起返回值 isError:true 也被尊重,本工具恒走 throw 路径两版语义等价);
|
|
254
|
+
// getService() 未初始化异常穿透到这里,包装
|
|
251
255
|
// 'Error: Scheduler not initialized' 格式(R3 格式保持)。
|
|
252
256
|
pi.registerTool({
|
|
253
257
|
name: 'schedule',
|
|
@@ -308,14 +312,11 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
308
312
|
* `ctx as GuiContext`:pi 的 `ExtensionContext` 与协议包最小结构(mode/hasUI/ui.setWidget)
|
|
309
313
|
* 静态不完全兼容,先例见 todo/src/index.ts makeRefreshDisplay。
|
|
310
314
|
*
|
|
311
|
-
* 推送频率判定(scheduler widget 推送修正设计 D1-b
|
|
315
|
+
* 推送频率判定(scheduler widget 推送修正设计 D1-b;原 D2 保活底线帧随 idle reaper
|
|
316
|
+
* 退役一并删除——其存在理由唯一指向 reaper 回收防御):
|
|
312
317
|
* - D1 指纹跳推:任务集稳定指纹(computeTasksFingerprint,字段集含 kind/locale)与上次
|
|
313
|
-
*
|
|
318
|
+
* 实际推送相同 → 跳过推送。时间流逝不是状态变化,任务集不变期间零推送
|
|
314
319
|
* (rpc/tui 两模式同效——判定在 setWidgetDual 之前)。
|
|
315
|
-
* - D2 保活底线帧:有任务(任务集非空,含全部 disabled——任务存在即调度意图,re-enable
|
|
316
|
-
* 后须可执行)且距上次实际推送超过 WIDGET_KEEPALIVE_INTERVAL_MS → 强制推一帧(内容
|
|
317
|
-
* 与上帧相同,纯粹为维持心跳防 idle reaper 回收)。空任务集不发保活帧:清屏后任务集
|
|
318
|
-
* 保持空 → 无帧 → 会话按 idle 规则正常回收(保活与任务存在性绑定)。
|
|
319
320
|
* - fail-open:指纹计算异常即推送(宁可多推不可漏显,设计 §3.1 失败路径)。
|
|
320
321
|
*/
|
|
321
322
|
function refreshWidget(ctx: ExtensionContext): void {
|
|
@@ -330,22 +331,16 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
330
331
|
fingerprint = computeTasksFingerprint(tasks, locale)
|
|
331
332
|
} catch (err) {
|
|
332
333
|
// fail-open(辅助显示面的降级 ≠ 吞错):指纹异常说明序列化路径有 bug,跳推判定不可信
|
|
333
|
-
// → 直接推送保显示正确;缓存失效(null
|
|
334
|
+
// → 直接推送保显示正确;缓存失效(null)使下一帧也必推。
|
|
334
335
|
logger.warn('widget fingerprint computation failed, pushing anyway', { error: toErrorMessage(err) })
|
|
335
336
|
lastWidgetFingerprint = null
|
|
336
|
-
lastWidgetPushedAt = Date.now()
|
|
337
337
|
setSchedulerWidget(ctx as GuiContext, tasks)
|
|
338
338
|
return
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
|
|
342
|
-
// D2 双条件:任务存在(含 disabled)+ 距上次实际推送超时。跳推不刷新 lastWidgetPushedAt
|
|
343
|
-
// ——保活计时只从「实际推送」起算。
|
|
344
|
-
const keepaliveDue = tasks.length > 0 && nowMs - lastWidgetPushedAt >= WIDGET_KEEPALIVE_INTERVAL_MS
|
|
345
|
-
if (fingerprint === lastWidgetFingerprint && !keepaliveDue) return
|
|
341
|
+
if (fingerprint === lastWidgetFingerprint) return
|
|
346
342
|
|
|
347
343
|
setSchedulerWidget(ctx as GuiContext, tasks)
|
|
348
344
|
lastWidgetFingerprint = fingerprint
|
|
349
|
-
lastWidgetPushedAt = nowMs
|
|
350
345
|
}
|
|
351
346
|
}
|