@zhushanwen/pi-scheduler 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/src/__tests__/U4-AFTER_RUN_INTENT.test.ts +99 -0
- package/src/__tests__/U4-DISPATCH_INFLIGHT.test.ts +109 -0
- package/src/__tests__/U4-ONSETTLED.test.ts +124 -0
- package/src/__tests__/U4-PARK_GATE.test.ts +206 -0
- package/src/__tests__/importer.test.ts +23 -9
- package/src/__tests__/index-generation.test.ts +14 -5
- package/src/__tests__/replay.test.ts +19 -12
- package/src/__tests__/runtime.test.ts +239 -41
- package/src/__tests__/service.test.ts +6 -9
- package/src/backend.ts +22 -1
- package/src/importer.ts +12 -10
- package/src/index.ts +48 -0
- package/src/replay.ts +7 -7
- package/src/runtime.ts +124 -29
package/src/backend.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { DeliveryHandle } from '@xyz-agent/session-delivery'
|
|
2
3
|
|
|
3
4
|
import { replayFoldEntries, type SchedulerEntryLike } from './replay.js'
|
|
4
5
|
import type { ScheduledTask, SchedulerEntryOp } from './types.js'
|
|
@@ -12,7 +13,7 @@ import type { ScheduledTask, SchedulerEntryOp } from './types.js'
|
|
|
12
13
|
* - sendMessage: 到期 dispatch 的消息注入(生产实现委托 pi.sendMessage)
|
|
13
14
|
* - appendEntry: 按 op 写 pi-scheduler:task custom entry(event sourcing)。
|
|
14
15
|
* 生产实现委托 pi.appendEntry(同步落盘)。失败必须被调用方 try-catch(ER-APPEND-FAIL:
|
|
15
|
-
* runtime 捕获后
|
|
16
|
+
* runtime 捕获后 logger.warn + 不 rethrow,内存态已更新,at-least-once 已知恶化窗口)
|
|
16
17
|
* - getSessionFile: 当前 session JSONL 路径(addTask 构建 upsert op 的 ownerSessionFile 用;
|
|
17
18
|
* --no-session 模式返回 undefined,调用方 ?? '' 兜底)
|
|
18
19
|
* - now: 时间源(测试可注入固定值)
|
|
@@ -33,6 +34,8 @@ export interface SchedulerBackend {
|
|
|
33
34
|
appendEntry(op: SchedulerEntryOp): void
|
|
34
35
|
getSessionFile(): string | undefined
|
|
35
36
|
now(): number
|
|
37
|
+
/** 获取 delivery handle(装配点注入;未注入时 force 路径仍用 sendMessage 直投)。 */
|
|
38
|
+
getDeliveryHandle?(): DeliveryHandle | undefined
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -59,6 +62,7 @@ export interface SchedulerBackendCtx {
|
|
|
59
62
|
export class PiSchedulerBackend implements SchedulerBackend {
|
|
60
63
|
private ctx: SchedulerBackendCtx
|
|
61
64
|
private pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>
|
|
65
|
+
private deliveryHandle: DeliveryHandle | undefined
|
|
62
66
|
|
|
63
67
|
constructor(ctx: SchedulerBackendCtx, pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>) {
|
|
64
68
|
this.ctx = ctx
|
|
@@ -93,6 +97,18 @@ export class PiSchedulerBackend implements SchedulerBackend {
|
|
|
93
97
|
now(): number {
|
|
94
98
|
return Date.now()
|
|
95
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 注入 delivery handle(装配点创建后调用)。
|
|
103
|
+
* runtime 通过 getDeliveryHandle() 获取,非 force 任务走内核队列。
|
|
104
|
+
*/
|
|
105
|
+
setDeliveryHandle(handle: DeliveryHandle): void {
|
|
106
|
+
this.deliveryHandle = handle
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getDeliveryHandle(): DeliveryHandle | undefined {
|
|
110
|
+
return this.deliveryHandle
|
|
111
|
+
}
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
// ── 测试实现 ──
|
|
@@ -120,6 +136,7 @@ export class MockSchedulerBackend implements SchedulerBackend {
|
|
|
120
136
|
fakeSessionFile: string | undefined = '/test/session.json'
|
|
121
137
|
nowValue: number | undefined
|
|
122
138
|
appendError: Error | null = null
|
|
139
|
+
deliveryHandle: DeliveryHandle | undefined
|
|
123
140
|
|
|
124
141
|
async sendMessage(
|
|
125
142
|
msg: { content: string; customType: string; display: boolean },
|
|
@@ -141,6 +158,10 @@ export class MockSchedulerBackend implements SchedulerBackend {
|
|
|
141
158
|
return this.nowValue ?? Date.now()
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
getDeliveryHandle(): DeliveryHandle | undefined {
|
|
162
|
+
return this.deliveryHandle
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
/**
|
|
145
166
|
* 读路径(非接口成员,与 PiSchedulerBackend.loadTasks 对称):经 replayFoldEntries 折叠
|
|
146
167
|
* fakeEntries + fakeSessionFile 恢复任务。测试用它验证 backend→replay 委托(TC-W-BACKEND-REPLAY)。
|
package/src/importer.ts
CHANGED
|
@@ -4,9 +4,12 @@ import * as path from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
6
6
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
7
8
|
|
|
8
9
|
import type { ScheduledTask, SchedulerEntryOp, SchedulerStore, TaskSnapshot } from './types.js'
|
|
9
10
|
|
|
11
|
+
const logger = getLogger('scheduler')
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* 获取旧 store 文件路径:scheduler/<root>/<segments>/scheduler.json,双候选探测。
|
|
12
15
|
*
|
|
@@ -143,9 +146,9 @@ function importFromFile(
|
|
|
143
146
|
// 空 store(0 任务)无持久化依赖,直接删;resumed session(sessionFile 已存在)已即时落盘,删
|
|
144
147
|
if (tasks.length === 0 || fs.existsSync(currentSessionFile)) {
|
|
145
148
|
fs.unlinkSync(importedPath)
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
+
// 内部诊断日志(非用户可见消息):经共享 logger.warn → appendEntry 持久化为 custom entry,
|
|
150
|
+
// 不污染 TUI(logging-conventions SSOT:诊断走 logger.warn,禁裸 console)。
|
|
151
|
+
logger.warn('imported legacy tasks', { count: tasks.length, path: importedPath })
|
|
149
152
|
return undefined
|
|
150
153
|
}
|
|
151
154
|
|
|
@@ -155,9 +158,10 @@ function importFromFile(
|
|
|
155
158
|
// 出现 → 删;仍未 flush → 静默保留 .imported 供下次 session_start 的 handleImportedResidue
|
|
156
159
|
// 崩溃恢复重导入。跨 session 重导入窗口 = session_start → 首个 turn_end(秒级):turn_end 后
|
|
157
160
|
// .imported 已删,后续 session 启动看不到残留 → 双导入窗口闭合(R-CONCURRENT-IMPORT 已更新)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
logger.warn('imported legacy tasks (session not flushed; deferring removal)', {
|
|
162
|
+
count: tasks.length,
|
|
163
|
+
path: importedPath,
|
|
164
|
+
})
|
|
161
165
|
return () => {
|
|
162
166
|
// 幂等:已删(本进程或并发另一进程已处理)→ no-op。cleanup 在每次 turn_end 都会调用,重复调用安全
|
|
163
167
|
if (!fs.existsSync(importedPath)) return
|
|
@@ -216,7 +220,7 @@ function handleImportedResidue(
|
|
|
216
220
|
* 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务(pi _appendEntry 同步 push
|
|
217
221
|
* fileEntries,design-review 已实测验证)。
|
|
218
222
|
*
|
|
219
|
-
* 整体降级(C1):read/parse/appendEntry 任一异常 →
|
|
223
|
+
* 整体降级(C1):read/parse/appendEntry 任一异常 → logger.warn + 不 rethrow,不让
|
|
220
224
|
* session_start 崩溃(与 replay gap4 / ER-APPEND-FAIL 同款降级语义)。append 中途失败时
|
|
221
225
|
* .imported 保留(不 unlink)——下次 session 的 handleImportedResidue 会重导入全部任务,
|
|
222
226
|
* 已成功 append 的子集可能跨 session 双触发;取舍:删除则失败任务永久丢失(更糟),
|
|
@@ -255,9 +259,7 @@ export function importLegacyStore(
|
|
|
255
259
|
return importFromFile(importedPath, pi, currentSessionFile)
|
|
256
260
|
} catch (err) {
|
|
257
261
|
// C1 整体降级:read/parse/appendEntry 任一异常不崩 session_start
|
|
258
|
-
|
|
259
|
-
`[scheduler] import failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
260
|
-
)
|
|
262
|
+
logger.warn('import failed', { error: err instanceof Error ? err.message : String(err) })
|
|
261
263
|
return undefined
|
|
262
264
|
}
|
|
263
265
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { createDelivery, type DeliveryHandle, type DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
2
3
|
|
|
3
4
|
import { PiSchedulerBackend } from './backend.js'
|
|
4
5
|
import { registerScheduleCommand } from './commands.js'
|
|
@@ -43,6 +44,7 @@ let sessionGeneration = 0
|
|
|
43
44
|
*/
|
|
44
45
|
export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
45
46
|
let service: SchedulerService | null = null
|
|
47
|
+
let deliveryHandle: DeliveryHandle | undefined
|
|
46
48
|
// IMPORT-FLUSH-GUARD(MF-1):importLegacyStore 对未 flush 的新 session 返回延迟删除 .imported
|
|
47
49
|
// 的 cleanup——turn_end / session_shutdown 时执行:确认 flush(sessionFile 已出现)则删,
|
|
48
50
|
// 未 flush 保留供崩溃恢复重导入(否则未 flush 即退出 → 全部旧任务丢失且源文件已销毁)。
|
|
@@ -66,15 +68,58 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
66
68
|
// 窗口与 session 替换交错时旧 session_shutdown 可能永远等不到(timer 泄漏源头)。stopScheduler 幂等,
|
|
67
69
|
// shutdown 已停过再停一次无副作用。
|
|
68
70
|
service?.runtime.stopScheduler()
|
|
71
|
+
// 销毁旧 delivery handle(清队列 + 清 timer + 退订 settled)
|
|
72
|
+
deliveryHandle?.dispose()
|
|
69
73
|
// 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
|
|
70
74
|
const backend = new PiSchedulerBackend(ctx, pi)
|
|
71
75
|
// 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
|
|
72
76
|
// append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
|
|
73
77
|
// ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
|
|
74
78
|
importCleanup = importLegacyStore(ctx.cwd, pi, ctx.sessionManager.getSessionFile())
|
|
79
|
+
// 创建 delivery handle(U4:scheduler 切换内核)
|
|
80
|
+
// busyPolicy: 'park'(busy 入队不重试,等 tick 外部 flush)
|
|
81
|
+
// intent: 'after-run'(保持 followUp 语义)
|
|
82
|
+
// onSettled:延迟绑定——runtime 创建后填充(闭包变量)
|
|
83
|
+
// #11:整条 msg 透传(runtime 按 msg.dedupeKey=task.id 精确反查,不再 content 匹配)
|
|
84
|
+
const settledHandlerRef: { current: ((msg: DeliveryMessage, outcome: 'delivered' | 'rejected') => void) | undefined } = { current: undefined }
|
|
85
|
+
deliveryHandle = createDelivery({
|
|
86
|
+
supportedPayloads: ['custom'],
|
|
87
|
+
isIdle: () => ctx.isIdle(),
|
|
88
|
+
hasPendingMessages: () => ctx.hasPendingMessages(),
|
|
89
|
+
subscribeSettled: (cb) => {
|
|
90
|
+
// 无退订语义(pi 0.84.1 `pi.on(...)` 全重载返回 void、无 off——实装锚点:
|
|
91
|
+
// node_modules @earendil-works/pi-coding-agent dist/core/extensions/types.d.ts
|
|
92
|
+
// `on()` 系列,0.84.1 实测);disposed 标志包装兑现退订(notifier.ts 同款)
|
|
93
|
+
let disposed = false
|
|
94
|
+
pi.on('agent_settled', () => { if (!disposed) cb() })
|
|
95
|
+
return () => { disposed = true }
|
|
96
|
+
},
|
|
97
|
+
send: (msg, intent) => {
|
|
98
|
+
const piOpts = intent === 'interrupt-at-turn-boundary'
|
|
99
|
+
? { triggerTurn: true, deliverAs: 'steer' as const }
|
|
100
|
+
: { triggerTurn: true, deliverAs: 'followUp' as const }
|
|
101
|
+
// supportedPayloads 已收窄到 'custom',payload.kind 恒为 'custom'
|
|
102
|
+
const content = msg.payload.content
|
|
103
|
+
return pi.sendMessage(
|
|
104
|
+
{ content, customType: 'pi-scheduler:dispatched', display: true },
|
|
105
|
+
piOpts,
|
|
106
|
+
)
|
|
107
|
+
},
|
|
108
|
+
}, {
|
|
109
|
+
intent: 'after-run',
|
|
110
|
+
busyPolicy: 'park',
|
|
111
|
+
onSettled: (msg, outcome) => {
|
|
112
|
+
// 委托给 runtime 的 settledHandler(延迟绑定)
|
|
113
|
+
settledHandlerRef.current?.(msg, outcome)
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
backend.setDeliveryHandle(deliveryHandle)
|
|
117
|
+
|
|
75
118
|
// G1:注入代际比对(本 runtime 建立时的代数 vs 实时代数),供 tick 前置检查与
|
|
76
119
|
// F2 catch 分诊判定 stale——不依赖 pi 错误文案。
|
|
77
120
|
const runtime = new SchedulerRuntime(backend, ctx, () => sessionGeneration !== myGeneration)
|
|
121
|
+
// 延迟绑定:runtime 的 handleSettled 绑定到 delivery onSettled 回调
|
|
122
|
+
settledHandlerRef.current = (msg, outcome) => runtime.handleSettled(msg, outcome)
|
|
78
123
|
runtime.loadTasks(backend.loadTasks())
|
|
79
124
|
// W2:tick 后回调刷新 widget(替代独立 widgetTimer + setInterval,节奏对齐 TICK_INTERVAL_MS)
|
|
80
125
|
runtime.onAfterTick(() => refreshWidget(ctx))
|
|
@@ -100,6 +145,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
100
145
|
if (service) {
|
|
101
146
|
service.runtime.stopScheduler()
|
|
102
147
|
}
|
|
148
|
+
// 销毁 delivery handle(清队列 + 清 timer + 退订 settled)
|
|
149
|
+
deliveryHandle?.dispose()
|
|
150
|
+
deliveryHandle = undefined
|
|
103
151
|
// IMPORT-FLUSH-GUARD(MF-1):兜底清理——正常路径已由首个 turn_end 完成;此处覆盖
|
|
104
152
|
// 从未产生 turn 的 session(打开未发消息即关闭)。cleanup 确认 flush(sessionFile 已出现)
|
|
105
153
|
// 则删 .imported,未 flush 保留供崩溃恢复重导入
|
package/src/replay.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
2
|
+
|
|
1
3
|
import type { ScheduledTask, SchedulerEntryOp, TaskSnapshot } from './types.js'
|
|
2
4
|
|
|
5
|
+
const logger = getLogger('scheduler')
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* CustomEntry 的最小可识别形状(duck-typed,不依赖 @earendil-works/pi-coding-agent 的
|
|
5
9
|
* 具体 SessionEntry 类型)。与 pending-notifications/state.ts、goal/ports.ts 同款:
|
|
@@ -33,7 +37,7 @@ const HISTORY_LIMIT = 20
|
|
|
33
37
|
*
|
|
34
38
|
* append-only 时序保证 nextRunAt 不回退(D1):advance entry 按写入顺序折叠,自然取到最后推进值。
|
|
35
39
|
*
|
|
36
|
-
* getEntries 解析/迭代异常 →
|
|
40
|
+
* getEntries 解析/迭代异常 → logger.warn + 返回空 Map(gap4):降级为无任务而非崩溃 session_start。
|
|
37
41
|
*/
|
|
38
42
|
export function replayFoldEntries(
|
|
39
43
|
entries: Iterable<SchedulerEntryLike>,
|
|
@@ -82,9 +86,7 @@ export function replayFoldEntries(
|
|
|
82
86
|
// MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
|
|
83
87
|
// snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
|
|
84
88
|
// 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
|
|
85
|
-
|
|
86
|
-
`[scheduler] skipping corrupted scheduler entry: ${err instanceof Error ? err.message : String(err)}`,
|
|
87
|
-
)
|
|
89
|
+
logger.warn('skipping corrupted scheduler entry', { error: err instanceof Error ? err.message : String(err) })
|
|
88
90
|
continue
|
|
89
91
|
}
|
|
90
92
|
}
|
|
@@ -101,9 +103,7 @@ export function replayFoldEntries(
|
|
|
101
103
|
return tasks
|
|
102
104
|
} catch (err) {
|
|
103
105
|
// gap4:session JSONL 损坏 / 迭代器抛错时降级为无任务,不让 session_start 崩溃。
|
|
104
|
-
|
|
105
|
-
`[scheduler] replayFoldEntries failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
106
|
-
)
|
|
106
|
+
logger.warn('replayFoldEntries failed', { error: err instanceof Error ? err.message : String(err) })
|
|
107
107
|
return new Map()
|
|
108
108
|
}
|
|
109
109
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
3
|
+
|
|
4
|
+
import type { DeliveryHandle, DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
2
5
|
|
|
3
6
|
import type { SchedulerBackend } from './backend.js'
|
|
4
7
|
import { autoName, generateTaskId } from './format.js'
|
|
@@ -11,7 +14,11 @@ import type {
|
|
|
11
14
|
TaskSnapshot,
|
|
12
15
|
} from './types.js'
|
|
13
16
|
|
|
17
|
+
const logger = getLogger('scheduler')
|
|
18
|
+
|
|
14
19
|
const MAX_TASKS = 50
|
|
20
|
+
// 入队防重标记 TTL(合批非首条任务无终态回调,过期后放行重投;10 min >> 合批窗口)
|
|
21
|
+
const QUEUE_DEDUPE_TTL_MS = 10 * 60 * 1000
|
|
15
22
|
const RATE_LIMIT_PER_MINUTE = 6
|
|
16
23
|
const TICK_INTERVAL_MS = 30_000
|
|
17
24
|
const DEFAULT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
@@ -30,13 +37,21 @@ const STALE_CTX_MARKER = 'stale after session replacement'
|
|
|
30
37
|
export class SchedulerRuntime {
|
|
31
38
|
private tasks: Map<string, ScheduledTask> = new Map()
|
|
32
39
|
private backend: SchedulerBackend
|
|
33
|
-
private ctx: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>
|
|
34
40
|
private tickTimer: ReturnType<typeof setInterval> | null = null
|
|
35
41
|
private dispatchTimestamps: number[] = []
|
|
36
42
|
private onAfterTickCallback: (() => void) | null = null
|
|
37
43
|
private readonly isCtxStale: (() => boolean) | undefined
|
|
38
44
|
// R3-S1:同任务 dispatch 在途标记(Set<taskId>),见 dispatchTask 注释
|
|
39
45
|
private readonly dispatchesInFlight = new Set<string>()
|
|
46
|
+
// 入队防重标记(Map<taskId, enqueuedAt>):非 force 任务 send 进 delivery 内核后、
|
|
47
|
+
// 终态回调(handleSettled)前,nextRunAt 未推进——tick step2 会按 `now >= nextRunAt`
|
|
48
|
+
// 重新置 pending,若无此标记,agent busy 的每个 tick 都会再压一份同 prompt 副本进队列
|
|
49
|
+
// (合批后重复注入)。入队置位、delivered/rejected 清除;任务删除(delete/过期)同步清除。
|
|
50
|
+
// TTL 兜底:合批投递时 onSettled 只带首条 dedupeKey,非首条任务收不到终态回调——
|
|
51
|
+
// 标记过期后允许重投,保持 at-least-once(与旧「nextRunAt 未推进下 tick 重投」等价)。
|
|
52
|
+
private readonly queuedInDeliveryAt = new Map<string, number>()
|
|
53
|
+
// delivery handle(装配点注入;非 force 任务走内核队列)
|
|
54
|
+
private delivery: DeliveryHandle | undefined
|
|
40
55
|
|
|
41
56
|
/**
|
|
42
57
|
* 依赖反转构造:backend 承担 appendEntry/pi.sendMessage/时间源,runtime 只持有内存态。
|
|
@@ -48,12 +63,15 @@ export class SchedulerRuntime {
|
|
|
48
63
|
*/
|
|
49
64
|
constructor(
|
|
50
65
|
backend: SchedulerBackend,
|
|
51
|
-
ctx
|
|
66
|
+
ctx?: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
|
|
52
67
|
isCtxStale?: () => boolean,
|
|
53
68
|
) {
|
|
54
69
|
this.backend = backend
|
|
55
|
-
|
|
70
|
+
// ctx 不再存实例变量(gate 已交内核);isCtxStale 保留用于代际检测
|
|
71
|
+
void ctx
|
|
56
72
|
this.isCtxStale = isCtxStale
|
|
73
|
+
// 从 backend 获取 delivery handle(装配点注入)
|
|
74
|
+
this.delivery = backend.getDeliveryHandle?.()
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
// ── 任务 CRUD ──
|
|
@@ -161,6 +179,7 @@ export class SchedulerRuntime {
|
|
|
161
179
|
|
|
162
180
|
deleteTask(id: string): boolean {
|
|
163
181
|
const deleted = this.tasks.delete(id)
|
|
182
|
+
if (deleted) this.queuedInDeliveryAt.delete(id)
|
|
164
183
|
if (deleted) {
|
|
165
184
|
this.appendEntrySafe({ op: 'delete', taskId: id })
|
|
166
185
|
}
|
|
@@ -199,7 +218,7 @@ export class SchedulerRuntime {
|
|
|
199
218
|
if (this.isCtxStale?.() || message.includes(STALE_CTX_MARKER)) {
|
|
200
219
|
this.retireStaleTimer()
|
|
201
220
|
} else {
|
|
202
|
-
|
|
221
|
+
logger.warn('tick error', { error: message })
|
|
203
222
|
}
|
|
204
223
|
})
|
|
205
224
|
}, TICK_INTERVAL_MS)
|
|
@@ -218,7 +237,7 @@ export class SchedulerRuntime {
|
|
|
218
237
|
* session_start 重建的新一代 runtime 接管。
|
|
219
238
|
*/
|
|
220
239
|
private retireStaleTimer(): void {
|
|
221
|
-
|
|
240
|
+
logger.warn('tick stopped: stale extension ctx (session replaced); timer self-retired')
|
|
222
241
|
this.stopScheduler()
|
|
223
242
|
}
|
|
224
243
|
|
|
@@ -239,6 +258,7 @@ export class SchedulerRuntime {
|
|
|
239
258
|
for (const [id, task] of this.tasks) {
|
|
240
259
|
if (task.expiresAt && now >= task.expiresAt) {
|
|
241
260
|
this.tasks.delete(id)
|
|
261
|
+
this.queuedInDeliveryAt.delete(id)
|
|
242
262
|
this.appendEntrySafe({ op: 'delete', taskId: id })
|
|
243
263
|
}
|
|
244
264
|
}
|
|
@@ -264,6 +284,10 @@ export class SchedulerRuntime {
|
|
|
264
284
|
|
|
265
285
|
// W2:tick 完成后刷新 widget(index.ts 注册 refreshWidget)
|
|
266
286
|
this.onAfterTickCallback?.()
|
|
287
|
+
|
|
288
|
+
// delivery flush:park 模式下内核不主动重试,由 scheduler tick 外部触发 flush
|
|
289
|
+
// 让积压在队列中的消息在每个 tick 尝试投递
|
|
290
|
+
this.delivery?.flush()
|
|
267
291
|
}
|
|
268
292
|
|
|
269
293
|
// ── dispatch ──
|
|
@@ -282,7 +306,7 @@ export class SchedulerRuntime {
|
|
|
282
306
|
async dispatchTask(task: ScheduledTask): Promise<boolean> {
|
|
283
307
|
if (!task.enabled) return false
|
|
284
308
|
if (this.dispatchesInFlight.has(task.id)) {
|
|
285
|
-
|
|
309
|
+
logger.warn('dispatch already in flight, skipping this tick', { taskId: task.id })
|
|
286
310
|
return false
|
|
287
311
|
}
|
|
288
312
|
this.dispatchesInFlight.add(task.id)
|
|
@@ -302,17 +326,28 @@ export class SchedulerRuntime {
|
|
|
302
326
|
* once 成功 → append delete。失败 dispatch 不 append(CL7 重试语义,transient 失败 nextRunAt 未推进)。
|
|
303
327
|
*/
|
|
304
328
|
private async dispatchTaskInner(task: ScheduledTask): Promise<boolean> {
|
|
305
|
-
// 检查 force 或 idle
|
|
306
|
-
if (!task.force) {
|
|
307
|
-
if (!this.ctx.isIdle() || this.ctx.hasPendingMessages()) {
|
|
308
|
-
return false // 延迟到下次 tick
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
329
|
// 检查速率限制
|
|
313
330
|
if (!this.hasDispatchCapacity(this.backend.now())) return false
|
|
314
331
|
|
|
315
|
-
|
|
332
|
+
if (task.force || !this.delivery) {
|
|
333
|
+
// force 任务或无 delivery handle 时直投(绕过内核队列)
|
|
334
|
+
return this.dispatchDirect(task)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 已在内核队列中(入队后未终态且未过 TTL)——step2 会按未推进的 nextRunAt 重新置
|
|
338
|
+
// pending,此处拦截防重复入队(见 queuedInDeliveryAt 字段注释)
|
|
339
|
+
const queuedAt = this.queuedInDeliveryAt.get(task.id)
|
|
340
|
+
if (queuedAt !== undefined && this.backend.now() - queuedAt < QUEUE_DEDUPE_TTL_MS) return false
|
|
341
|
+
|
|
342
|
+
// 非 force 任务走 delivery 内核(park 模式:busy 入队等下次 tick flush)
|
|
343
|
+
return this.dispatchViaDelivery(task)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* force 任务直投:绕过 delivery 内核队列,直接调 backend.sendMessage。
|
|
348
|
+
* 无 delivery handle 时也走此路径(向后兼容)。
|
|
349
|
+
*/
|
|
350
|
+
private async dispatchDirect(task: ScheduledTask): Promise<boolean> {
|
|
316
351
|
try {
|
|
317
352
|
await this.backend.sendMessage(
|
|
318
353
|
{ content: task.prompt, customType: 'pi-scheduler:dispatched', display: true },
|
|
@@ -325,49 +360,111 @@ export class SchedulerRuntime {
|
|
|
325
360
|
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
326
361
|
return false
|
|
327
362
|
}
|
|
363
|
+
return this.onDispatchSuccess(task)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 非 force 任务走 delivery 内核:入队后由内核 flush 时投递。
|
|
368
|
+
* gate(isIdle/hasPendingMessages)由内核管理,busy 时入队不重试(park 模式)。
|
|
369
|
+
* onSettled 回调处理成功/失败记账。
|
|
370
|
+
* 返回 true 表示已入队(非实际发送)。
|
|
371
|
+
*/
|
|
372
|
+
private dispatchViaDelivery(task: ScheduledTask): boolean {
|
|
373
|
+
const delivery = this.delivery!
|
|
374
|
+
|
|
375
|
+
delivery.send({
|
|
376
|
+
payload: {
|
|
377
|
+
kind: 'custom',
|
|
378
|
+
customType: 'pi-scheduler:dispatched',
|
|
379
|
+
content: task.prompt,
|
|
380
|
+
display: true,
|
|
381
|
+
},
|
|
382
|
+
intent: 'after-run',
|
|
383
|
+
// #11:task.id 作为 onSettled 反查键(本 handle 未开 dedupe,dedupeKey 不驱动
|
|
384
|
+
// 去重,仅随消息透传给 onSettled 回调)。content 反查在同 prompt 多任务下错配。
|
|
385
|
+
dedupeKey: task.id,
|
|
386
|
+
})
|
|
328
387
|
|
|
329
|
-
//
|
|
388
|
+
// send() 不 throw(park 模式下入队即返回)。
|
|
389
|
+
// 入队即挂防重标记 + 计入速率限制(nextRunAt 要等 delivered 后才推进,期间 step2
|
|
390
|
+
// 会持续重标 pending——防重标记拦截重复入队,速率记账覆盖入队侧消耗)。
|
|
391
|
+
// 成功/失败记账由 onSettled 回调异步处理。
|
|
392
|
+
this.queuedInDeliveryAt.set(task.id, this.backend.now())
|
|
393
|
+
this.dispatchTimestamps.push(this.backend.now())
|
|
394
|
+
task.pending = false
|
|
395
|
+
return true
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* dispatch 成功后的状态更新与持久化(dispatchDirect 成功后、onSettled delivered 后共用)。
|
|
400
|
+
* countRate=false 时不再计速率(delivery 路径入队时已计入,delivered 再计会双算)。
|
|
401
|
+
*/
|
|
402
|
+
private async onDispatchSuccess(task: ScheduledTask, countRate = true): Promise<boolean> {
|
|
330
403
|
const now = this.backend.now()
|
|
331
404
|
task.runCount++
|
|
332
405
|
task.lastRunAt = now
|
|
333
406
|
task.lastStatus = 'success'
|
|
334
407
|
task.pending = false
|
|
335
|
-
task.lastError = undefined
|
|
408
|
+
task.lastError = undefined
|
|
336
409
|
task.history.push({ at: now, status: 'success' })
|
|
337
410
|
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
338
411
|
|
|
339
|
-
// 计算下次执行 + 持久化(append-only)
|
|
340
412
|
if (task.kind === 'once') {
|
|
341
413
|
this.tasks.delete(task.id)
|
|
342
|
-
// once 成功 → append delete(CL7:抵消 upsert,防 resume 复活已执行的 once 任务)
|
|
343
414
|
this.appendEntrySafe({ op: 'delete', taskId: task.id })
|
|
344
415
|
} else {
|
|
345
416
|
const next = await computeNextRunAt(task.schedule, now)
|
|
346
417
|
if (next === undefined) {
|
|
347
|
-
// ERR-2 fallback:cron 表达式失效 → 停用任务,避免 `?? now()` 死循环。
|
|
348
|
-
// 不 append advance(nextRunAt 未推进,CL7 重试语义);cron 失效停用的 enabled=false
|
|
349
|
-
// 持久化缺口属 at-least-once 已知窗口(首个 tick 会再停用),非 must-fix
|
|
350
418
|
task.enabled = false
|
|
351
419
|
task.lastStatus = 'failed'
|
|
352
420
|
task.lastError = 'cron expression invalid'
|
|
353
|
-
// nextRunAt 保留原值(enabled=false 后 tick 不再触发)
|
|
354
421
|
} else {
|
|
355
422
|
task.nextRunAt = next
|
|
356
|
-
// recurring 成功推进 nextRunAt → append advance(D1 核心:持久化新 nextRunAt,防 resume 回退重放)
|
|
357
423
|
this.appendEntrySafe({
|
|
358
424
|
op: 'advance',
|
|
359
425
|
taskId: task.id,
|
|
360
426
|
nextRunAt: next,
|
|
361
427
|
at: now,
|
|
362
|
-
status: 'success',
|
|
428
|
+
status: 'success',
|
|
363
429
|
})
|
|
364
430
|
}
|
|
365
431
|
}
|
|
366
432
|
|
|
367
|
-
this.dispatchTimestamps.push(now)
|
|
433
|
+
if (countRate) this.dispatchTimestamps.push(now)
|
|
368
434
|
return true
|
|
369
435
|
}
|
|
370
436
|
|
|
437
|
+
/**
|
|
438
|
+
* onSettled 回调入口(index.ts 装配点绑定):delivery 内核投递终态时调用。
|
|
439
|
+
* delivered → 成功记账(onDispatchSuccess);rejected → 失败记账。
|
|
440
|
+
* once 任务失败不删持久化(at-least-once 语义)。
|
|
441
|
+
* #11:按 msg.dedupeKey(dispatch 时挂的 task.id,见 dispatchViaDelivery)精确反查
|
|
442
|
+
* tasks Map——旧 content 反查在「同 prompt 多任务」下错配(find 取首个命中),
|
|
443
|
+
* 任务已删除时静默丢弃不误记。反查未命中(once 成功已删 / 批量合投非首条 /
|
|
444
|
+
* 非 scheduler 消息)直接返回。
|
|
445
|
+
* 已知限制:内核 busy 期间多条消息合投为一批时(doSend splice 全队列),onSettled
|
|
446
|
+
* 只收到保留首条 dedupeKey 的 composed 消息——非首条任务不记账,靠下个 tick 的
|
|
447
|
+
* nextRunAt 未推进重投(at-least-once 兜底),与旧 content 反查行为等价不劣化。
|
|
448
|
+
*/
|
|
449
|
+
handleSettled(msg: DeliveryMessage, outcome: 'delivered' | 'rejected'): void {
|
|
450
|
+
const taskId = msg.dedupeKey
|
|
451
|
+
// 防重标记先清(任务可能已被删除,反查未命中也要清)
|
|
452
|
+
if (taskId !== undefined) this.queuedInDeliveryAt.delete(taskId)
|
|
453
|
+
const task = taskId !== undefined ? this.tasks.get(taskId) : undefined
|
|
454
|
+
if (!task) return // 任务已被删除(once 成功后删)或非 scheduler 发出的消息
|
|
455
|
+
|
|
456
|
+
if (outcome === 'delivered') {
|
|
457
|
+
// fire-and-forget:onDispatchSuccess 内部 catch 不 rethrow(countRate=false:入队时已计速率)
|
|
458
|
+
void this.onDispatchSuccess(task, false).catch(() => {})
|
|
459
|
+
} else {
|
|
460
|
+
// 失败记账
|
|
461
|
+
task.lastStatus = 'failed'
|
|
462
|
+
task.history.push({ at: this.backend.now(), status: 'failed' })
|
|
463
|
+
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
464
|
+
// once 任务失败不删持久化(at-least-once 语义)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
371
468
|
private hasDispatchCapacity(now: number): boolean {
|
|
372
469
|
const oneMinuteAgo = now - 60_000
|
|
373
470
|
this.dispatchTimestamps = this.dispatchTimestamps.filter(t => t > oneMinuteAgo)
|
|
@@ -384,7 +481,7 @@ export class SchedulerRuntime {
|
|
|
384
481
|
// ── append-only 持久化辅助 ──
|
|
385
482
|
|
|
386
483
|
/**
|
|
387
|
-
* 委托 backend.appendEntry。失败 →
|
|
484
|
+
* 委托 backend.appendEntry。失败 → logger.warn + 不 rethrow(ER-APPEND-FAIL)。
|
|
388
485
|
* 内存态已先行更新(at-least-once 已知恶化窗口:append 失败则该 op 丢失,resume 重放回退)。
|
|
389
486
|
* 不再设 task.lastError='persist failed'(append 失败是 transient,不应污染业务态)。
|
|
390
487
|
*/
|
|
@@ -394,9 +491,7 @@ export class SchedulerRuntime {
|
|
|
394
491
|
} catch (err) {
|
|
395
492
|
// best-effort 降级(ER-APPEND-FAIL):append-only 模型下 append 失败仅丢失该 op 的持久化,
|
|
396
493
|
// 内存态已先行更新、不 rethrow,业务流程继续。at-least-once 已知恶化窗口(resume 重放回退)。
|
|
397
|
-
|
|
398
|
-
`[scheduler] appendEntry failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
399
|
-
)
|
|
494
|
+
logger.warn('appendEntry failed', { error: err instanceof Error ? err.message : String(err) })
|
|
400
495
|
}
|
|
401
496
|
}
|
|
402
497
|
|