cs-bots 0.0.0-stage → 0.1.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.
@@ -0,0 +1,651 @@
1
+ /**
2
+ * cs-bots Dispatcher(唤醒桥)—— 官方完全没有的关键零件。
3
+ *
4
+ * ## 为什么必须有它
5
+ *
6
+ * 官方 Agent Teams 的硬事实(已核验,见 `10-初步架构设计方案-v1.md` §3.5):
7
+ * - 任务 `complete` **只改状态位**;
8
+ * - `activity.notify(TeamId)` 是 **Team 级广播**,只 resolve 正在阻塞的 `wait_agent`;
9
+ * - 工具契约原文:**"Task readiness never starts an owner"**。
10
+ *
11
+ * ⇒ **没有唤醒桥,平台会静默停摆**:任务建好了、被派了,但 owner 根本不会动。
12
+ * 本模块就是那条「让 owner 动起来」的通路。
13
+ *
14
+ * ## 三条纪律(§3.5 原文)
15
+ *
16
+ * 1. **只对 owner 定向发**,绝不用 `wait_agent` 当轮询(Team 级广播会造成唤醒风暴);
17
+ * 2. **去重 + 节流**:`dedupKey` 命中即跳过;
18
+ * 3. **租约超时**也走唤醒("你的任务租约已过期")。
19
+ *
20
+ * ## 绝不静默失败(本模块最重要的性质)
21
+ *
22
+ * 三种「没唤成」的情况**都必须能被调用方看见**,且**两层可见**(返回值 + 落盘):
23
+ *
24
+ * | 情况 | 返回值 | 落盘 | 说明 |
25
+ * |---|---|---|---|
26
+ * | 被去重 | `{ok:true, result:'skipped', reason:'dedup-hit'}` | `result:'skipped'` | 同 dedupKey 只唤一次 |
27
+ * | 被背压 | `{ok:false, result:'failed', reason:'backpressure'}` | `result:'failed'` | 队列超限,绝不静默丢 |
28
+ * | 投递失败 | `{ok:false, result:'failed', reason:<原因>}` | `result:'failed'` | agent-not-live / followup-failed… |
29
+ * | 队龄超限 | (由 `flush()` 的 `pruned` 报告) | `result:'failed'` | 24h 未投出,丢弃可见 |
30
+ * | 落盘失败 | `{ok:false, reason:'record-persist-failed', durable:false}` | —— | 状态目录不可写,**明说写不成** |
31
+ *
32
+ * 一句话:**任何时候「本该发生唤醒却没发生」,调用方一定拿得到理由,
33
+ * 且 `wakes.jsonl` 里一定有一条能读到的记录。**
34
+ *
35
+ * ## 去重是「投递前就把键写死」的(intent-first)
36
+ *
37
+ * 官方投递失败(如 `agent-not-live`)在会话重启后是**可恢复**的,但本模块**不自动重试**。
38
+ * 理由:Dispatcher 的上游是任务板/房间的**事件流**,同一 `dedupKey` 会被反复触发。
39
+ * 若投递失败就释放键,事件流会在「失败 → 释放键 → 事件再触发 → 再投递」之间
40
+ * **无限空转**,正是 K1/K2 要防的反模式,且会烧 token。
41
+ *
42
+ * ⇒ **先占键 → 再投递**,投递失败也不释放(原因已落盘、已可见,
43
+ * 由上层按自己的策略决定是否用**新的** dedupKey 重派)。
44
+ * **两处例外**(都是「请求根本没被接受」,而非「接受了但没送成」):
45
+ * - 状态目录不可写(连记录都落不成 ⇒ 没有任何持久痕迹,占着键只会把该 target
46
+ * 永久静默,故释放);
47
+ * - 被背压拒绝(请求被**拒绝**,该 dedupKey 尚未被消费掉,释放后队列疏通仍可重发)。
48
+ *
49
+ * ## 投递语义:一律 `followup`,不用 `steer`(K1)
50
+ *
51
+ * K1 已定:投递必须**异步**、**绝不阻塞** —— 唤醒是「新开一个回合干活」,不是插话。
52
+ * `kernel.steer` 只用于「正在干活时插一句话」,本模块不需要,故不引入。
53
+ *
54
+ * ## 时间控制(测试友好)
55
+ *
56
+ * **不使用不可注入的 `setTimeout`**:`config.now` 与 `config.timer` 均可注入,
57
+ * 测试用假时钟把「节流推迟 / 到期投递 / 24h 过期」全部**确定性**推进,零真实等待。
58
+ *
59
+ * ## 持久化口径:只落**终态**
60
+ *
61
+ * `wakes.jsonl` 里每条记录的 `result` 严格取自 §3.5 的三值
62
+ * `'sent' | 'skipped' | 'failed'`;尚未投递的唤醒**只存在于内存队列**(不写垃圾状态),
63
+ * 它最终落盘时必然是 `sent` 或 `failed`。这样 `list()` 的返回类型与 §3.5 模型完全一致。
64
+ *
65
+ * ## 本文件零依赖(CI 检查 1 覆盖)
66
+ *
67
+ * 不 import 任何 `@deepseek-ai/*`(耦合只允许出现在 `kernel.js`);
68
+ * 只用 node 内置 + `store.js` + `kernel.js`。
69
+ */
70
+
71
+ import { rmSync } from 'node:fs'
72
+ import { dirname } from 'node:path'
73
+
74
+ import * as kernel from './kernel.js'
75
+ import { IdempotencySet, appendJsonl, ensureDir, nextSeq, readJsonl, statePath } from './store.js'
76
+
77
+ // ── 导出常量 ────────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * 合法的唤醒原因(§3.5 原文枚举)。
81
+ * 非法 reason 会被**明确拒绝**而不是被悄悄接受 —— 否则事件源写错一个词,
82
+ * 该任务的唤醒就会**静默失联**,正是本模块要根除的故障。
83
+ * @type {readonly string[]}
84
+ */
85
+ export const WAKE_REASONS = Object.freeze([
86
+ 'task-assigned',
87
+ 'task-unblocked',
88
+ 'room-message',
89
+ 'lease-timeout',
90
+ 'timer',
91
+ ])
92
+
93
+ /** `notify()` 返回值的全部可能取值(比 Wake.result 多一个 `'pending'`,见 `notify` 注释)。 */
94
+ export const NOTIFY_RESULTS = Object.freeze(['sent', 'skipped', 'failed', 'pending'])
95
+
96
+ /** 原因 → 人类可读文案(生成缺省唤醒文本用)。 */
97
+ const REASON_TEXT = Object.freeze({
98
+ 'task-assigned': '你被指派了新任务,请开工。',
99
+ 'task-unblocked': '你的任务依赖已解除,现在可以开工。',
100
+ 'room-message': '房间有新消息,请查看。',
101
+ 'lease-timeout': '你的任务租约已过期,请处理或续租。',
102
+ timer: '定时唤醒。',
103
+ })
104
+
105
+ /**
106
+ * 缺省配置,全部可被 `createDispatcher(ctx, config)` 覆盖。
107
+ *
108
+ * - `throttleMs` 同 target 时间窗内最多唤醒一次(K4 时间窗)
109
+ * - `maxPerTarget` 每会话队列上限(K4 队列上限;DS-BOT 原型为每会话 50 条)
110
+ * - `queueTtlMs` pending 存活上限,超过即判过期丢弃(K4 的 24h)
111
+ * - `retryDeferredMs` 队列非空时的巡检间隔(驱动过期清理与到期投递)
112
+ */
113
+ const DEFAULTS = Object.freeze({
114
+ throttleMs: 5_000,
115
+ maxPerTarget: 50,
116
+ queueTtlMs: 24 * 60 * 60 * 1_000,
117
+ retryDeferredMs: 1_000,
118
+ })
119
+
120
+ /** 去重器内存上限(LRU 近似,超出丢最旧)。 */
121
+ const DEDUP_MAX = 50_000
122
+
123
+ /**
124
+ * 重复 skip 的**落盘折叠窗口**:同一 dedupKey 在此窗口内重复触发,只落一条 skipped 记录。
125
+ *
126
+ * 为什么需要:上游是事件流,`notify` 会被同一 dedupKey 高频重复调用(例如任务板每次
127
+ * 重算都重发同一条指派)。逐次落盘会让 `wakes.jsonl` 线性膨胀。
128
+ * 折叠后**可见性不降**(返回值仍是 `result:'skipped'`,`stats().skipped` 仍逐次计数),
129
+ * 只是审计日志不被重复条目淹没。
130
+ */
131
+ const SKIP_RECORD_INTERVAL_MS = 60_000
132
+
133
+ // ── 内部工具 ────────────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * 探测目标文件所在目录**是否真的可写** —— 这是「绝不静默失败」的地基。
137
+ *
138
+ * 做法:在**旁路文件**上试写再删除(不污染 `wakes.jsonl` 审计流)。
139
+ * 若不可写,Dispatcher 仍照常工作,但每次决策都会在返回值里带 `durable:false` ——
140
+ * 调用方绝不能以为「没报错 = 记录下来了」。
141
+ *
142
+ * @param {string} file
143
+ * @returns {{ writable: boolean, probeError?: string }}
144
+ */
145
+ function probeWritable(file) {
146
+ const probeFile = `${file}.probe-${process.pid}`
147
+ try {
148
+ if (!ensureDir(dirname(file))) return { writable: false, probeError: 'state-dir-unusable' }
149
+ if (!appendJsonl(probeFile, { probe: true, at: Date.now() })) {
150
+ return { writable: false, probeError: 'state-dir-not-writable' }
151
+ }
152
+ rmSync(probeFile, { force: true })
153
+ return { writable: true }
154
+ } catch (error) {
155
+ return { writable: false, probeError: `state-dir-probe-failed:${error?.message ?? error}` }
156
+ }
157
+ }
158
+
159
+ // ── 队列(有界、按 target 分区)─────────────────────────────────────────────
160
+
161
+ /**
162
+ * pending 唤醒队列。
163
+ *
164
+ * 设计要点:
165
+ * - **按 target 分区计数**,让「每会话上限」是 **O(1)** 判定,而不是每次扫全队列;
166
+ * - Entry 额外带 `notBefore`(节流推迟后的最早可投递时刻)与 `keyHeld`
167
+ * (本 entry 是否占着去重键 —— 丢弃时必须释放,否则该任务永久静默)。
168
+ */
169
+ function createQueue() {
170
+ /** @type {Map<string, object[]>} target → Entry[] */
171
+ const byTarget = new Map()
172
+ /** @type {Map<string, object>} wakeId → Entry */
173
+ const index = new Map()
174
+
175
+ function detach(entry) {
176
+ index.delete(entry.draft.id)
177
+ const list = byTarget.get(entry.draft.target)
178
+ if (!list) return
179
+ const i = list.indexOf(entry)
180
+ if (i >= 0) list.splice(i, 1)
181
+ if (list.length === 0) byTarget.delete(entry.draft.target)
182
+ }
183
+
184
+ return {
185
+ add(entry) {
186
+ const list = byTarget.get(entry.draft.target)
187
+ if (list) list.push(entry)
188
+ else byTarget.set(entry.draft.target, [entry])
189
+ index.set(entry.draft.id, entry)
190
+ },
191
+ remove: detach,
192
+ /** 某 target 当前排队条数(O(1) —— 背压判定不走全队列扫描)。 */
193
+ sizeOf(target) {
194
+ return byTarget.get(target)?.length ?? 0
195
+ },
196
+ total() {
197
+ return index.size
198
+ },
199
+ targets() {
200
+ return byTarget.size
201
+ },
202
+ all() {
203
+ return [...index.values()]
204
+ },
205
+ /** 已到期的 entry(`notBefore <= now`)。 */
206
+ due(at) {
207
+ const out = []
208
+ for (const entry of index.values()) if (entry.notBefore <= at) out.push(entry)
209
+ return out
210
+ },
211
+ /**
212
+ * 摘出超过 TTL 的 pending。**不静默**:返回被丢弃的 entry,
213
+ * 由调用方逐条落 `failed` 记录并释放去重键。
214
+ */
215
+ prune(at, ttlMs) {
216
+ const dropped = []
217
+ for (const entry of index.values()) {
218
+ if (at - entry.draft.scheduledAt > ttlMs) dropped.push(entry)
219
+ }
220
+ for (const entry of dropped) detach(entry)
221
+ return dropped
222
+ },
223
+ }
224
+ }
225
+
226
+ // ── 主体 ────────────────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * 创建 Dispatcher。
230
+ *
231
+ * @param {object} ctx 宿主上下文(透传给 `kernel.followup`)
232
+ * @param {{
233
+ * stateDir?: string, wakesFile?: string, throttleMs?: number, maxPerTarget?: number,
234
+ * queueTtlMs?: number, retryDeferredMs?: number,
235
+ * now?: () => number,
236
+ * timer?: { setTimeout: (fn: () => void, ms: number) => unknown, clearTimeout?: (h: unknown) => void },
237
+ * clearTimer?: (handle: unknown) => void,
238
+ * }} [config]
239
+ */
240
+ export function createDispatcher(ctx, config = {}) {
241
+ // ── 时间面(可注入)──
242
+ const now = typeof config.now === 'function' ? config.now : () => Date.now()
243
+ const timer = config.timer ?? {
244
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
245
+ clearTimeout: (handle) => clearTimeout(handle),
246
+ }
247
+ const clearTimer =
248
+ typeof config.clearTimer === 'function'
249
+ ? config.clearTimer
250
+ : (handle) => timer.clearTimeout?.(handle)
251
+
252
+ // ── 策略参数 ──
253
+ const throttleMs = Number.isFinite(config.throttleMs) ? config.throttleMs : DEFAULTS.throttleMs
254
+ const maxPerTarget = Number.isFinite(config.maxPerTarget) ? config.maxPerTarget : DEFAULTS.maxPerTarget
255
+ const queueTtlMs = Number.isFinite(config.queueTtlMs) ? config.queueTtlMs : DEFAULTS.queueTtlMs
256
+ const retryDeferredMs = Number.isFinite(config.retryDeferredMs)
257
+ ? config.retryDeferredMs
258
+ : DEFAULTS.retryDeferredMs
259
+
260
+ // ── 存储面 ──
261
+ const stateDirectory = config.stateDir ?? kernel.stateDir()
262
+ const wakesFile = config.wakesFile ?? statePath(stateDirectory, 'wakes.jsonl')
263
+ const probe = probeWritable(wakesFile)
264
+
265
+ // ── 内存状态 ──
266
+ const dedup = new IdempotencySet(DEDUP_MAX)
267
+ /** @type {Map<string, number>} target → 上一次**投递成功**时刻(节流依据:只算真正送达的) */
268
+ const lastSentAt = new Map()
269
+ /** @type {Map<string, number>} dedupKey → 上次落 skipped 记录的时刻(落盘折叠用) */
270
+ const skipRecordedAt = new Map()
271
+ const queue = createQueue()
272
+ const counters = { total: 0, sent: 0, skipped: 0, failed: 0, deferred: 0, backpressured: 0, pruned: 0 }
273
+
274
+ /**
275
+ * 单调序号:创建时读一次盘取初值,之后**内存自增**(避免每条 wake 都全文件扫描)。
276
+ * 本 Dispatcher 是该文件的唯一写者,故内存计数即正确。
277
+ */
278
+ let seqCounter = nextSeq(wakesFile)
279
+ const takeSeq = () => (seqCounter += 1)
280
+
281
+ /**
282
+ * 从 `wakes.jsonl` 重建去重键(**重启后去重仍然有效** —— 这正是「不重复唤醒」的持久保证)。
283
+ *
284
+ * 有例外:被显式**释放**的键(`keyReleased:true`,见背压 / TTL 过期 / 落盘失败三处)
285
+ * 必须**不**重建 —— 否则队列疏通后上游用同一个 dedupKey 重发会被自己历史记录挡住。
286
+ * 按落盘顺序遍历,后写的一条覆盖前一条,故「先失败(保留键) → 后被释放」也能正确还原。
287
+ *
288
+ * @returns {number} 重建出的键数
289
+ */
290
+ function rebuildDedup() {
291
+ const { rows } = readJsonl(wakesFile)
292
+ let rebuilt = 0
293
+ for (const row of rows) {
294
+ const key = row?.dedupKey
295
+ if (!key) continue
296
+ if (row.keyReleased === true) {
297
+ dedup.keys.delete(key)
298
+ continue
299
+ }
300
+ if (!dedup.keys.has(key)) {
301
+ dedup.keys.add(key)
302
+ rebuilt += 1
303
+ }
304
+ }
305
+ return rebuilt
306
+ }
307
+
308
+ // 重启后去重仍然有效:已有记录里的 dedupKey 一律视为「已消费」。
309
+ rebuildDedup()
310
+
311
+ // ── 定时器 ──
312
+ let retryHandle = null
313
+ let timerActive = false
314
+ let flushing = false
315
+
316
+ // ── 落盘 ──────────────────────────────────────────────────────────────────
317
+
318
+ /**
319
+ * 落一条**终态**记录(`result` 严格 ∈ §3.5 三值)。
320
+ *
321
+ * @param {object} draft
322
+ * @param {'sent'|'skipped'|'failed'} result
323
+ * @param {{ at?: number, detail?: string, keyReleased?: boolean }} [options]
324
+ * `keyReleased:true` 表示**本记录的 dedupKey 已被释放**(该请求未被接受,
325
+ * 或队龄超限被丢弃)—— 供重启后 `rebuildDedup()` 正确还原去重状态。
326
+ * @returns {{ wake: object, durable: boolean }}
327
+ */
328
+ function commit(draft, result, { at = now(), detail, keyReleased } = {}) {
329
+ const wake = {
330
+ id: draft.id,
331
+ target: draft.target,
332
+ reason: draft.reason,
333
+ dedupKey: draft.dedupKey,
334
+ scheduledAt: draft.scheduledAt,
335
+ sentAt: result === 'sent' ? at : undefined,
336
+ result,
337
+ // ── 平台扩展字段(§3.5 未列,但对「可读原因」与可审计是必需的)──
338
+ text: draft.text,
339
+ updatedAt: at,
340
+ seq: takeSeq(),
341
+ }
342
+ if (detail) wake.detail = detail
343
+ if (keyReleased) wake.keyReleased = true
344
+ const durable = Boolean(appendJsonl(wakesFile, wake))
345
+ return { wake, durable }
346
+ }
347
+
348
+ /** 构造 wake 草稿(此刻尚无终态;`result` 只在 `commit` 时赋值)。 */
349
+ function draftWake({ target, reason, dedupKey, text, at }) {
350
+ return {
351
+ id: `wake-${at}-${process.pid.toString(36)}-${takeSeq().toString(36)}`,
352
+ target,
353
+ reason,
354
+ dedupKey,
355
+ text,
356
+ scheduledAt: at,
357
+ }
358
+ }
359
+
360
+ // ── 投递 ──────────────────────────────────────────────────────────────────
361
+
362
+ /**
363
+ * 把一个 entry 真正投递出去(单次尝试,同步)。
364
+ *
365
+ * 无论成功失败都**摘队 + 落终态记录**,且失败时**保留** dedupKey(intent-first)。
366
+ */
367
+ function deliver(entry) {
368
+ const draft = entry.draft
369
+ const at = now()
370
+ const attempt = kernel.followup(ctx, draft.target, draft.text ?? '')
371
+
372
+ queue.remove(entry)
373
+
374
+ if (attempt?.ok) {
375
+ lastSentAt.set(draft.target, at)
376
+ counters.sent += 1
377
+ const { wake, durable } = commit(draft, 'sent', { at })
378
+ return { ok: true, wakeId: wake.id, wake, durable }
379
+ }
380
+
381
+ const detail = attempt?.reason ?? 'followup-unknown-failure'
382
+ counters.failed += 1
383
+ const { wake, durable } = commit(draft, 'failed', { at, detail })
384
+ return { ok: false, reason: detail, detail, wakeId: wake.id, wake, durable }
385
+ }
386
+
387
+ // ── 巡检 ──────────────────────────────────────────────────────────────────
388
+
389
+ function scheduleRetry() {
390
+ if (timerActive || queue.total() === 0) return
391
+ timerActive = true
392
+ retryHandle = timer.setTimeout(() => {
393
+ timerActive = false
394
+ retryHandle = null
395
+ flush()
396
+ }, retryDeferredMs)
397
+ }
398
+
399
+ function cancelRetry() {
400
+ if (retryHandle != null) {
401
+ try {
402
+ clearTimer(retryHandle)
403
+ } catch {
404
+ /* 清理失败不影响正确性:下次巡检会重排 */
405
+ }
406
+ retryHandle = null
407
+ }
408
+ timerActive = false
409
+ }
410
+
411
+ /**
412
+ * 清理超过 TTL 的 pending —— **丢弃必须可见**。
413
+ *
414
+ * 逐条落 `failed` 记录(`detail:'queue-ttl-expired'`),并**释放去重键**:
415
+ * 否则该任务会被一个已经死掉的延迟唤醒**永久静默** —— 那是「静默停摆」的另一种形态。
416
+ */
417
+ function pruneExpired() {
418
+ const dropped = queue.prune(now(), queueTtlMs)
419
+ for (const entry of dropped) {
420
+ const draft = entry.draft
421
+ counters.failed += 1
422
+ counters.pruned += 1
423
+ if (entry.keyHeld && draft.dedupKey) dedup.keys.delete(draft.dedupKey)
424
+ commit(draft, 'failed', { detail: 'queue-ttl-expired', keyReleased: true })
425
+ }
426
+ return dropped.length
427
+ }
428
+
429
+ // ── 公开接口 ──────────────────────────────────────────────────────────────
430
+
431
+ /**
432
+ * 请求一次唤醒。**唯一入口**。
433
+ *
434
+ * ### 返回值的 `result` 语义(诚实优先)
435
+ *
436
+ * `Wake.result`(**落盘**)严格取自 §3.5 的三值 `'sent' | 'skipped' | 'failed'`;
437
+ * 而 `notify()` 的**返回值**多一个 `'pending'`,表示「已受理、已入队、尚未投递」
438
+ * (被节流推迟,或显式传了 `delayMs`)。
439
+ *
440
+ * 为什么不把 `'pending'` 谎报成 `'sent'`:那正是本模块要根除的**静默失败** ——
441
+ * 调用方会以为 owner 已经被唤醒了,而实际上它还在队列里。`'pending'` 不是失败:
442
+ * 调用方用 `deferred:true` / `stats().pending` / 后续 `flush()` 跟踪其最终落点。
443
+ *
444
+ * @param {{
445
+ * target: string, reason: string, dedupKey?: string, text?: string, delayMs?: number,
446
+ * }} request
447
+ * @returns {{ ok: boolean, result: 'sent'|'skipped'|'failed'|'pending', wakeId?: string,
448
+ * reason?: string, durable?: boolean, deferred?: boolean }}
449
+ */
450
+ function notify(request = {}) {
451
+ const at = now()
452
+ const { target, reason, dedupKey, text, delayMs } = request
453
+ const wakeText = text ?? `[cs-bots 唤醒] ${REASON_TEXT[reason] ?? String(reason)}`
454
+
455
+ // 每调用一次 = 一次决策,先计数(恒等式 total === sent + skipped + failed + pending 由此成立)。
456
+ counters.total += 1
457
+
458
+ // ── 输入校验:不合法就**明确拒绝**,绝不悄悄接受 ──
459
+ if (typeof target !== 'string' || target.length === 0) {
460
+ counters.failed += 1
461
+ return { ok: false, result: 'failed', reason: 'invalid-target', durable: probe.writable }
462
+ }
463
+ if (!WAKE_REASONS.includes(reason)) {
464
+ counters.failed += 1
465
+ const draft = draftWake({ target, reason: String(reason), dedupKey, text: wakeText, at })
466
+ const { wake, durable } = commit(draft, 'failed', { at, detail: `invalid-reason:${reason}` })
467
+ return { ok: false, result: 'failed', wakeId: wake.id, reason: 'invalid-reason', durable }
468
+ }
469
+
470
+ // ── 纪律 2 之去重:命中即 skipped,不重复唤醒 ──
471
+ if (dedupKey && !dedup.check(dedupKey)) {
472
+ counters.skipped += 1
473
+ const last = skipRecordedAt.get(dedupKey)
474
+ let wakeId
475
+ let durable = probe.writable
476
+ if (last === undefined || at - last >= SKIP_RECORD_INTERVAL_MS) {
477
+ const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
478
+ const committed = commit(draft, 'skipped', { at })
479
+ wakeId = committed.wake.id
480
+ durable = committed.durable
481
+ skipRecordedAt.set(dedupKey, at)
482
+ if (skipRecordedAt.size > DEDUP_MAX) {
483
+ skipRecordedAt.delete(skipRecordedAt.keys().next().value)
484
+ }
485
+ }
486
+ return { ok: true, result: 'skipped', wakeId, reason: 'dedup-hit', durable }
487
+ }
488
+
489
+ // ── K4 背压:队列超限 → 拒绝 + **可见失败记录**,绝不静默丢弃 ──
490
+ if (queue.sizeOf(target) >= maxPerTarget) {
491
+ counters.backpressured += 1
492
+ counters.failed += 1
493
+ const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
494
+ // 请求被**拒绝**(而非「受理后没送成」)⇒ 该 dedupKey 尚未被消费,释放它,
495
+ // 队列疏通后上游仍可用同一个 dedupKey 重发。
496
+ if (dedupKey) dedup.keys.delete(dedupKey)
497
+ const { wake, durable } = commit(draft, 'failed', {
498
+ at,
499
+ detail: `queue-full:${queue.sizeOf(target)}/${maxPerTarget}`,
500
+ keyReleased: Boolean(dedupKey),
501
+ })
502
+ return { ok: false, result: 'failed', wakeId: wake.id, reason: 'backpressure', durable }
503
+ }
504
+
505
+ // ── K4 时间窗:窗内则**延后**(不丢唤醒),显式 delayMs 则按其推迟 ──
506
+ let notBefore = at
507
+ let deferred = false
508
+ if (delayMs == null) {
509
+ const since = lastSentAt.get(target)
510
+ if (since !== undefined && at - since < throttleMs) {
511
+ notBefore = since + throttleMs
512
+ deferred = true
513
+ }
514
+ } else {
515
+ notBefore = at + Math.max(0, Number(delayMs) || 0)
516
+ deferred = notBefore > at
517
+ }
518
+
519
+ const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
520
+ const entry = { draft, notBefore, keyHeld: Boolean(dedupKey) }
521
+ queue.add(entry)
522
+ if (deferred) counters.deferred += 1
523
+
524
+ // 已到期 → 立刻投递;否则挂起,由 timer 驱动(到期清理 + 投递)。
525
+ if (entry.notBefore <= at) {
526
+ const outcome = deliver(entry)
527
+ if (queue.total() > 0) scheduleRetry()
528
+ if (outcome.ok) return { ok: true, result: 'sent', wakeId: outcome.wakeId, durable: outcome.durable }
529
+ return {
530
+ ok: false,
531
+ result: 'failed',
532
+ wakeId: outcome.wakeId,
533
+ reason: outcome.reason,
534
+ durable: outcome.durable,
535
+ }
536
+ }
537
+
538
+ // 已受理但尚未投递 —— **如实报 'pending'**,并明说推迟原因。
539
+ scheduleRetry()
540
+ return {
541
+ ok: true,
542
+ result: 'pending',
543
+ wakeId: draft.id,
544
+ reason: delayMs != null ? 'scheduled-delay' : 'throttled',
545
+ durable: probe.writable,
546
+ deferred: true,
547
+ }
548
+ }
549
+
550
+ /**
551
+ * 立即投递 pending 唤醒(供测试与**关机前**调用)。
552
+ *
553
+ * @param {{ force?: boolean }} [options] `force:true` 时无视节流推迟,投递**全部** pending
554
+ * (关机前正确用法:与其让它们 24h 后过期成 `failed`,不如现在就投出去)
555
+ * @returns {{ ok: boolean, sent: number, failed: number, pruned: number, remaining: number }}
556
+ */
557
+ function flush(options = {}) {
558
+ if (flushing) {
559
+ return { ok: true, sent: 0, failed: 0, pruned: 0, remaining: queue.total() }
560
+ }
561
+ flushing = true
562
+ try {
563
+ cancelRetry()
564
+ const pruned = pruneExpired()
565
+ const at = now()
566
+ const pending = options.force ? queue.all() : queue.due(at)
567
+
568
+ // 按 `scheduledAt`(同刻按 seq)升序投递 —— 同 target 的多次唤醒保持**入队顺序**,
569
+ // 避免「后来的先执行」把 owner 引到错误的任务状态上。
570
+ pending.sort(
571
+ (a, b) =>
572
+ a.draft.scheduledAt - b.draft.scheduledAt || a.draft.id.localeCompare(b.draft.id),
573
+ )
574
+
575
+ let sent = 0
576
+ let failed = 0
577
+ for (const entry of pending) {
578
+ const outcome = deliver(entry)
579
+ if (outcome.ok) sent += 1
580
+ else failed += 1
581
+ }
582
+
583
+ if (queue.total() > 0) scheduleRetry()
584
+ return { ok: true, sent, failed, pruned, remaining: queue.total() }
585
+ } finally {
586
+ flushing = false
587
+ }
588
+ }
589
+
590
+ /**
591
+ * 计数快照。恒等式:`total === sent + skipped + failed + pending`。
592
+ *
593
+ * `total` 统计的是**决策数**(每次 `notify` 的判定),因此尚在排队的唤醒
594
+ * 计入 `pending` 而非 `sent`。
595
+ *
596
+ * @returns {{ total: number, sent: number, skipped: number, failed: number, pending: number,
597
+ * deferred: number, backpressured: number, pruned: number, targets: number }}
598
+ */
599
+ function stats() {
600
+ return {
601
+ total: counters.total,
602
+ sent: counters.sent,
603
+ skipped: counters.skipped,
604
+ failed: counters.failed,
605
+ pending: queue.total(),
606
+ // ── 诊断扩展(不属于 §3.5 最小集,排查时必需)──
607
+ deferred: counters.deferred,
608
+ backpressured: counters.backpressured,
609
+ pruned: counters.pruned,
610
+ targets: queue.targets(),
611
+ }
612
+ }
613
+
614
+ /**
615
+ * 从 `wakes.jsonl` 读唤醒记录(**读盘,不是读内存** —— 进程重启后仍可审计)。
616
+ *
617
+ * @param {{ target?: string, limit?: number }} [options]
618
+ * @returns {object[]} 终态 Wake 记录(落盘顺序;有 `limit` 时取**最后** limit 条)
619
+ */
620
+ function list(options = {}) {
621
+ const { target, limit } = options
622
+ // 先按 limit 取尾,再按 target 过滤 —— 语义是「最近 limit 条里属于该 target 的」。
623
+ const { rows, bad, total } = readJsonl(wakesFile, limit ? { limit } : {})
624
+ const filtered = target ? rows.filter((row) => row?.target === target) : rows
625
+ // 坏行**不静默**:作为非枚举属性挂上,调用方可用 `Object.keys` 忽略、也可显式读取。
626
+ Object.defineProperty(filtered, 'bad', { value: bad, enumerable: false })
627
+ Object.defineProperty(filtered, 'total', { value: total, enumerable: false })
628
+ return filtered
629
+ }
630
+
631
+ return {
632
+ notify,
633
+ flush,
634
+ stats,
635
+ list,
636
+ /** 只读诊断:状态目录 / 文件路径 / 是否可写。 */
637
+ paths: { stateDir: stateDirectory, wakesFile },
638
+ /** 状态目录可否持久化;`false` ⇒ 所有返回值都会带 `durable:false`。 */
639
+ durable: probe.writable,
640
+ probeError: probe.probeError,
641
+ /**
642
+ * 关机收尾:停掉定时器(避免进程退出被挂起的 timer 拖住)。
643
+ * **不投递** —— 投递请显式调用 `flush({ force: true })`。
644
+ */
645
+ dispose() {
646
+ cancelRetry()
647
+ },
648
+ }
649
+ }
650
+
651
+ export default createDispatcher