cs-bots 0.1.1 → 0.7.5

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/README.md +65 -10
  3. package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
  4. package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
  5. package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
  6. package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
  7. package/docs/ARCHITECTURE.md +277 -0
  8. package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
  9. package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
  10. package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
  11. package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
  12. package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
  13. package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
  14. package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
  15. package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
  16. package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
  17. package/package.json +4 -2
  18. package/src/admission.js +365 -0
  19. package/src/api.js +4097 -420
  20. package/src/board.js +883 -14
  21. package/src/botprofile.js +190 -3
  22. package/src/client.js +6596 -1082
  23. package/src/cycle.js +516 -0
  24. package/src/delivery-watch.js +255 -0
  25. package/src/dirbrowse.js +20 -0
  26. package/src/discipline.js +1188 -0
  27. package/src/fence.js +142 -0
  28. package/src/index.js +387 -4
  29. package/src/kernel.js +636 -24
  30. package/src/library.js +1224 -11
  31. package/src/memory.js +617 -37
  32. package/src/metrics.js +339 -0
  33. package/src/prompts.js +1318 -0
  34. package/src/reviewer.js +709 -0
  35. package/src/room.js +655 -14
  36. package/src/roster.js +346 -26
  37. package/src/routing/complexity.js +320 -0
  38. package/src/routing/hints.js +707 -0
  39. package/src/scratch.js +477 -0
  40. package/src/settings.js +97 -0
  41. package/src/stage.js +263 -0
  42. package/src/store.js +133 -1
  43. package/src/subscription.js +571 -0
  44. package/src/suggest.js +1203 -0
  45. package/src/teams.js +527 -0
  46. package/src/todo.js +1301 -0
  47. package/src/tools.js +734 -10
  48. package/src/verify.js +356 -0
