@zhushanwen/pi-scheduler 0.11.0 → 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 +5 -4
- package/src/__tests__/service.test.ts +1 -1
- package/src/__tests__/session-tree-registration.test.ts +3 -3
- 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 +6 -6
- package/src/interaction.ts +1 -1
- package/src/runtime.ts +23 -23
- 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.11.
|
|
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
|
|
|
@@ -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)
|
|
@@ -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')
|
|
@@ -26,7 +26,7 @@ const makeTask = (overrides: Partial<ScheduledTask> = {}): ScheduledTask => ({
|
|
|
26
26
|
...overrides,
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
-
describe('renderSchedulerWidgetTui(D1
|
|
29
|
+
describe('renderSchedulerWidgetTui(D1 静态范围契约)', () => {
|
|
30
30
|
it('returns empty array when no tasks', () => {
|
|
31
31
|
expect(renderSchedulerWidgetTui([], 'en-US')).toEqual([])
|
|
32
32
|
})
|
|
@@ -37,7 +37,7 @@ describe('renderSchedulerWidgetTui(D1 静态面契约)', () => {
|
|
|
37
37
|
expect(result[0]).toContain('2 scheduled')
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
-
//
|
|
40
|
+
// 静态范围:最近任务行 = 任务名 + 静态调度描述(formatSchedule),无相对时间
|
|
41
41
|
it('renders nearest task with static schedule description, no relative time', () => {
|
|
42
42
|
const tasks = [makeTask({ name: 'check build' })]
|
|
43
43
|
const result = renderSchedulerWidgetTui(tasks, 'en-US')
|
|
@@ -48,7 +48,7 @@ describe('renderSchedulerWidgetTui(D1 静态面契约)', () => {
|
|
|
48
48
|
expect(result[0]).not.toContain('now')
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
// 逾期标记整体移除:nextRunAt 在过去也不产生 [!] 段(now
|
|
51
|
+
// 逾期标记整体移除:nextRunAt 在过去也不产生 [!] 段(now 派生显示不进静态范围)
|
|
52
52
|
it('omits overdue marker even when nextRunAt is in the past', () => {
|
|
53
53
|
const tasks = [makeTask({ nextRunAt: Date.now() - 1000 })]
|
|
54
54
|
const result = renderSchedulerWidgetTui(tasks, 'en-US')
|
|
@@ -120,7 +120,7 @@ describe('renderSchedulerWidgetTui(D1 静态面契约)', () => {
|
|
|
120
120
|
})
|
|
121
121
|
})
|
|
122
122
|
|
|
123
|
-
describe('buildSchedulerWidgetGui(D1
|
|
123
|
+
describe('buildSchedulerWidgetGui(D1 静态范围契约)', () => {
|
|
124
124
|
it('emits structured meta with self-owned localized title (en-US)', () => {
|
|
125
125
|
const tasks = [makeTask(), makeTask({ id: 'def67890', name: 'another' })]
|
|
126
126
|
const gui = buildSchedulerWidgetGui(tasks, 'en-US')
|
|
@@ -219,7 +219,7 @@ describe('setSchedulerWidget', () => {
|
|
|
219
219
|
|
|
220
220
|
// ── D1-b 任务集指纹(computeTasksFingerprint 纯函数契约)──
|
|
221
221
|
// 维护不变量:指纹字段集 ⊇ widget 显示决定因素全集(scheduler widget 推送修正设计 D1-b)。
|
|
222
|
-
//
|
|
222
|
+
// 显示侧 = f(id, name, schedule, kind, enabled, nextRunAt, locale)——以下用例逐字段锁定:
|
|
223
223
|
// 变更任一显示决定因素必变指纹,字段不变必同指纹。
|
|
224
224
|
|
|
225
225
|
describe('computeTasksFingerprint(字段集契约)', () => {
|
|
@@ -259,13 +259,17 @@ describe('computeTasksFingerprint(字段集契约)', () => {
|
|
|
259
259
|
|
|
260
260
|
it('non-display runtime state does NOT alter the fingerprint', () => {
|
|
261
261
|
// runCount/history/lastStatus/pending 不在指纹字段集(非显示决定因素——执行摘要只进
|
|
262
|
-
//
|
|
263
|
-
|
|
262
|
+
// 命令查询结果)。它们变化不触发推送,否则静态期零推送承诺被破坏。
|
|
263
|
+
// nextRunAt 显式固定(nextRunAt 在指纹字段集内):makeTask 缺省 Date.now() 两次调用
|
|
264
|
+
// 跨毫秒边界时差 1ms → 指纹不同 → 断言测到时钟而非字段集(coverage-gate 实测实例)。
|
|
265
|
+
const now = Date.now()
|
|
266
|
+
const base = makeTask({ nextRunAt: now })
|
|
264
267
|
const busy = makeTask({
|
|
265
268
|
runCount: 7,
|
|
266
269
|
history: [{ at: base.createdAt, status: 'failed' }],
|
|
267
270
|
lastStatus: 'failed',
|
|
268
271
|
pending: true,
|
|
272
|
+
nextRunAt: now,
|
|
269
273
|
})
|
|
270
274
|
expect(computeTasksFingerprint([base], 'en-US')).toBe(computeTasksFingerprint([busy], 'en-US'))
|
|
271
275
|
})
|
package/src/ack-notify.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// (`notify-toast.ts` 对非前台 session 的 info 不弹窗),用 `info` 等于静默撒谎。
|
|
13
13
|
//
|
|
14
14
|
// 反向撒谎禁令:e1/e2/e4/e5(真实模型已应答 ⇒ 任务其实已落盘)与 e8b(hybrid ⇒ 覆写照常
|
|
15
|
-
// 发生)一律不发「未写入」文案;e6
|
|
15
|
+
// 发生)一律不发「未写入」文案;e6(注销失败)与落盘无关,只在日志侧登记。这些形态在
|
|
16
16
|
// 类型层就不可传入通知路径(`AckNotifyReason` 只含两种可通知形态)。
|
|
17
17
|
|
|
18
18
|
/**
|
package/src/ack-provider.ts
CHANGED
|
@@ -228,7 +228,7 @@ export async function computeAckAvailability(deps: AckAvailabilityDeps): Promise
|
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
// ──
|
|
231
|
+
// ── 小检查(禁 any / 免断言)──
|
|
232
232
|
|
|
233
233
|
/** 是否为带 `code` 的 Node errno 异常(ENOENT 判别用)。 */
|
|
234
234
|
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
@@ -240,7 +240,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
240
240
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
/**
|
|
243
|
+
/** 错误类型最小派生视图(name + 可选 code)——只取可安全入日志的字段。 */
|
|
244
244
|
function describeError(err: unknown): { name: string; code?: string } {
|
|
245
245
|
if (err instanceof Error) {
|
|
246
246
|
const code = isErrnoException(err) ? err.code : undefined
|
package/src/ack-turn.ts
CHANGED
|
@@ -78,7 +78,7 @@ export const ackState: AckState = getOrCreateAckState()
|
|
|
78
78
|
/** 通知去重(模块级;resetAckState 全清——跨会话隔离)。 */
|
|
79
79
|
const notifyDedup = createAckNotifyDedup()
|
|
80
80
|
|
|
81
|
-
/**
|
|
81
|
+
/** 编排依赖(注入点;backend 为 per-session 实例,状态经模块级单例跨代共享)。 */
|
|
82
82
|
export interface AckTurnDeps {
|
|
83
83
|
backend: SchedulerBackend
|
|
84
84
|
log: {
|
package/src/backend.ts
CHANGED
|
@@ -72,7 +72,7 @@ export interface SchedulerBackend {
|
|
|
72
72
|
*/
|
|
73
73
|
getEntries(): SchedulerEntryLike[]
|
|
74
74
|
/**
|
|
75
|
-
* U6c:session_tree
|
|
75
|
+
* U6c:session_tree(树回退)重折叠订阅。生产注册点在 extension factory 顶层
|
|
76
76
|
* (index.ts:pi.on('session_tree') 每代恰一,事件经 activeBackend 委托
|
|
77
77
|
* PiSchedulerBackend.refoldSessionTree),事件到达时按活跃路径重折叠任务集并回调
|
|
78
78
|
* listener——落点是 SchedulerRuntime 的内存任务 Map(runtime 构造时订阅,backend
|
|
@@ -107,7 +107,7 @@ export interface SchedulerBackendCtx {
|
|
|
107
107
|
*/
|
|
108
108
|
isIdle?(): boolean
|
|
109
109
|
/**
|
|
110
|
-
* 会话当前模型(真实 ExtensionContext.model: Model<any> | undefined
|
|
110
|
+
* 会话当前模型(真实 ExtensionContext.model: Model<any> | undefined,结构兼容此处最小派生视图)。
|
|
111
111
|
* 可选 = 无模型会话(ExtensionContext.model 本身可为 undefined)。
|
|
112
112
|
*/
|
|
113
113
|
model?: SchedulerCurrentModel
|
|
@@ -156,7 +156,7 @@ export class PiSchedulerBackend implements SchedulerBackend {
|
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
158
|
* U6c:session_tree(树回退)事件委托体,由 extension factory 顶层注册的 handler
|
|
159
|
-
* 经 activeBackend 调用(index.ts
|
|
159
|
+
* 经 activeBackend 调用(index.ts;不在本类构造函数注册,注册点理由见该处注释)。
|
|
160
160
|
* 纯重建体:只按活跃路径重折叠任务集(loadTasks)并回调 runtime 换 Map——不做
|
|
161
161
|
* 任何 sendMessage / appendEntry / tickTimer 启停(tick 常驻循环照常消费新任务集,
|
|
162
162
|
* 被撤子树任务不在集内即到点不触发,A14)。
|
|
@@ -215,14 +215,14 @@ export class PiSchedulerBackend implements SchedulerBackend {
|
|
|
215
215
|
*
|
|
216
216
|
* ctx.isIdle 缺失(最小 duck ctx)时保守返回 false(视作「有轮在跑」):ack 机制的
|
|
217
217
|
* fail-safe 方向是「不确定则不介入」——运行中的轮次会自然产出 assistant 消息、打开会话
|
|
218
|
-
*
|
|
218
|
+
* 落盘门禁(设计 §3.3 D4 / G4),而未知状态下注入合成轮反而可能吞掉用户消息。
|
|
219
219
|
*/
|
|
220
220
|
isIdle(): boolean {
|
|
221
221
|
return this.ctx.isIdle ? this.ctx.isIdle() : false
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
/**
|
|
225
|
-
*
|
|
225
|
+
* 会话当前模型的最小派生视图(**类方法,非接口成员**,理由同 isIdle);ctx.model 缺失/
|
|
226
226
|
* undefined(无模型会话)时返回 undefined。
|
|
227
227
|
*/
|
|
228
228
|
getCurrentModel(): SchedulerCurrentModel | undefined {
|
|
@@ -991,7 +991,7 @@ export class ScheduleCreateComponent implements Component {
|
|
|
991
991
|
this.done(result)
|
|
992
992
|
}
|
|
993
993
|
|
|
994
|
-
/** 取消。public 供 signal abort 监听复用 resolved
|
|
994
|
+
/** 取消。public 供 signal abort 监听复用 resolved 检查(abort 晚于用户操作时防二次 done)。 */
|
|
995
995
|
cancel(): void {
|
|
996
996
|
if (this.resolved) return
|
|
997
997
|
this.resolved = true
|
package/src/format.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 时间格式化器(r4 M1 locale 版):formatSchedule / formatRelativeTime 是 locale 必填的
|
|
2
2
|
// 本地实现(UiLocale 双语输出,i18n.ts / widget / service / commands 消费);fix 轨 locale
|
|
3
|
-
// 化与 U2 protocol 下沉(无 locale 面,服务 taiji plugin
|
|
3
|
+
// 化与 U2 protocol 下沉(无 locale 面,服务 taiji plugin 管理侧)暂双轨并存,收敛待后续
|
|
4
4
|
// 把 locale 版提升进 @zhushanwen/extension-protocol。formatDuration / MS_PER_* 单源
|
|
5
5
|
// @zhushanwen/extension-protocol(re-export 保持既有 import 路径)。
|
|
6
6
|
import { formatDuration, MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND } from './parsing.js'
|
package/src/i18n.ts
CHANGED
|
@@ -19,12 +19,12 @@ export { readUiLocale }
|
|
|
19
19
|
* - `t(key, params)`:单条词典模板
|
|
20
20
|
* - `renderResult(messageKey, params, locale)`:toast / 命令反馈(12+ 条命令反馈)
|
|
21
21
|
* - `renderTaskLine(taskParams, locale)`:**命令变体**任务行(状态●/○ + id + name + 摘要 +
|
|
22
|
-
* 相对时间 +
|
|
22
|
+
* 相对时间 + 执行状态摘要),调用点 = list 行 / toast 内部组合,`renderResult` 内部复用、
|
|
23
23
|
* 不重实现
|
|
24
24
|
* - `renderTaskLineStatic(taskParams, locale)`:**widget 静态变体**(状态●/○ + id + name +
|
|
25
25
|
* 静态调度描述,无任何 now 派生段),仅供 widget GUI body——两变体拆分是 scheduler
|
|
26
|
-
* widget 推送修正设计 D1-a
|
|
27
|
-
*
|
|
26
|
+
* widget 推送修正设计 D1-a 的渲染单点裁决:命令层是按需呈现侧(保留相对时间),widget
|
|
27
|
+
* 显示侧静态化是指纹跳推的前置
|
|
28
28
|
*
|
|
29
29
|
* L4(模型可见的 tool result)**不**经本模块——`service.ts` 的 `message` 字段保留英文回退。
|
|
30
30
|
*/
|
|
@@ -74,9 +74,9 @@ export type TaskParams = TaskScheduleParams & {
|
|
|
74
74
|
enabled: boolean
|
|
75
75
|
/**
|
|
76
76
|
* 执行状态摘要(可选;仅命令层 `task.list` 行尾消费,见 renderLastExecSummary)。
|
|
77
|
-
* widget 静态变体**不消费**本字段:摘要不在 widget
|
|
78
|
-
* 「显示已变、指纹未变」的冻结缺口(scheduler widget 推送修正设计 D1-a
|
|
79
|
-
*
|
|
77
|
+
* widget 静态变体**不消费**本字段:摘要不在 widget 指纹字段集内,进显示侧会造出
|
|
78
|
+
* 「显示已变、指纹未变」的冻结缺口(scheduler widget 推送修正设计 D1-a/不采用③);
|
|
79
|
+
* 失败可见性补偿只进命令查询结果(按需呈现,零推送成本)。
|
|
80
80
|
*/
|
|
81
81
|
lastExec?: { lastStatus: TaskStatus; recentFailures: number }
|
|
82
82
|
}
|
|
@@ -136,7 +136,7 @@ export type ServiceMessage<K extends ServiceMessageKey = ServiceMessageKey> = {
|
|
|
136
136
|
}[K]
|
|
137
137
|
|
|
138
138
|
/**
|
|
139
|
-
* messageKey
|
|
139
|
+
* messageKey 词表(机器化检查用:`service.test.ts` 断言「词典 key 集合 ⊇ 本表 + tray.title」)。
|
|
140
140
|
* RESULT_RENDERERS 的映射类型已提供编译期穷尽校验;本数组供测试与词典对账。
|
|
141
141
|
*/
|
|
142
142
|
export const SERVICE_MESSAGE_KEYS: readonly ServiceMessageKey[] = [
|
|
@@ -192,7 +192,7 @@ const ZH_CN: Dictionary = {
|
|
|
192
192
|
'task.notDispatched': '任务 {id} 未派发(已停用 / 触发受限 / 正在派发中)',
|
|
193
193
|
'task.limit': '任务数已达上限({max}),请先删除一个',
|
|
194
194
|
'schedule.invalid': '无法解析的时间表达式:{input}',
|
|
195
|
-
// 执行状态摘要(task.list 行尾;widget
|
|
195
|
+
// 执行状态摘要(task.list 行尾;widget 静态范围不带——scheduler widget 推送修正设计 D1)
|
|
196
196
|
// 失败可见性优先裁决:窗口内有失败即失败形态(n = 近期失败计数),否则成功形态
|
|
197
197
|
'task.lastOk': '上次: 成功',
|
|
198
198
|
'task.lastFailures': '上次: 失败×{n}',
|
|
@@ -212,14 +212,14 @@ const ZH_CN: Dictionary = {
|
|
|
212
212
|
|
|
213
213
|
// ack 确认轮(合成确认行 + 落盘失败如实文案)
|
|
214
214
|
// 措辞边界:只承诺「写入会话文件」(本机制的唯一保证),不得写「已持久化 / 已保存到磁盘」
|
|
215
|
-
// 之类存储级承诺——ack.notPersisted
|
|
215
|
+
// 之类存储级承诺——ack.notPersisted 是失败情形,confirm 是成功情形,两者都不越界。
|
|
216
216
|
'ack.confirm': '已保存任务:{name}({schedule})。',
|
|
217
217
|
'ack.notPersisted': '已创建任务 {name},但未写入会话文件。在本会话说一句话即可保存。',
|
|
218
218
|
|
|
219
219
|
// 托盘标题(扩展自产,宿主零改动)
|
|
220
220
|
'tray.title': '定时任务',
|
|
221
221
|
|
|
222
|
-
// TUI widget
|
|
222
|
+
// TUI widget 文本行(静态范围:任务名 + 静态调度描述,无相对时间/逾期段——D1 静态化)
|
|
223
223
|
'widget.title': '定时任务',
|
|
224
224
|
'widget.count': '{n} 条',
|
|
225
225
|
'widget.task': '{name} {schedule}',
|
|
@@ -264,7 +264,7 @@ const EN_US: Dictionary = {
|
|
|
264
264
|
// 托盘标题(扩展自产,宿主零改动)
|
|
265
265
|
'tray.title': 'Scheduled tasks',
|
|
266
266
|
|
|
267
|
-
// TUI widget
|
|
267
|
+
// TUI widget 文本行(静态范围:任务名 + 静态调度描述,无相对时间/逾期段——D1 静态化)
|
|
268
268
|
'widget.title': 'scheduler',
|
|
269
269
|
'widget.count': '{n} scheduled',
|
|
270
270
|
'widget.task': '{name} {schedule}',
|
|
@@ -285,7 +285,7 @@ export function dictionaryKeys(locale: UiLocale): string[] {
|
|
|
285
285
|
|
|
286
286
|
/**
|
|
287
287
|
* 渲染词典模板。键缺失 → 回落 en 值 → 再回落键名本身(**禁返回空串**,设计 r4 S1):
|
|
288
|
-
* 所有经 `t()` 赋值的字段因此天然非空——防「空标题在 nullish
|
|
288
|
+
* 所有经 `t()` 赋值的字段因此天然非空——防「空标题在 nullish 渲染范围显示空白段头」
|
|
289
289
|
* 从词典后门回潮(`meta.title` 依赖本保证)。占位符缺参时保留 `{name}` 原样(非空)。
|
|
290
290
|
*/
|
|
291
291
|
export function t(key: string, params?: Record<string, string | number>, locale?: UiLocale): string {
|
|
@@ -302,10 +302,10 @@ function interpolate(template: string, params?: Record<string, string | number>)
|
|
|
302
302
|
})
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
// ──
|
|
305
|
+
// ── 形状映射 ──
|
|
306
306
|
|
|
307
307
|
/**
|
|
308
|
-
*
|
|
308
|
+
* 执行状态摘要派生视图(ScheduledTask.history/lastStatus → TaskParams.lastExec 单点):
|
|
309
309
|
* recentFailures = history 定长窗口(HISTORY_LIMIT=20)内失败计数;无任何执行记录
|
|
310
310
|
* (history 空且 lastStatus 缺)→ undefined = 行尾不显示摘要段。
|
|
311
311
|
* 失败记账为进程内存态(失败 dispatch 无 jsonl op),跨 resume/重启后计数归零、
|
|
@@ -317,7 +317,7 @@ function toLastExec(task: ScheduledTask): TaskParams['lastExec'] {
|
|
|
317
317
|
return { lastStatus: task.lastStatus ?? 'success', recentFailures }
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
/** ScheduledTask → locale-neutral TaskParams(service 与 widget
|
|
320
|
+
/** ScheduledTask → locale-neutral TaskParams(service 与 widget 共用同一派生视图,防双口径)。 */
|
|
321
321
|
export function toTaskParams(task: ScheduledTask, now: number): TaskParams {
|
|
322
322
|
const base = {
|
|
323
323
|
id: task.id,
|
|
@@ -327,7 +327,7 @@ export function toTaskParams(task: ScheduledTask, now: number): TaskParams {
|
|
|
327
327
|
now,
|
|
328
328
|
expiresAt: task.expiresAt,
|
|
329
329
|
enabled: task.enabled,
|
|
330
|
-
//
|
|
330
|
+
// 摘要随派生视图携带;仅命令变体消费(widget 静态变体忽略——不在指纹字段集内)
|
|
331
331
|
lastExec: toLastExec(task),
|
|
332
332
|
}
|
|
333
333
|
return task.schedule.mode === 'interval'
|
|
@@ -345,7 +345,7 @@ function toScheduleSpec(task: TaskParams): ScheduleSpec {
|
|
|
345
345
|
// ── 三个渲染入口 ──
|
|
346
346
|
|
|
347
347
|
/**
|
|
348
|
-
* 执行状态摘要段(task.list 行尾,D1 失败可见性补偿;widget
|
|
348
|
+
* 执行状态摘要段(task.list 行尾,D1 失败可见性补偿;widget 静态范围不带)。
|
|
349
349
|
* 失败可见性优先:窗口内有失败即失败形态(`上次: 失败×n`,n = 近期失败计数——即使最近
|
|
350
350
|
* 一次执行成功,窗口内存在失败也须让用户看见,否则静默失败不可发现与 G3 语义冲突);
|
|
351
351
|
* 无失败但有记录 → 成功形态;无任何记录 → 空串(行尾不追加段)。
|
|
@@ -362,7 +362,7 @@ function renderLastExecSummary(lastExec: TaskParams['lastExec'], locale: UiLocal
|
|
|
362
362
|
* `{状态} {id} {name} · {摘要} · {相对时间}[ · {执行状态摘要}]`——状态由 `enabled` 在
|
|
363
363
|
* **代码**派生(r5 追补:不得把 ●/○ 做成词典固定字面量,否则停用任务本地化后会显示为
|
|
364
364
|
* 启用);摘要/相对时间走带 locale 的格式化器现算(params 只携带原始值)。
|
|
365
|
-
*
|
|
365
|
+
* 相对时间**保留**:命令层是按需呈现侧(用户主动触发,无推送放大成本),schedule tool
|
|
366
366
|
* 的「next run time」承诺依赖它(scheduler widget 推送修正设计 D1-a)。
|
|
367
367
|
*/
|
|
368
368
|
export function renderTaskLine(taskParams: TaskParams, locale: UiLocale): string {
|
|
@@ -378,7 +378,7 @@ export function renderTaskLine(taskParams: TaskParams, locale: UiLocale): string
|
|
|
378
378
|
/**
|
|
379
379
|
* **widget 静态变体**任务行(仅供 widget GUI body,`buildSchedulerWidgetItems` 单点消费):
|
|
380
380
|
* `{状态} {id} {name} · {静态调度描述}`——无任何 now 派生段(相对时间/逾期标记/执行摘要
|
|
381
|
-
* 都不进 widget
|
|
381
|
+
* 都不进 widget 行),显示侧 = f(稳定字段, locale)。这是 index.ts 任务集指纹跳推的完备性
|
|
382
382
|
* 前提(D1-a):now 派生元素会造出「显示已变而指纹不变」的冻结缺口(P6 反例基线)。
|
|
383
383
|
*/
|
|
384
384
|
export function renderTaskLineStatic(taskParams: TaskParams, locale: UiLocale): string {
|
package/src/importer.ts
CHANGED
|
@@ -117,7 +117,7 @@ function importFromFile(
|
|
|
117
117
|
// appendMessage 触发 _persist 全量落盘)与 session_shutdown 时重复调用——flush 后 sessionFile
|
|
118
118
|
// 出现 → 删;仍未 flush → 静默保留 .imported 供下次 session_start 的 handleImportedResidue
|
|
119
119
|
// 崩溃恢复重导入。跨 session 重导入窗口 = session_start → 首个 turn_end(秒级):turn_end 后
|
|
120
|
-
// .imported 已删,后续 session 启动看不到残留 →
|
|
120
|
+
// .imported 已删,后续 session 启动看不到残留 → 双导入窗口封住(R-CONCURRENT-IMPORT 已更新)
|
|
121
121
|
logger.warn('imported legacy tasks (session not flushed; deferring removal)', {
|
|
122
122
|
count: tasks.length,
|
|
123
123
|
path: importedPath,
|
package/src/index.ts
CHANGED
|
@@ -48,7 +48,7 @@ import type { ScheduledTask } from './types.js'
|
|
|
48
48
|
// STALE_CTX_MARKER 文案兜底(F2 catch 分诊)。
|
|
49
49
|
let sessionGeneration = 0
|
|
50
50
|
|
|
51
|
-
/** 包内既有 logger(ack
|
|
51
|
+
/** 包内既有 logger(ack 编排的日志侧与其它模块同源)。 */
|
|
52
52
|
const logger = getLogger('scheduler')
|
|
53
53
|
|
|
54
54
|
/**
|
|
@@ -62,7 +62,7 @@ const logger = getLogger('scheduler')
|
|
|
62
62
|
export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
63
63
|
let service: SchedulerService | null = null
|
|
64
64
|
// U6c:当代 backend(session_start 装配点更新)。factory 顶层 session_tree handler
|
|
65
|
-
//
|
|
65
|
+
// 经它委托当代重折叠(注册点理由见下方 session_tree 注册点注释)。
|
|
66
66
|
let activeBackend: PiSchedulerBackend | null = null
|
|
67
67
|
// IMPORT-FLUSH-GUARD(MF-1):importLegacyStore 对未 flush 的新 session 返回延迟删除 .imported
|
|
68
68
|
// 的 cleanup——turn_end / session_shutdown 时执行:确认 flush(sessionFile 已出现)则删,
|
|
@@ -110,7 +110,7 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
110
110
|
// F2 catch 分诊判定 stale——不依赖 pi 错误文案。
|
|
111
111
|
// 投递模型(scheduler-steer-direct-dispatch):到期任务 steer 直投(backend.sendMessage),
|
|
112
112
|
// 受理即记账,不经投递内核。
|
|
113
|
-
// U4
|
|
113
|
+
// U4 模型控制接口(设计 D3):runtime 只见 'provider/id' ref(与 task.model 同域),pi 的
|
|
114
114
|
// Model 对象解析封在本闭包。setModelByRef 契约 = 不 throw、失败返回 false(ref 非法 /
|
|
115
115
|
// modelRegistry.find 无命中 / pi setModel false / 意外异常统一折叠)。
|
|
116
116
|
const modelOps: SchedulerModelOps = {
|
|
@@ -155,7 +155,7 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
155
155
|
ackController.handleSessionBoundary()
|
|
156
156
|
|
|
157
157
|
service = new SchedulerService(runtime, () => backend.now(), (task) => {
|
|
158
|
-
// 创建汇聚点 → ack 触发器(fire-and-forget:回调不 await
|
|
158
|
+
// 创建汇聚点 → ack 触发器(fire-and-forget:回调不 await,失败仅日志侧)。
|
|
159
159
|
// 唯一生产开关 TAIJI_SCHED_ACK_DISABLE 在此单点读取(fail-safe 方向:缺省不禁用)。
|
|
160
160
|
const controller = ackController
|
|
161
161
|
if (!controller) return
|
|
@@ -227,7 +227,7 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
227
227
|
pi.on('agent_settled', () => service?.runtime.handleRunSettled())
|
|
228
228
|
|
|
229
229
|
pi.on('session_shutdown', async () => {
|
|
230
|
-
//
|
|
230
|
+
// 命令路径挂起表单的收尾(设计 §6.2 生命周期案 ①②③):session_shutdown
|
|
231
231
|
//(reason ∈ quit/reload/new/resume/fork)→ abort 挂起的表单交互(不创建、不 toast)。
|
|
232
232
|
// taiji 内「切到另一会话」不触发本事件,表单保留且仍有效(有意行为)。
|
|
233
233
|
abortPendingScheduleForms()
|
|
@@ -349,7 +349,7 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
349
349
|
try {
|
|
350
350
|
fingerprint = computeTasksFingerprint(tasks, locale)
|
|
351
351
|
} catch (err) {
|
|
352
|
-
// fail-open
|
|
352
|
+
// fail-open(辅助显示侧的降级 ≠ 吞错):指纹异常说明序列化路径有 bug,跳推判定不可信
|
|
353
353
|
// → 直接推送保显示正确;缓存失效(null)使下一帧也必推。
|
|
354
354
|
logger.warn('widget fingerprint computation failed, pushing anyway', { error: toErrorMessage(err) })
|
|
355
355
|
lastWidgetFingerprint = null
|
package/src/interaction.ts
CHANGED
|
@@ -245,7 +245,7 @@ function parseScheduleFormAnswerValue(rawResult: unknown): ScheduleFormResult {
|
|
|
245
245
|
// ── 命令路径入口(异步打开)──
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
|
-
* 命令路径表单入口:打开创建表单,提交后经 service.create
|
|
248
|
+
* 命令路径表单入口:打开创建表单,提交后经 service.create 写入。
|
|
249
249
|
*
|
|
250
250
|
* **契约:返回的 Promise 永不 reject**(内部错误自 catch → `ctx.ui.notify(..., 'error')`;
|
|
251
251
|
* 挂起期间 `session_shutdown` abort → 取消收尾)。因此调用点的 `void openScheduleFormAsync(...)`
|
package/src/runtime.ts
CHANGED
|
@@ -33,7 +33,7 @@ const DEFAULT_EXPIRY_MS = DEFAULT_EXPIRY_DAYS * MS_PER_DAY // 7 days
|
|
|
33
33
|
const DISPATCH_CUSTOM_TYPE_PREFIX = 'pi-scheduler:'
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
36
|
+
* 模型控制接口(U4 dispatch 模型切换,依赖反转):runtime 只见 'provider/id' 字符串 ref
|
|
37
37
|
* (与 ScheduledTask.model 同域,tool.ts draft 组装 modelRef 的产出形态),pi 的 Model
|
|
38
38
|
* 对象解析(modelRegistry.find → setModel)封在生产装配闭包(index.ts session_start)。
|
|
39
39
|
*
|
|
@@ -53,7 +53,7 @@ export interface SchedulerModelOps {
|
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* 未决模型切换记录(纯内存态)。模型语义异常不进持久化词表
|
|
56
|
-
* (replay
|
|
56
|
+
* (replay 检查对 advance.status 硬校验 'success',旁路字段会炸重放推进)。
|
|
57
57
|
*
|
|
58
58
|
* 生命周期(ADR-0122 事实查询翻转后):dispatch 受理 → 记录创建(in-flight 等价语义:
|
|
59
59
|
* 等 run 落定);恢复兑现锚点 = isIdle() 同步事实查询(agent_settled 事件提前触发 +
|
|
@@ -68,7 +68,7 @@ interface PendingModelSwitch {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
// HISTORY_LIMIT 单点在 types.ts(ext-simplify-08 L5)——与 replay.ts 的 advance 折叠共用
|
|
71
|
-
// STALE_CTX_MARKER(文案兜底分诊词)已迁移到 ext-guards
|
|
71
|
+
// STALE_CTX_MARKER(文案兜底分诊词)已迁移到 ext-guards 共享检查(guardStaleCtx 内部
|
|
72
72
|
// 引用,本文件不再直接持有)。语义:G1 模块级代际检测(isCtxStale)为主判;文案子串
|
|
73
73
|
// 覆盖代际盲区——显式 reload / cwd 变化触发 clearExtensionCache 后 jiti 重新 import 产生
|
|
74
74
|
// 全新模块环境,旧闭包引用的模块级代数冻结不再递增,isCtxStale 恒 false,只剩错误文案
|
|
@@ -85,14 +85,14 @@ export class SchedulerRuntime {
|
|
|
85
85
|
private readonly isCtxStale: (() => boolean) | undefined
|
|
86
86
|
// R3-S1:同任务 dispatch 在途标记(Set<taskId>),见 dispatchTask 注释
|
|
87
87
|
private readonly dispatchesInFlight = new Set<string>()
|
|
88
|
-
// U4 dispatch 模型切换(设计 D3
|
|
88
|
+
// U4 dispatch 模型切换(设计 D3 修订版):模型控制接口(缺省 undefined = 无切换能力,降级)
|
|
89
89
|
private readonly modelOps: SchedulerModelOps | undefined
|
|
90
90
|
// 未决模型切换记录(同时至多一条——互斥窗口 + 模型 op 串行队列共同保证「切换→dispatch→
|
|
91
91
|
// 恢复」周期单在途、单 setModel 在途)
|
|
92
92
|
private pendingModelSwitch: PendingModelSwitch | null = null
|
|
93
|
-
// 模型 op 串行队列尾(MF-2
|
|
93
|
+
// 模型 op 串行队列尾(MF-2 收敛):调度器发起的全部 setModel(dispatch 切换 / 期望模型
|
|
94
94
|
// 恢复 / 错误路径恢复)单点经 serializeModelOp 入队——「setModel 起步 → 记录创建 /
|
|
95
|
-
//
|
|
95
|
+
// 恢复收尾」全程持队,任何后继 setModel 发起点先排队前置等待,两个 setModel 并发
|
|
96
96
|
// 结构性不可能(完成顺序不定 → turn 静默跑错模型的交错被消除)。链条引用恒吞错
|
|
97
97
|
// 自愈(见 serializeModelOp),前序 op 失败不阻断后继。
|
|
98
98
|
private modelOpChain: Promise<void> = Promise.resolve()
|
|
@@ -105,7 +105,7 @@ export class SchedulerRuntime {
|
|
|
105
105
|
* 替换。index.ts 装配点注入(模块级代数比对,R3-M1),使 stale 分诊不依赖 pi 错误文案;
|
|
106
106
|
* 缺省(不注入)恒视为非 stale——纯 runtime 单测与旧装配路径行为不变。
|
|
107
107
|
*
|
|
108
|
-
* modelOps(U4
|
|
108
|
+
* modelOps(U4):模型控制接口,session_start 装配点注入;缺省 = 无模型切换能力
|
|
109
109
|
* (task.model 非空时降级照常 dispatch)。
|
|
110
110
|
*/
|
|
111
111
|
constructor(backend: SchedulerBackend, isCtxStale?: () => boolean, modelOps?: SchedulerModelOps) {
|
|
@@ -240,13 +240,13 @@ export class SchedulerRuntime {
|
|
|
240
240
|
startScheduler(): void {
|
|
241
241
|
if (this.tickTimer) return
|
|
242
242
|
this.tickTimer = setInterval(() => {
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// 1. G1 前置检查(S9
|
|
243
|
+
// 三件套语义等价迁移到共享检查 guardStaleCtx(crash-resilience D1 / u1-ext-guard;
|
|
244
|
+
// 检查语义 = 本处原地实现的泛化,迁移对照逐条可证):
|
|
245
|
+
// 1. G1 前置检查(S9):检查的 isCtxStale 命中 → onStale(= retireStaleTimer)且
|
|
246
246
|
// tickScheduler 不执行——与迁移前「前置分支 return」等价:本 runtime 所属 session
|
|
247
247
|
// 已被替换 → timer 属泄漏资源自停退场,不触碰捕获的 stale ctx,不依赖 pi 错误文案。
|
|
248
248
|
// 主防线仍是 F1(session_start 停旧 timer),此处覆盖 F1 未能触达的泄漏路径。
|
|
249
|
-
// 2. F2 catch
|
|
249
|
+
// 2. F2 catch 分诊(防御兜底):检查对 tickScheduler 的 rejection 挂同一分诊谓词
|
|
250
250
|
// `isCtxStale() || 文案含 STALE_CTX_MARKER`(字面不变)——stale → retireStaleTimer;
|
|
251
251
|
// 非 stale → 原样 reject,由下方 .catch 仅告警不终止调度('tick error' 文案不变,
|
|
252
252
|
// U2/G1-c 锚定)。fire-and-forget 的 tick 链路必须自带 catch——tick 内任何异常
|
|
@@ -254,7 +254,7 @@ export class SchedulerRuntime {
|
|
|
254
254
|
// 即 unhandledRejection,直接崩掉 pi 主进程(E1 同机制)。
|
|
255
255
|
// 3. retireStaleTimer 自停:未改('tick stopped' warn 口径与幂等 stopScheduler 原样,
|
|
256
256
|
// U1/G1-b/G1-d 锚定)。
|
|
257
|
-
// `?.catch
|
|
257
|
+
// `?.catch`:前置检查命中时检查返回 undefined(fn 未执行、无 Promise、无 rejection
|
|
258
258
|
// 可接——retire 已由 onStale 完成);非 stale 时返回 Promise,非 stale rejection
|
|
259
259
|
// 流到 .catch 仅告警。
|
|
260
260
|
void guardStaleCtx(() => this.tickScheduler(), {
|
|
@@ -298,7 +298,7 @@ export class SchedulerRuntime {
|
|
|
298
298
|
// 循环才开 setModel;更广的「任意两个 setModel 不并发」由模型 op 串行队列(modelOpChain)
|
|
299
299
|
// 结构保证——含 agent_settled 恢复 fire-and-forget 挂点,dispatch 模型分支排在其后。
|
|
300
300
|
// 无未决记录(或非 idle)的绝大多数 tick 同步返回、不引入 microtask 断点——dispatch
|
|
301
|
-
// 的同步段契约(in-flight
|
|
301
|
+
// 的同步段契约(in-flight 检查在 tickScheduler() 未 await 时同步可见)保持不变。
|
|
302
302
|
const settling = this.settlePendingIfIdle('tick-reconcile')
|
|
303
303
|
if (settling) await settling
|
|
304
304
|
|
|
@@ -343,7 +343,7 @@ export class SchedulerRuntime {
|
|
|
343
343
|
* dispatch 单个任务。返回 true 表示消息已发出(pi.sendMessage 受理),false 表示
|
|
344
344
|
* no-op(task disabled / 已有同任务在途 / rate-limited)。
|
|
345
345
|
*
|
|
346
|
-
* R3-S1 in-flight
|
|
346
|
+
* R3-S1 in-flight 检查:tick 为 fire-and-forget,若 tick1 的 `await backend.sendMessage`
|
|
347
347
|
* 挂起超过 TICK_INTERVAL_MS(如 pi 卡死),tick2 的 step2 会再标 pending、step3 对同一
|
|
348
348
|
* task 并发第二个 dispatch → 同一 prompt 双注入。参照 subagent-workflow resumesInFlight
|
|
349
349
|
* 模式:入口同步置位、finally 清除(覆盖 gate / rate-limit / sendMessage 抛错 / 成功推进
|
|
@@ -365,8 +365,8 @@ export class SchedulerRuntime {
|
|
|
365
365
|
}
|
|
366
366
|
|
|
367
367
|
/**
|
|
368
|
-
* dispatch 本体(dispatchTask
|
|
369
|
-
* 手动 run-now 与挂起中的 tick dispatch
|
|
368
|
+
* dispatch 本体(dispatchTask 检查置位后执行;runTaskNow 与 tick step3 共用入口,
|
|
369
|
+
* 手动 run-now 与挂起中的 tick dispatch 并发时同样被检查拦截)。
|
|
370
370
|
* steer 直投(scheduler-steer-direct-dispatch 设计):{deliverAs:'steer', triggerTurn:true}
|
|
371
371
|
* 在 pi 侧的两分支——busy 时 steer 插入当前 turn(立即被模型看到)、idle 时开新 turn。
|
|
372
372
|
* 受理即记账:pi extension API sendMessage 是 fire-and-forget(返回 void,错误走
|
|
@@ -403,15 +403,15 @@ export class SchedulerRuntime {
|
|
|
403
403
|
// skip 本轮 + pending 留待下 tick 重试(复用现状 skip 语义,不建真队列;不需切模型
|
|
404
404
|
// 的任务不受互斥影响——检查只在本分支)。「dispatch 写新记录前先结算旧记录」
|
|
405
405
|
// 的落地:本分支结构性永不写新记录(杜绝新旧叠写),skip 前做一次 isIdle() 事实
|
|
406
|
-
// 查询结算(idle
|
|
406
|
+
// 查询结算(idle 即恢复收尾),保证新任务不被旧记录无限阻塞。
|
|
407
407
|
// 结算的恢复必须 await(顺序约束,与 tickScheduler 的结算 await 同构):恢复
|
|
408
408
|
// setModel(原) 完成后才放行 skip——恢复与紧随的需切模型任务 setModel(目标) 不得
|
|
409
409
|
// 并发(串行由模型 op 队列结构保证,本 await 是调用方顺序语义:skip 放行前恢复
|
|
410
|
-
//
|
|
410
|
+
// 已收尾);本分支随后即 skip,await 无额外延迟代价。
|
|
411
411
|
await this.settlePendingIfIdle('mutex-settle')
|
|
412
412
|
skipped = true
|
|
413
413
|
} else {
|
|
414
|
-
// 模型 op 串行段(MF-2
|
|
414
|
+
// 模型 op 串行段(MF-2 收敛,互斥窗口全程覆盖「setModel 起步 → 记录创建」):
|
|
415
415
|
// 前序模型 op(agent_settled 恢复 / 前序 dispatch 切换 / 错误路径恢复)可能仍在途——
|
|
416
416
|
// 排队等待期间未决记录与当前模型均可能变化,禁止按排队前快照盲切(旧快照的
|
|
417
417
|
// expectedModelRef 会把恢复锚点记错),串行段内重新校验后再切换。后来的需切模型
|
|
@@ -546,20 +546,20 @@ export class SchedulerRuntime {
|
|
|
546
546
|
* 未决模型切换记录的事实查询结算:记录存在 + 会话 idle(isIdle() 同步查询,
|
|
547
547
|
* 判定层级最高的事实来源)→ 兑现恢复;非 idle → 不动作(不能把在途 run 的模型
|
|
548
548
|
* 换掉),记录保留等下一查询点。无记录时同步返回 undefined(调用方跳过 await,
|
|
549
|
-
* 保住 tick 无记录路径的同步契约——dispatch 的 in-flight
|
|
549
|
+
* 保住 tick 无记录路径的同步契约——dispatch 的 in-flight 检查在 tickScheduler()
|
|
550
550
|
* 未 await 时同步可见)。
|
|
551
551
|
*
|
|
552
552
|
* 调用点两处:① tickScheduler 开头(严格先于 dispatch 循环——顺序约束:否则
|
|
553
553
|
* 「B dispatch 先切 Y → 对账持 A 旧记录把 Y 静默切回」;agent_settled 事件丢失时
|
|
554
554
|
* 本查询点是正确性兜底,事件只作提前触发加速器);② dispatchTaskInner 互斥分支
|
|
555
|
-
* (skip
|
|
555
|
+
* (skip 前尽量把旧记录收尾,恢复 setModel 与后续新任务的 setModel(目标) 不得并发
|
|
556
556
|
* ——串行由模型 op 队列结构保证,本 await 是调用方顺序语义)。
|
|
557
557
|
*/
|
|
558
558
|
private settlePendingIfIdle(reason: string): Promise<void> | undefined {
|
|
559
559
|
const ps = this.pendingModelSwitch
|
|
560
560
|
if (!ps) return undefined
|
|
561
561
|
if (this.isCtxStale?.()) {
|
|
562
|
-
// stale 代际失效(session 替换):未决记录随代际丢弃,不触碰旧 ctx
|
|
562
|
+
// stale 代际失效(session 替换):未决记录随代际丢弃,不触碰旧 ctx(同现有检查路径)
|
|
563
563
|
this.pendingModelSwitch = null
|
|
564
564
|
return undefined
|
|
565
565
|
}
|
|
@@ -586,7 +586,7 @@ export class SchedulerRuntime {
|
|
|
586
586
|
}
|
|
587
587
|
|
|
588
588
|
/**
|
|
589
|
-
*
|
|
589
|
+
* 执行恢复(切回会话期望模型)并关闭未决记录(在途标记生命周期收尾):
|
|
590
590
|
* 当前模型已等于期望(用户已手动切回)→ 清记录不动作;setModel(原) false →
|
|
591
591
|
* restore-failed 终态(日志含恢复动作:会话停留在任务模型,需手动切回)+ 清记录。
|
|
592
592
|
* 记录先关后恢复——防 await setModel 期间 agent_settled 与 tick 对账并发重入。
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// entry 契约与折叠器单源于 @zhushanwen/extension-protocol(U2 折叠器下沉:扩展与 taiji plugin
|
|
2
2
|
// 同源消费,杜绝双实现漂移)。本文件保持既有 import 路径不变(re-export),仅保留
|
|
3
|
-
//
|
|
3
|
+
// 扩展侧写侧私有物(toTaskSnapshot / 旧 store 形状 / 工具参数 / ack 机制类型)。
|
|
4
4
|
|
|
5
5
|
// ack 覆写 config 类型同源自 pi(文件尾 SchedulerCurrentModel / SchedulerProviderOverride 消费)
|
|
6
6
|
import type { ProviderConfig } from '@earendil-works/pi-coding-agent'
|
|
@@ -21,7 +21,7 @@ export type {
|
|
|
21
21
|
TaskStatus,
|
|
22
22
|
} from '@zhushanwen/extension-protocol'
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// 本地保留范围(写侧 helper / 旧 store 形状 / 工具参数)签名引用的类型——re-export 不引入作用域,需显式 import
|
|
25
25
|
import type { ScheduledTask, TaskKind, TaskSnapshot } from '@zhushanwen/extension-protocol'
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -44,7 +44,7 @@ export function toTaskSnapshot(task: ScheduledTask): TaskSnapshot {
|
|
|
44
44
|
export interface SchedulerStore {
|
|
45
45
|
/**
|
|
46
46
|
* 形状忠实保留(ext-simplify-08 L8):旧 store 文件(npm 0.1.1 store.ts)顶层携带
|
|
47
|
-
* version:1,importer 只读 tasks、version
|
|
47
|
+
* version:1,importer 只读 tasks、version 零读点——字段是磁盘格式的文档而非消费方,
|
|
48
48
|
* 删除会让类型与迁移源文件的真实形状静默漂移。
|
|
49
49
|
*/
|
|
50
50
|
version: 1
|
|
@@ -102,7 +102,7 @@ export type AckUnavailableReason = 'no-base' | 'toggle-disabled' | 'check-failed
|
|
|
102
102
|
*
|
|
103
103
|
* 其余形态(e1-register / e2-not-hit / e4-provider-error / e5-interrupted /
|
|
104
104
|
* e6-unregister / e8b-hybrid)**不发用户通知**——它们都意味着真实轮已应答或与落盘无关,
|
|
105
|
-
*
|
|
105
|
+
* 发「未写入」即反向撒谎;可观测性由日志侧承担(字符串标签,不实体化为类型成员)。
|
|
106
106
|
* 把「可通知」收窄成类型本身,反向撒谎禁令就成了编译期约束:调用点无法传入不通知的形态。
|
|
107
107
|
* 错误语义的完整 SSOT = 设计 scheduler-command-path-persistence §3.4。
|
|
108
108
|
*/
|
|
@@ -121,7 +121,7 @@ export interface AckState {
|
|
|
121
121
|
window: { providerId: string } | null
|
|
122
122
|
/** ack 轮是否已启动(session 边界写盘判定的判据之一:文件不存在且未启动才补发告警) */
|
|
123
123
|
ackTurnStarted: boolean
|
|
124
|
-
/** 本次 ack 触发的任务 id(session 边界通知拼去重键用);null = 未发起过 ack。注意:状态为模块级单例,session_start 时它可能是上一会话的残留——故所有读点都必须先过 taskId !== null
|
|
124
|
+
/** 本次 ack 触发的任务 id(session 边界通知拼去重键用);null = 未发起过 ack。注意:状态为模块级单例,session_start 时它可能是上一会话的残留——故所有读点都必须先过 taskId !== null 且由边界清理收尾 */
|
|
125
125
|
taskId: string | null
|
|
126
126
|
/** 本次 ack 触发的任务名(如实通知文案插值;taskId 为兜底) */
|
|
127
127
|
taskName: string
|
|
@@ -144,7 +144,7 @@ export interface AckState {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
|
-
*
|
|
147
|
+
* 会话当前模型的最小派生视图(`SchedulerBackend.getCurrentModel()` 返回值)。
|
|
148
148
|
*
|
|
149
149
|
* 只取 ack 合成轮所需三字段:`api`(registerProvider 覆写 config.api)、`provider`
|
|
150
150
|
* (覆写目标 provider id)、`id`(合成 assistant 行的 model 字段)。`api` 用
|
|
@@ -158,7 +158,7 @@ export interface SchedulerCurrentModel {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
|
-
* `registerProvider` 的覆写 config
|
|
161
|
+
* `registerProvider` 的覆写 config 最小子集:ack 合成轮只带 `api` + `streamSimple`,不带
|
|
162
162
|
* `models`(覆写不得改变 provider 的模型清单)。
|
|
163
163
|
*
|
|
164
164
|
* 不重定义字段——直接 Pick 自 pi 的 `ProviderConfig`,与 `pi.registerProvider` 的实参类型
|
package/src/widget.ts
CHANGED
|
@@ -22,9 +22,9 @@ const WIDGET_NAME_MAX_WIDTH = 20
|
|
|
22
22
|
* `readUiLocale()` 解析后传入,再经 `setSchedulerWidget` 双模推送)。
|
|
23
23
|
* 格式:[定时任务] 3 条 · check-build 每 5 分钟(en: [scheduler] 3 scheduled · …)
|
|
24
24
|
*
|
|
25
|
-
*
|
|
25
|
+
* 静态范围契约(scheduler widget 推送修正设计 D1-a):行内容只由任务集稳定字段 + locale
|
|
26
26
|
* 派生,**无任何 now 参与的元素**——相对时间与「[!] n 条已逾期」标记已整体移除(时间流逝
|
|
27
|
-
*
|
|
27
|
+
* 不是状态变化,静态范围恒定是 index.ts 指纹跳推「无变化零推送」的前提)。
|
|
28
28
|
* 「最近任务」= nextRunAt 升序首个**启用**任务、**不用 now 过滤**:若保留
|
|
29
29
|
* `nextRunAt > now` 过滤,任务 dispatch 失败未推进 nextRunAt 时越过时间点后「最近任务」
|
|
30
30
|
* 会切换——显示变化而指纹不变,重新引入冻结缺口(设计 D1-a 反例论证)。
|
|
@@ -58,7 +58,7 @@ export function renderSchedulerWidgetTui(tasks: ScheduledTask[], locale: UiLocal
|
|
|
58
58
|
/**
|
|
59
59
|
* GUI 臂结构化载荷(设计 §6.8 D8):body = 逐任务 list-tree(行文本走 `renderTaskLineStatic`
|
|
60
60
|
* 静态变体单点),head 元数据由宿主壳层渲染——**标题由扩展自产**(`meta.title = t('tray.title')`),
|
|
61
|
-
*
|
|
61
|
+
* 宿主零改动(三处渲染范围:前两面 trim / `TrayAggregatePanel` nullish,非空标题均正确)。
|
|
62
62
|
*
|
|
63
63
|
* icon 点名 lucide key 'clock'(与设计 §6.8 一致);badge = 启用任务数;status 两态 =
|
|
64
64
|
* 有启用任务 running / 空 idle(D1-a:「有逾期 → failed」翻牌已移除——逾期是 now 派生
|
|
@@ -82,7 +82,7 @@ export function buildSchedulerWidgetGui(tasks: ScheduledTask[], locale: UiLocale
|
|
|
82
82
|
/**
|
|
83
83
|
* GUI body 逐任务行(复用 renderTaskLineStatic 静态变体单点;导出供测试直接断言行文本)。
|
|
84
84
|
* `now` 形参仅为满足 toTaskParams 的形状必填字段(命令变体的相对时间基准),静态变体
|
|
85
|
-
*
|
|
85
|
+
* **不消费**它——保留形参以维持与命令派生视图同一形状来源,防双口径。
|
|
86
86
|
*/
|
|
87
87
|
export function buildSchedulerWidgetItems(
|
|
88
88
|
tasks: ScheduledTask[],
|
|
@@ -108,7 +108,7 @@ function widgetStatus(enabledCount: number): WidgetMeta['status'] {
|
|
|
108
108
|
* 否则冻结缺口静默回归。
|
|
109
109
|
*
|
|
110
110
|
* kind 显式入指纹:行文本含 `formatSchedule(spec, kind, locale)`,kind 是显示决定因素
|
|
111
|
-
* (当前无 update op 不可变,by-construction
|
|
111
|
+
* (当前无 update op 不可变,by-construction 论证仍须显式补全);locale 显式入指纹:
|
|
112
112
|
* 切语言一次推送自愈。
|
|
113
113
|
*/
|
|
114
114
|
export function computeTasksFingerprint(tasks: ScheduledTask[], locale: UiLocale): string {
|