@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/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 捕获后 console.warn + 不 rethrow,内存态已更新,at-least-once 已知恶化窗口)
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
- // 内部诊断日志(非用户可见消息):用 console.warn 而非 console.log(项目 convention:
147
- // extensions 禁 console.log/info 防泄漏到 TUI;诊断输出统一 console.warn)
148
- console.warn(`[scheduler] imported ${tasks.length} legacy tasks from ${importedPath}`)
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
- console.warn(
159
- `[scheduler] imported ${tasks.length} legacy tasks (session not flushed; deferring ${importedPath} removal)`,
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 任一异常 → console.warn + 不 rethrow,不让
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
- console.warn(
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 解析/迭代异常 → console.warn + 返回空 Map(gap4):降级为无任务而非崩溃 session_start。
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
- console.warn(
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
- console.warn(
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: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
66
+ ctx?: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
52
67
  isCtxStale?: () => boolean,
53
68
  ) {
54
69
  this.backend = backend
55
- this.ctx = ctx
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
- console.warn(`[scheduler] tick error: ${message}`)
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
- console.warn(`[scheduler] tick stopped: stale extension ctx (session replaced); timer self-retired`)
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
- console.warn(`[scheduler] dispatch already in flight for task ${task.id}; skipping this tick`)
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
- // 注入 message(await:async 错误必须被捕获,fire-and-forget 会漏)
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
- // 更新状态(收敛到一个 now 值,避免多次 backend.now() 在真实时钟下漂移)
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 // 成功 dispatch 后清除历史错误
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', // CL8:对齐 TaskStatus,非 'ok'
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。失败 → console.warn + 不 rethrow(ER-APPEND-FAIL)。
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
- console.warn(
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