@@ -0,0 +1,571 @@
1
+ /**
2
+ * cs-bots SubscriptionService —— 任务订阅(改造07 · 借鉴 Super-agent 的 Subscription 扇出)。
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * 现有协作里有一个**断点**:房间里的讨论在推进,但相关任务**看不到**。
7
+ * 人只好回到任务里手动补一句「刚才房间说……」,或者重新 @ Bot 一次。
8
+ *
9
+ * Super-agent 的 Subscription 解决了这个:一次性订阅后,
10
+ * Thread 上的**后续消息自动扇出**给订阅它的 Ticket——讨论进展自动到达,
11
+ * 不需要再点名。
12
+ *
13
+ * ## 本模块的形态(与 Super-agent 的差异,写清避免误读)
14
+ *
15
+ * | | Super-agent | cs-bots(本模块) |
16
+ * |---|---|---|
17
+ * | 订阅主体 | Ticket ↔ Thread | **Task ↔ Room** |
18
+ * | 扇出后果 | 消息作为上下文 Input 送达执行器 | 消息作为**任务上下文**追加,并**可选**唤醒 owner |
19
+ * | 授权影响 | 只给上下文,不授予新权力 | 同:**只给上下文,不改变任务状态** |
20
+ * | 生成方式 | 模型/人订阅 | 人/模型订阅(显式) |
21
+ *
22
+ * **刻意不做的事**:扇出不改变任务状态、不授予「可以开工」的权力——
23
+ * 与 Super-agent 的设计一致(Subscription 只加 context,不加 authority)。
24
+ * 谁有权开工仍由任务本身的 claim 决定。
25
+ *
26
+ * ## 四条不变量
27
+ *
28
+ * 1. **事件流是真相源**:先 append `subscription.jsonl`,再改内存,最后写快照。
29
+ * 2. **CAS**:所有变更带 `expectedRev`;不匹配即抛错,绝不静默覆盖。
30
+ * 3. **幂等**:同一 (taskId, roomId) 只存在一条活订阅;重复 subscribe 返回既有记录。
31
+ * 4. **绝不静默失败**:订阅/取消订阅/扇出全部有明确返回值与可读原因。
32
+ *
33
+ * ## 扇出节流(K4 类比)
34
+ *
35
+ * 房间发消息可以很频繁,每条都扇出会让任务上下文爆炸。
36
+ * 故扇出有两条约束:
37
+ * - 每次扇出只保留**最近 N 条**(默认 20,`fanoutKeep`),滚动的上下文窗口;
38
+ * - **唤醒**默认关闭(`wakeOnFanout:false`)——上下文到达不等于要打扰执行者。
39
+ *
40
+ * ## 依赖面
41
+ *
42
+ * node 内置 + `./store.js` + `./kernel.js`(惰性)。零第三方依赖。
43
+ */
44
+
45
+ import { appendJsonl, readJsonl, readJson, writeJsonAtomic, ensureDir, statePath } from './store.js'
46
+ import { decideAdmission, ADMISSIONS, DEFAULT_ACCEPT_MODE, ACCEPT_MODES } from './admission.js'
47
+
48
+ let kernel = null
49
+ let kernelError = null
50
+ try {
51
+ kernel = await import('./kernel.js')
52
+ } catch (error) {
53
+ kernelError = error
54
+ }
55
+
56
+ /** 合法状态。 */
57
+ export const SUB_STATES = Object.freeze(['active', 'active_stopped', 'dropped'])
58
+
59
+ /** 事件动作集合。 */
60
+ const ACTIONS = ['subscribe', 'unsubscribe', 'fanout', 'drop']
61
+
62
+ /** 订阅上可配置的准入字段(改造09)。 */
63
+ const ADMISSION_FIELDS = ['acceptMode', 'rules', 'receivesEveryMessage']
64
+
65
+ /** 缺省配置。 */
66
+ const DEFAULTS = Object.freeze({
67
+ /** 每个任务保留的上下文条目上限(滚动窗口)。 */
68
+ fanoutKeep: 20,
69
+ /** 扇出后是否唤醒 owner(默认否:上下文到达不等于要打扰)。 */
70
+ wakeOnFanout: false,
71
+ })
72
+
73
+ /** 结构化错误。 */
74
+ export class SubscriptionError extends Error {
75
+ constructor(message, code = 'SUBSCRIPTION_ERROR', details) {
76
+ super(message)
77
+ this.name = 'SubscriptionError'
78
+ this.code = code
79
+ if (details !== undefined) this.details = details
80
+ }
81
+ }
82
+
83
+ function clone(value) {
84
+ if (value === undefined || value === null) return value
85
+ return JSON.parse(JSON.stringify(value))
86
+ }
87
+
88
+ function requireString(value, field, where) {
89
+ if (typeof value !== 'string' || value.trim() === '') {
90
+ throw new SubscriptionError(
91
+ `subscription.${where}:${field} 必须是非空字符串,收到 ${JSON.stringify(value)}`,
92
+ 'SUBSCRIPTION_BAD_FIELD',
93
+ { where, field },
94
+ )
95
+ }
96
+ return value.trim()
97
+ }
98
+
99
+ function optionalString(value, field, where) {
100
+ if (value === undefined || value === null) return undefined
101
+ if (typeof value !== 'string') {
102
+ throw new SubscriptionError(
103
+ `subscription.${where}:${field} 必须是字符串,收到 ${JSON.stringify(value)}`,
104
+ 'SUBSCRIPTION_BAD_FIELD',
105
+ { where, field },
106
+ )
107
+ }
108
+ return value
109
+ }
110
+
111
+ function normalizeNow(value, where) {
112
+ if (value === undefined || value === null) return Date.now()
113
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
114
+ throw new SubscriptionError(
115
+ `subscription.${where}:now 必须是有限数字,收到 ${JSON.stringify(value)}`,
116
+ 'SUBSCRIPTION_BAD_FIELD',
117
+ { where, field: 'now' },
118
+ )
119
+ }
120
+ return value
121
+ }
122
+
123
+ function resolveStateDir(config) {
124
+ if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') return config.stateDir.trim()
125
+ if (kernel && typeof kernel.stateDir === 'function') return kernel.stateDir()
126
+ const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
127
+ throw new SubscriptionError(
128
+ `subscription.createSubscription:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`,
129
+ 'SUBSCRIPTION_NO_STATE_DIR',
130
+ )
131
+ }
132
+
133
+ /**
134
+ * 创建订阅服务。
135
+ *
136
+ * @param {{ logger?: { info?: Function, warn?: Function } }} [ctx]
137
+ * @param {{ stateDir?: string, fanoutKeep?: number, wakeOnFanout?: boolean, now?: () => number }} [config]
138
+ * @returns {object} subscription service
139
+ */
140
+ export function createSubscription(ctx, config = {}) {
141
+ const stateDir = resolveStateDir(config)
142
+ const jsonlPath = statePath(stateDir, 'subscription.jsonl')
143
+ const snapPath = statePath(stateDir, 'subscription.snap.json')
144
+ const fanoutKeep = Number.isFinite(config.fanoutKeep) && config.fanoutKeep > 0
145
+ ? Math.floor(config.fanoutKeep)
146
+ : DEFAULTS.fanoutKeep
147
+ const wakeOnFanout = config.wakeOnFanout === true
148
+ const now = typeof config.now === 'function' ? config.now : () => Date.now()
149
+
150
+ const info = (...args) => { try { ctx?.logger?.info?.(...args) } catch { /* */ } }
151
+ const warn = (...args) => { try { ctx?.logger?.warn?.(...args) } catch { /* */ } }
152
+
153
+ /** @type {Map<string, object>} 订阅 id → Subscription */
154
+ const subs = new Map()
155
+ /** 索引:(taskId, roomId) → subId(活订阅唯一) */
156
+ const liveIndex = new Map()
157
+ /** 全部事件(审计) */
158
+ const events = []
159
+ let idCounter = 0
160
+
161
+ const diagnostics = {
162
+ stateDir,
163
+ jsonlPath,
164
+ snapPath,
165
+ hydratedFrom: null,
166
+ replayedEvents: 0,
167
+ badLines: 0,
168
+ snapshotWriteFailures: 0,
169
+ fanouts: 0,
170
+ wakeFailures: 0,
171
+ }
172
+
173
+ function snapshot() {
174
+ return { version: 1, savedAt: Date.now(), idCounter, subs: [...subs.values()] }
175
+ }
176
+
177
+ /**
178
+ * 提交一次变更:**先落事件,再改内存,最后写快照**。顺序不可颠倒。
179
+ */
180
+ function commit(action, at, by, before, after) {
181
+ if (!appendJsonl(jsonlPath, { id: after.id, rev: after.rev, action, by, at, before, after })) {
182
+ throw new SubscriptionError(
183
+ `subscription.${action}:事件追加失败(${jsonlPath})——拒绝「只改内存不落盘」`,
184
+ 'SUBSCRIPTION_APPEND_FAILED',
185
+ { action },
186
+ )
187
+ }
188
+ subs.set(after.id, clone(after))
189
+ events.push({ id: after.id, rev: after.rev, action, by, at, before: before ? clone(before) : null, after: clone(after) })
190
+ if (after.state === 'active') liveIndex.set(keyOf(after.taskId, after.roomId), after.id)
191
+ else liveIndex.delete(keyOf(after.taskId, after.roomId))
192
+ if (!writeJsonAtomic(snapPath, snapshot())) {
193
+ diagnostics.snapshotWriteFailures += 1
194
+ warn(`[cs-bots/subscription] 快照写入失败(${snapPath})——事件流已落盘,replay() 可恢复`)
195
+ }
196
+ return clone(after)
197
+ }
198
+
199
+ function keyOf(taskId, roomId) {
200
+ return `${taskId}::${roomId}`
201
+ }
202
+
203
+ function hydrate() {
204
+ subs.clear()
205
+ liveIndex.clear()
206
+ events.length = 0
207
+ idCounter = 0
208
+ const { rows, bad } = readJsonl(jsonlPath)
209
+ diagnostics.badLines = bad
210
+ diagnostics.replayedEvents = rows.length
211
+ if (bad > 0) warn(`[cs-bots/subscription] ${jsonlPath} 有 ${bad} 行无法解析——已跳过,请核对`)
212
+ if (rows.length === 0) {
213
+ const snap = readJson(snapPath, null)
214
+ const list = Array.isArray(snap?.subs) ? snap.subs : null
215
+ if (list) {
216
+ for (const raw of list) {
217
+ if (!raw || typeof raw.id !== 'string') continue
218
+ subs.set(raw.id, clone(raw))
219
+ if (raw.state === 'active') liveIndex.set(keyOf(raw.taskId, raw.roomId), raw.id)
220
+ const m = /^sub-(\d+)$/.exec(raw.id)
221
+ if (m) idCounter = Math.max(idCounter, Number(m[1]))
222
+ }
223
+ if (Number.isFinite(snap?.idCounter)) idCounter = Math.max(idCounter, snap.idCounter)
224
+ diagnostics.hydratedFrom = 'snapshot'
225
+ info(`[cs-bots/subscription] 回放完成:由快照恢复 ${subs.size} 条订阅`)
226
+ return
227
+ }
228
+ diagnostics.hydratedFrom = 'empty'
229
+ return
230
+ }
231
+ let skipped = 0
232
+ for (const ev of rows) {
233
+ if (!ev || typeof ev.id !== 'string' || !ev.after) { skipped += 1; continue }
234
+ const cur = subs.get(ev.id)
235
+ if (cur && Number.isFinite(ev.rev) && ev.rev <= cur.rev) { skipped += 1; continue }
236
+ subs.set(ev.id, clone(ev.after))
237
+ events.push(clone(ev))
238
+ if (ev.after.state === 'active') liveIndex.set(keyOf(ev.after.taskId, ev.after.roomId), ev.after.id)
239
+ else liveIndex.delete(keyOf(ev.after.taskId, ev.after.roomId))
240
+ const m = /^sub-(\d+)$/.exec(ev.id)
241
+ if (m) idCounter = Math.max(idCounter, Number(m[1]))
242
+ }
243
+ if (skipped > 0) warn(`[cs-bots/subscription] 回放跳过 ${skipped} 条非递增/残缺事件`)
244
+ diagnostics.hydratedFrom = 'jsonl'
245
+ }
246
+
247
+ function requireSub(id, where) {
248
+ const s = subs.get(id)
249
+ if (!s) {
250
+ throw new SubscriptionError(
251
+ `subscription.${where}:订阅 ${id} 不存在`,
252
+ 'SUBSCRIPTION_NOT_FOUND',
253
+ { where, id },
254
+ )
255
+ }
256
+ return s
257
+ }
258
+
259
+ function cas(id, expectedRev, where) {
260
+ const s = requireSub(id, where)
261
+ if (expectedRev !== undefined && expectedRev !== null) {
262
+ if (!Number.isInteger(expectedRev)) {
263
+ throw new SubscriptionError(
264
+ `subscription.${where}:expectedRev 必须是整数(当前 rev=${s.rev})`,
265
+ 'SUBSCRIPTION_BAD_FIELD', { where, field: 'expectedRev' },
266
+ )
267
+ }
268
+ if (s.rev !== expectedRev) {
269
+ throw new SubscriptionError(
270
+ `subscription.${where} CAS 冲突:订阅 ${id} 期望 rev=${expectedRev},实际 rev=${s.rev} —— 拒绝静默覆盖`,
271
+ 'SUBSCRIPTION_REV_CONFLICT', { where, id, expectedRev, rev: s.rev },
272
+ )
273
+ }
274
+ }
275
+ return s
276
+ }
277
+
278
+ // ── 写 ────────────────────────────────────────────────────────────────────
279
+
280
+ /**
281
+ * 归一化订阅上的准入配置(改造09)。
282
+ *
283
+ * 只认白名单字段 —— 未知字段一律不写,与 board 的「绝不发明字段」同口径。
284
+ * 非法模式值退回缺省 `observe`(**绝不退回 `auto`**:那等于偷偷放权)。
285
+ *
286
+ * @param {object} input
287
+ * @returns {{ acceptMode: string, rules?: object[], receivesEveryMessage?: boolean }}
288
+ */
289
+ function normalizeAdmission(input) {
290
+ const out = { acceptMode: DEFAULT_ACCEPT_MODE }
291
+ const mode = input && typeof input === 'object' ? input.acceptMode : undefined
292
+ if (ACCEPT_MODES.includes(mode)) out.acceptMode = mode
293
+ if (Array.isArray(input?.rules)) out.rules = input.rules
294
+ // ⚠️ 房间订阅缺省 `receivesEveryMessage: true` —— 房间是群聊,
295
+ // 成员本来就**看到所有消息**。这不是放权,而是对事实的承认:
296
+ // 在群聊里「没点名」不等于「与我无关」。`false` 用于「只收点名」的场景。
297
+ out.receivesEveryMessage = input?.receivesEveryMessage !== false
298
+ return out
299
+ }
300
+
301
+ /**
302
+ * 订阅:让任务接收某个房间的后续消息。
303
+ *
304
+ * **幂等**:同一 (taskId, roomId) 已有活订阅时,直接返回既有记录
305
+ * (不抛错、不新建——重复订阅是无害的用户操作)。
306
+ *
307
+ * @param {{ taskId: string, roomId: string, note?: string, by: string, now?: number }} input
308
+ * @returns {object} Subscription
309
+ */
310
+ function subscribe(input = {}) {
311
+ const { taskId, roomId, note, by, now: at0 } = input ?? {}
312
+ const tId = requireString(taskId, 'taskId', 'subscribe')
313
+ const rId = requireString(roomId, 'roomId', 'subscribe')
314
+ const author = requireString(by, 'by', 'subscribe')
315
+ const n = optionalString(note, 'note', 'subscribe')
316
+ const at = normalizeNow(at0, 'subscribe')
317
+
318
+ // 幂等:已有活订阅 → 原样返回
319
+ const existingId = liveIndex.get(keyOf(tId, rId))
320
+ if (existingId) {
321
+ const existing = subs.get(existingId)
322
+ if (existing && existing.state === 'active') {
323
+ info(`[cs-bots/subscription] subscribe 幂等命中 ${tId} ↔ ${rId} → 复用 ${existingId}`)
324
+ return clone(existing)
325
+ }
326
+ }
327
+
328
+ idCounter += 1
329
+ const id = `sub-${idCounter}`
330
+ /** @type {Record<string, unknown>} */
331
+ const sub = {
332
+ id,
333
+ rev: 1,
334
+ taskId: tId,
335
+ roomId: rId,
336
+ state: 'active',
337
+ context: [],
338
+ fanoutCount: 0,
339
+ createdAt: at,
340
+ createdBy: author,
341
+ updatedAt: at,
342
+ updatedBy: author,
343
+ }
344
+ if (n !== undefined) sub.note = n
345
+ // 改造09:订阅自带准入配置(缺省 observe —— 最保守的一档)
346
+ const adm = normalizeAdmission(input)
347
+ sub.acceptMode = adm.acceptMode
348
+ if (adm.rules !== undefined) sub.rules = adm.rules
349
+ if (adm.receivesEveryMessage !== undefined) sub.receivesEveryMessage = adm.receivesEveryMessage
350
+ commit('subscribe', at, author, null, sub)
351
+ info(`[cs-bots/subscription] ${id}:任务 ${tId} 订阅房间 ${rId}`)
352
+ return clone(sub)
353
+ }
354
+
355
+ /**
356
+ * 取消订阅(活 → dropped,终态)。
357
+ *
358
+ * @param {{ id: string, expectedRev?: number, by: string, now?: number }} input
359
+ * @returns {object} Subscription
360
+ */
361
+ function unsubscribe(input = {}) {
362
+ const { id, expectedRev, by, now: at0 } = input ?? {}
363
+ const author = requireString(by, 'by', 'unsubscribe')
364
+ const at = normalizeNow(at0, 'unsubscribe')
365
+ const cur = cas(id, expectedRev, 'unsubscribe')
366
+ if (cur.state === 'dropped') {
367
+ throw new SubscriptionError(
368
+ `subscription.unsubscribe:订阅 ${id} 已是 dropped(终态),无法再次取消`,
369
+ 'SUBSCRIPTION_ALREADY_DROPPED', { id },
370
+ )
371
+ }
372
+ const next = clone(cur)
373
+ next.rev = cur.rev + 1
374
+ next.state = 'dropped'
375
+ next.droppedAt = at
376
+ next.droppedBy = author
377
+ next.updatedAt = at
378
+ next.updatedBy = author
379
+ commit('unsubscribe', at, author, clone(cur), next)
380
+ return clone(next)
381
+ }
382
+
383
+ /**
384
+ * 扇出:把房间的一条消息送达所有订阅该房间的活任务。
385
+ *
386
+ * **只加上下文,不改任务状态**(与 Super-agent「Subscription 只加 context 不加 authority」一致)。
387
+ * 每条订阅的 `context` 是滚动窗口(默认保留最近 20 条)。
388
+ *
389
+ * @param {{ roomId: string, message: object, wake?: boolean, by?: string, now?: number }} input
390
+ * @returns {{ fanned: number, woke: number, failed: number, details: object[] }}
391
+ */
392
+ async function fanout(input = {}) {
393
+ const { roomId, message, wake } = input ?? {}
394
+ const rId = requireString(roomId, 'roomId', 'fanout')
395
+ if (!message || typeof message !== 'object') {
396
+ throw new SubscriptionError(
397
+ 'subscription.fanout:message 必须是消息对象',
398
+ 'SUBSCRIPTION_BAD_FIELD', { field: 'message' },
399
+ )
400
+ }
401
+ const at = normalizeNow(input.now, 'fanout')
402
+ const by = typeof input.by === 'string' && input.by ? input.by : 'system'
403
+ const shouldWake = wake === true || (wake === undefined && wakeOnFanout)
404
+
405
+ // 找出订阅该房间的全部活订阅
406
+ const targets = [...subs.values()].filter((s) => s.roomId === rId && s.state === 'active')
407
+ const details = []
408
+ let woke = 0
409
+ let failed = 0
410
+
411
+ for (const s of targets) {
412
+ // ── 改造09:Gate 判定(确定性、可审计)────────────────────────────
413
+ // superagent 把准入拆成 Gate + Assessment:Gate 判「是不是候选 / 明确请求」,
414
+ // 只有放行的才进上下文并唤醒。忽略掉的连上下文都不记(省 token、省打扰)。
415
+ const gate = decideAdmission({
416
+ message,
417
+ recipientId: s.taskId,
418
+ subscribed: s.state === 'active',
419
+ subscriptionState: s.state,
420
+ acceptMode: s.acceptMode,
421
+ rules: s.rules,
422
+ receivesEveryMessage: s.receivesEveryMessage !== false,
423
+ })
424
+
425
+ if (gate.admission === ADMISSIONS.IGNORE) {
426
+ details.push({
427
+ subId: s.id, taskId: s.taskId, ok: true, woke: false, admitted: false,
428
+ admission: gate.admission, decidedBy: gate.decidedBy, reason: gate.reason,
429
+ })
430
+ continue // 不记上下文 —— 不相关的消息不该占用执行者的上下文窗口
431
+ }
432
+
433
+ const entry = {
434
+ from: message.from ?? '?',
435
+ seq: message.seq ?? null,
436
+ text: typeof message.text === 'string' ? message.text.slice(0, 2000) : '',
437
+ at: message.at ?? at,
438
+ messageId: message.id ?? null,
439
+ admission: gate.admission, // 记下它是 explicit 还是 inferred(审计 / 评估用)
440
+ }
441
+ const next = clone(s)
442
+ next.rev = s.rev + 1
443
+ const ctx = Array.isArray(s.context) ? [...s.context, entry] : [entry]
444
+ // 滚动窗口:只留最近 fanoutKeep 条
445
+ next.context = ctx.length > fanoutKeep ? ctx.slice(-fanoutKeep) : ctx
446
+ next.fanoutCount = (s.fanoutCount ?? 0) + 1
447
+ next.lastFanoutAt = at
448
+ next.updatedAt = at
449
+ next.updatedBy = by
450
+ try {
451
+ commit('fanout', at, by, clone(s), next)
452
+ diagnostics.fanouts += 1
453
+ } catch (error) {
454
+ failed += 1
455
+ details.push({ subId: s.id, taskId: s.taskId, ok: false, reason: `commit-failed:${error?.message ?? error}` })
456
+ continue
457
+ }
458
+
459
+ // 唤醒 = 「全局开关允许」(shouldWake) AND 「这条消息是明确要你」(explicit)。
460
+ // 二者是 AND 而非替代:一个说「这次允许唤醒」,一个说「这条值得唤醒」。
461
+ // 候选(inferred)只记上下文,不打扰 —— 是否动手交给执行者评估。
462
+ if (shouldWake && gate.admission === ADMISSIONS.EXPLICIT) {
463
+ try {
464
+ const text = `[任务上下文] 房间 ${rId} 新消息(${entry.from},${gate.admission}):${entry.text.slice(0, 500)}`
465
+ const wakeResult = await wakeTask(s.taskId, text)
466
+ if (wakeResult.ok) woke += 1
467
+ else { failed += 1; diagnostics.wakeFailures += 1 }
468
+ details.push({ subId: s.id, taskId: s.taskId, ok: true, woke: wakeResult.ok, admitted: true, admission: gate.admission, wakeReason: wakeResult.reason })
469
+ } catch (error) {
470
+ failed += 1
471
+ diagnostics.wakeFailures += 1
472
+ details.push({ subId: s.id, taskId: s.taskId, ok: true, woke: false, admitted: true, admission: gate.admission, reason: `wake-failed:${error?.message ?? error}` })
473
+ }
474
+ } else {
475
+ details.push({ subId: s.id, taskId: s.taskId, ok: true, woke: false, admitted: true, admission: gate.admission, decidedBy: gate.decidedBy })
476
+ }
477
+ }
478
+
479
+ return { fanned: targets.length, woke, failed, details }
480
+ }
481
+
482
+ /** 唤醒某任务的 owner(未提供 dispatcher 时返回明确原因,不假装成功)。 */
483
+ async function wakeTask(taskId, text) {
484
+ if (!kernel) return { ok: false, reason: `kernel-unavailable:${kernelError?.message ?? 'unknown'}` }
485
+ const target = ctx?.services?.board
486
+ ? (() => {
487
+ try {
488
+ const t = ctx.services.board.get(taskId)
489
+ return t?.ownerId
490
+ } catch { return undefined }
491
+ })()
492
+ : undefined
493
+ if (!target) return { ok: false, reason: 'task-has-no-owner' }
494
+ try {
495
+ const out = kernel.followup(ctx, target, text)
496
+ return { ok: Boolean(out?.ok), reason: out?.reason }
497
+ } catch (error) {
498
+ return { ok: false, reason: String(error?.message ?? error) }
499
+ }
500
+ }
501
+
502
+ // ── 读 ────────────────────────────────────────────────────────────────────
503
+
504
+ function get(id) {
505
+ const s = subs.get(id)
506
+ return s ? clone(s) : null
507
+ }
508
+
509
+ function list(query = {}) {
510
+ let rows = [...subs.values()]
511
+ if (query?.taskId) rows = rows.filter((s) => s.taskId === query.taskId)
512
+ if (query?.roomId) rows = rows.filter((s) => s.roomId === query.roomId)
513
+ if (query?.state) rows = rows.filter((s) => s.state === query.state)
514
+ return clone(rows)
515
+ }
516
+
517
+ /** 某任务的全部活订阅(任务详情页显示「订阅了什么」)。 */
518
+ function listByTask(taskId) {
519
+ return clone([...subs.values()].filter((s) => s.taskId === taskId))
520
+ }
521
+
522
+ /** 某房间的订阅者任务 id 列表(房间页显示「谁在听」)。 */
523
+ function subscribersOf(roomId) {
524
+ return [...subs.values()]
525
+ .filter((s) => s.roomId === roomId && s.state === 'active')
526
+ .map((s) => s.taskId)
527
+ }
528
+
529
+ function stats() {
530
+ const byState = {}
531
+ for (const s of subs.values()) byState[s.state] = (byState[s.state] ?? 0) + 1
532
+ return {
533
+ total: subs.size,
534
+ active: byState.active ?? 0,
535
+ dropped: byState.dropped ?? 0,
536
+ byState,
537
+ fanouts: diagnostics.fanouts,
538
+ wakeFailures: diagnostics.wakeFailures,
539
+ events: events.length,
540
+ }
541
+ }
542
+
543
+ function replay() {
544
+ hydrate()
545
+ return { from: diagnostics.hydratedFrom, events: events.length, bad: diagnostics.badLines }
546
+ }
547
+
548
+ hydrate()
549
+
550
+ return {
551
+ name: config.name ?? 'subscription',
552
+ paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
553
+ subscribe,
554
+ unsubscribe,
555
+ fanout,
556
+ get,
557
+ list,
558
+ listByTask,
559
+ subscribersOf,
560
+ stats,
561
+ replay,
562
+ diagnostics: () => clone(diagnostics),
563
+ constants: Object.freeze({
564
+ SUB_STATES: Object.freeze([...SUB_STATES]),
565
+ ACTIONS: Object.freeze([...ACTIONS]),
566
+ DEFAULTS,
567
+ }),
568
+ }
569
+ }
570
+
571
+ export default createSubscription