cs-bots 0.7.5 → 0.9.0

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/dispatcher.js CHANGED
@@ -336,6 +336,8 @@ export function createDispatcher(ctx, config = {}) {
336
336
  result,
337
337
  // ── 平台扩展字段(§3.5 未列,但对「可读原因」与可审计是必需的)──
338
338
  text: draft.text,
339
+ // t-39 第2批 D:唤醒成本可见。无团队归属时是 null(不猜)。
340
+ teamId: draft.teamId ?? null,
339
341
  updatedAt: at,
340
342
  seq: takeSeq(),
341
343
  }
@@ -345,14 +347,22 @@ export function createDispatcher(ctx, config = {}) {
345
347
  return { wake, durable }
346
348
  }
347
349
 
348
- /** 构造 wake 草稿(此刻尚无终态;`result` 只在 `commit` 时赋值)。 */
349
- function draftWake({ target, reason, dedupKey, text, at }) {
350
+ /**
351
+ * 构造 wake 草稿(此刻尚无终态;`result` 只在 `commit` 时赋值)。
352
+ *
353
+ * `teamId`:唤醒成本可见(t-39 第2批 D)。**不是所有唤醒都属于某个团队**
354
+ * (例如任务派活、房间广播都可能与团队无关)—— 那种情况一律写 `null`,
355
+ * 绝不按 target 猜一个团队塞进去:猜出来的数字比没有更糟,它会让
356
+ * 「每团队唤醒成本」的统计凭空多出一份不属于它的开销。
357
+ */
358
+ function draftWake({ target, reason, dedupKey, text, at, teamId }) {
350
359
  return {
351
360
  id: `wake-${at}-${process.pid.toString(36)}-${takeSeq().toString(36)}`,
352
361
  target,
353
362
  reason,
354
363
  dedupKey,
355
364
  text,
365
+ teamId: teamId ?? null,
356
366
  scheduledAt: at,
357
367
  }
358
368
  }
@@ -449,7 +459,7 @@ export function createDispatcher(ctx, config = {}) {
449
459
  */
450
460
  function notify(request = {}) {
451
461
  const at = now()
452
- const { target, reason, dedupKey, text, delayMs } = request
462
+ const { target, reason, dedupKey, text, delayMs, teamId } = request
453
463
  const wakeText = text ?? `[cs-bots 唤醒] ${REASON_TEXT[reason] ?? String(reason)}`
454
464
 
455
465
  // 每调用一次 = 一次决策,先计数(恒等式 total === sent + skipped + failed + pending 由此成立)。
@@ -462,7 +472,7 @@ export function createDispatcher(ctx, config = {}) {
462
472
  }
463
473
  if (!WAKE_REASONS.includes(reason)) {
464
474
  counters.failed += 1
465
- const draft = draftWake({ target, reason: String(reason), dedupKey, text: wakeText, at })
475
+ const draft = draftWake({ target, reason: String(reason), dedupKey, text: wakeText, at, teamId })
466
476
  const { wake, durable } = commit(draft, 'failed', { at, detail: `invalid-reason:${reason}` })
467
477
  return { ok: false, result: 'failed', wakeId: wake.id, reason: 'invalid-reason', durable }
468
478
  }
@@ -474,7 +484,7 @@ export function createDispatcher(ctx, config = {}) {
474
484
  let wakeId
475
485
  let durable = probe.writable
476
486
  if (last === undefined || at - last >= SKIP_RECORD_INTERVAL_MS) {
477
- const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
487
+ const draft = draftWake({ target, reason, dedupKey, text: wakeText, at, teamId })
478
488
  const committed = commit(draft, 'skipped', { at })
479
489
  wakeId = committed.wake.id
480
490
  durable = committed.durable
@@ -490,7 +500,7 @@ export function createDispatcher(ctx, config = {}) {
490
500
  if (queue.sizeOf(target) >= maxPerTarget) {
491
501
  counters.backpressured += 1
492
502
  counters.failed += 1
493
- const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
503
+ const draft = draftWake({ target, reason, dedupKey, text: wakeText, at, teamId })
494
504
  // 请求被**拒绝**(而非「受理后没送成」)⇒ 该 dedupKey 尚未被消费,释放它,
495
505
  // 队列疏通后上游仍可用同一个 dedupKey 重发。
496
506
  if (dedupKey) dedup.keys.delete(dedupKey)
@@ -516,7 +526,7 @@ export function createDispatcher(ctx, config = {}) {
516
526
  deferred = notBefore > at
517
527
  }
518
528
 
519
- const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
529
+ const draft = draftWake({ target, reason, dedupKey, text: wakeText, at, teamId })
520
530
  const entry = { draft, notBefore, keyHeld: Boolean(dedupKey) }
521
531
  queue.add(entry)
522
532
  if (deferred) counters.deferred += 1
@@ -614,25 +624,74 @@ export function createDispatcher(ctx, config = {}) {
614
624
  /**
615
625
  * 从 `wakes.jsonl` 读唤醒记录(**读盘,不是读内存** —— 进程重启后仍可审计)。
616
626
  *
617
- * @param {{ target?: string, limit?: number }} [options]
627
+ * @param {{ target?: string, teamId?: string, limit?: number }} [options]
628
+ * `teamId` 是 t-39 第2批 D 加的:唤醒成本可见(按团队过滤)。
618
629
  * @returns {object[]} 终态 Wake 记录(落盘顺序;有 `limit` 时取**最后** limit 条)
619
630
  */
620
631
  function list(options = {}) {
621
- const { target, limit } = options
622
- // 先按 limit 取尾,再按 target 过滤 —— 语义是「最近 limit 条里属于该 target 的」。
632
+ const { target, teamId, limit } = options
633
+ // 先按 limit 取尾,再按 target / teamId 过滤 —— 语义是「最近 limit 条里属于该 target/team 的」。
623
634
  const { rows, bad, total } = readJsonl(wakesFile, limit ? { limit } : {})
624
- const filtered = target ? rows.filter((row) => row?.target === target) : rows
635
+ let filtered = rows
636
+ if (target) filtered = filtered.filter((row) => row?.target === target)
637
+ if (teamId) filtered = filtered.filter((row) => row?.teamId === teamId)
625
638
  // 坏行**不静默**:作为非枚举属性挂上,调用方可用 `Object.keys` 忽略、也可显式读取。
626
639
  Object.defineProperty(filtered, 'bad', { value: bad, enumerable: false })
627
640
  Object.defineProperty(filtered, 'total', { value: total, enumerable: false })
628
641
  return filtered
629
642
  }
630
643
 
644
+ /**
645
+ * 按团队聚合唤醒成本(t-39 第2批 D)。
646
+ *
647
+ * 为什么需要:Boss 要看「哪个团队在消耗唤醒配额」。单看 target 看不出团队归属
648
+ * (target 是会话 id,团队归属在 `teamId` 上)。
649
+ *
650
+ * 只聚合**真正有 teamId 的记录**;`teamId === null` 的(任务派活、房间广播等
651
+ * 与团队无关的唤醒)单独归入 `teamId: null` 桶,不混进任何团队数字里。
652
+ *
653
+ * @param {{ since?: number }} [options] `since` = epoch ms,只统计该时刻之后的记录
654
+ * @returns {{ since?: number, teams: object[], unassigned: object, total: number }}
655
+ */
656
+ function byTeam(options = {}) {
657
+ const { since } = options
658
+ const { rows } = readJsonl(wakesFile, { limit: 100_000 })
659
+ const buckets = new Map()
660
+ let total = 0
661
+ for (const row of rows) {
662
+ const at = row?.scheduledAt ?? row?.at
663
+ if (since !== undefined && Number.isFinite(since) && Number.isFinite(at) && at < since) continue
664
+ total += 1
665
+ const key = row?.teamId ?? null
666
+ if (!buckets.has(key)) {
667
+ buckets.set(key, { teamId: key, count: 0, sent: 0, skipped: 0, failed: 0, pending: 0, targets: new Set() })
668
+ }
669
+ const b = buckets.get(key)
670
+ b.count += 1
671
+ const r = row?.result
672
+ if (r === 'sent') b.sent += 1
673
+ else if (r === 'skipped') b.skipped += 1
674
+ else if (r === 'failed') b.failed += 1
675
+ else if (r === 'pending') b.pending += 1
676
+ if (row?.target) b.targets.add(row.target)
677
+ }
678
+ const teams = [...buckets.entries()]
679
+ .filter(([key]) => key !== null)
680
+ .map(([, b]) => ({ ...b, targets: b.targets.size, targetsList: [...b.targets] }))
681
+ .sort((a, b) => b.count - a.count)
682
+ const unassignedEntry = buckets.get(null)
683
+ const unassigned = unassignedEntry
684
+ ? { teamId: null, count: unassignedEntry.count, sent: unassignedEntry.sent, skipped: unassignedEntry.skipped, failed: unassignedEntry.failed, pending: unassignedEntry.pending, targets: unassignedEntry.targets.size, targetsList: [...unassignedEntry.targets] }
685
+ : { teamId: null, count: 0, sent: 0, skipped: 0, failed: 0, pending: 0, targets: 0, targetsList: [] }
686
+ return { since: since ?? null, teams, unassigned, total }
687
+ }
688
+
631
689
  return {
632
690
  notify,
633
691
  flush,
634
692
  stats,
635
693
  list,
694
+ byTeam,
636
695
  /** 只读诊断:状态目录 / 文件路径 / 是否可写。 */
637
696
  paths: { stateDir: stateDirectory, wakesFile },
638
697
  /** 状态目录可否持久化;`false` ⇒ 所有返回值都会带 `durable:false`。 */
package/src/index.js CHANGED
@@ -127,7 +127,14 @@ function applyInner(ctx, config, { log, warn }) {
127
127
  // ⚠️ 2026-11-05(P0):createBoard 必须拿到 settings —— wipLimitPerBot(WIP 上限)
128
128
  // 与 requireTriageForReady(Ready 闸门)都从 config.settings 读。只传 stateDir 会让
129
129
  // 平台设置对任务板静默失效(永远退回代码内默认值),且用户改了设置看不到任何变化。
130
- services.board = createBoard(ctx, { stateDir: config.stateDir, leaseMs: settings.defaultLeaseMs, settings })
130
+ services.board = createBoard(ctx, {
131
+ stateDir: config.stateDir,
132
+ leaseMs: settings.defaultLeaseMs,
133
+ settings,
134
+ // 指标旁路(bots04 欠账2):传**取值函数**而不是实例 ——
135
+ // metrics 在 L276 才装配,此刻还是 undefined。传函数可规避装配顺序耦合。
136
+ metrics: () => services.metrics,
137
+ })
131
138
  log(`[cs-bots] BoardService 就绪(stateDir=${services.board.paths?.stateDir ?? '?'})`)
132
139
  // ⚠️ 修复(2026-11-04 测试,P1-4):expireLeases 之前无任何调用者,
133
140
  // owner 崩溃/失联后任务永久停在 in_progress(连"重启时回收"都不做)。
package/src/kernel.js CHANGED
@@ -297,6 +297,21 @@ export function hostLlmAvailable() {
297
297
  * sole ordinary message of its own turn."
298
298
  * ⇒ 适合「有新活要干」的唤醒。
299
299
  *
300
+ * ⚠️ **`followup` 在 `agent` 对象上,不在 `agents` 服务上** —— 这里极易搞错。
301
+ *
302
+ * 写测试替身时,很多人会自然地把 `followup` 挂到假 `agents` 服务上
303
+ * (像 `create` / `get` / `list` 那样),结果永远拿到 `followup-unsupported`,
304
+ * 而且报错信息不指向真正的原因。2026-11-11 实测踩过一次,
305
+ * 排查时还一度以为是 `assertDeliverable` 拦的。
306
+ *
307
+ * 正确形状:
308
+ * ```js
309
+ * get: (key) => key === 'agents' ? {
310
+ * get: (id) => ({ id, sessionId: id, followup(msg) { ... } }), // ← 挂在这里
311
+ * // 不是这里:followup(msg) {}
312
+ * } : undefined
313
+ * ```
314
+ *
300
315
  * @param {object} ctx
301
316
  * @param {string} sessionId
302
317
  * @param {string} text
package/src/room.js CHANGED
@@ -154,6 +154,18 @@ const RELAY_MULTIPLIER = 3
154
154
  const RELAY_FLOOR = 3
155
155
  /** 人类发送者 id(§3.2:`from` 为 sessionId 或 `'user'`)。 */
156
156
  const USER = 'user'
157
+ /**
158
+ * 人类身份全集 —— 与 `teams.js` 的 `HUMAN_IDS` 对齐(`ui` / `user` / `person`)。
159
+ *
160
+ * **绝不唤醒人**:人是「看页面的人」,不是会话;`kernel.followup` 对着这些 id 必然失败。
161
+ * 实测证据(2026-11-11):`wakes.jsonl` 里 `room-msg:m-1:ui → failed` ——
162
+ * 缺省广播把 Boss 自己也算成唤醒目标,每次必失败一条。
163
+ */
164
+ const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
165
+ /** 是否为人类身份(房间转发一律跳过)。 */
166
+ function isHumanId(id) {
167
+ return typeof id === 'string' && HUMAN_IDS.includes(id)
168
+ }
157
169
  /** 房间事件类型集合(审计流可枚举)。 */
158
170
  const EVENTS = [
159
171
  'room_created',
@@ -186,6 +198,14 @@ const DEFAULT_SETTINGS = Object.freeze({
186
198
  allowBotAutoPost: true,
187
199
  maxBotsPerRound: 3,
188
200
  relayEnabled: true,
201
+ // ── 定向唤醒(2026-11-11「唤醒风暴」止血)────────────────────────────────
202
+ // 缺省 **true**:房间消息**只在被 @ 时**才唤醒成员。
203
+ //
204
+ // 为什么缺省要改成「安静」:实测 `wakes.jsonl` 里一条消息产生 3 次唤醒尝试、
205
+ // 3 次全失败(`room-msg:m-1:ui` + 两个 bot 全 failed)—— 缺省广播「除自己外全部成员」
206
+ // 既吵又必然失败(`ui` 根本没有会话可唤醒)。改缺省 = 把最吵、最容易失败的路径关掉;
207
+ // 需要广播的房间显式 `mentionOnly:false` 打开。
208
+ mentionOnly: true,
189
209
  })
190
210
  /** `bodyRef` 的合法形态(防路径穿越:只允许 `bodies/<安全文件名>.txt`)。 */
191
211
  const BODY_REF_RE = /^bodies\/[A-Za-z0-9._-]+\.txt$/
@@ -283,6 +303,55 @@ function stringList(value, field, where) {
283
303
  return out
284
304
  }
285
305
 
306
+ /**
307
+ * 从消息里收集「被 @ 的成员 id」(与 `admission.isAddressed` 的 `@id` 解析同口径)。
308
+ *
309
+ * 两条来源(取并集):
310
+ * 1. `message.mentions` —— 结构化数组,若有则优先
311
+ * 2. `message.text` 里的 `@id` —— 兜底解析(必须是 word-boundary 后的 `@`,避免误伤 `@bot-2` 当 `@bot`)
312
+ *
313
+ * 仅保留 **room.members 里实际存在的** id(非成员被 @ 了也不算唤醒目标)。
314
+ *
315
+ * ⚠️ 注意:`message.from` 即使被 @ 了也不进唤醒名单(自己不唤醒自己)。
316
+ * `HUMAN_IDS`(人类身份)也在 `wakeable()` 里统一过滤,这里不再二次过滤。
317
+ * @param {object} message
318
+ * @param {string[]} members
319
+ * @returns {string[]} 被 @ 的可唤醒成员 id(去重、保序)
320
+ */
321
+ function collectMentions(message, members) {
322
+ if (!message || typeof message !== 'object') return []
323
+ const memberSet = new Set(members)
324
+ const out = []
325
+ const seen = new Set()
326
+
327
+ // ① 结构化 mentions 数组
328
+ if (Array.isArray(message.mentions)) {
329
+ for (const m of message.mentions) {
330
+ if (typeof m === 'string' && memberSet.has(m) && !seen.has(m)) {
331
+ seen.add(m)
332
+ out.push(m)
333
+ }
334
+ }
335
+ }
336
+
337
+ // ② 文本里的 @id(与 admission.js 同口径:`@id` 后必须跟非标识符字符或结尾)
338
+ const text = typeof message.text === 'string' ? message.text : ''
339
+ if (text) {
340
+ for (const m of members) {
341
+ if (seen.has(m)) continue
342
+ const idx = text.indexOf(`@${m}`)
343
+ if (idx >= 0) {
344
+ const after = text.slice(idx + 1 + m.length)
345
+ if (after === '' || !/[A-Za-z0-9_-]/.test(after[0])) {
346
+ seen.add(m)
347
+ out.push(m)
348
+ }
349
+ }
350
+ }
351
+ }
352
+ return out
353
+ }
354
+
286
355
  /** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
287
356
  function normalizeNow(value, where) {
288
357
  if (value === undefined || value === null) return Date.now()
@@ -373,7 +442,7 @@ function normalizePurpose(value, where = 'createRoom') {
373
442
 
374
443
  /** `settings` 单个开关校验(按键分派;未知键抛错 —— 拒绝写入「怎么写都不会生效」的配置)。 */
375
444
  function normalizeSettingValue(key, value, where) {
376
- if (key === 'allowBotAutoPost' || key === 'relayEnabled') {
445
+ if (key === 'allowBotAutoPost' || key === 'relayEnabled' || key === 'mentionOnly') {
377
446
  if (typeof value !== 'boolean') {
378
447
  throw new RoomError(
379
448
  `room.${where}:settings.${key} 必须是布尔值,收到 ${JSON.stringify(value)}`,
@@ -1880,28 +1949,109 @@ export function createRoom(ctx, config = {}) {
1880
1949
  const message = requireMessage(room.id, mid, 'relay')
1881
1950
  const actor = optionalString(byId, 'byId', 'relay')
1882
1951
 
1952
+ // ══ 目标计算(2026-11-11「唤醒风暴」止血)═══════════════════════════════
1953
+ // 四档,**顺序即优先级**:
1954
+ // ① 显式给了 targets → 原样采信(调用方说了算;仍强制排除人类 id)
1955
+ // ② mentionOnly=true 且消息 @ 了某人 → 只打给被 @ 的成员(不含发送者/人类)
1956
+ // ③ mentionOnly=true 但没 @ → **只落消息,不唤醒任何人**(relayed:0)
1957
+ // ④ mentionOnly=false(显式关掉定向)→ 回退「除自己外全部成员」,但仍排除人类
1958
+ //
1959
+ // 为什么③不抛错:「只记录不打扰」是合法结果(Boss 只是同步信息,不需要谁被叫醒)。
1960
+ // 抛错会把「安静」变成「失败」,页面反而要处理异常 —— 那是更差的设计。
1961
+ const settings = room.settings ?? DEFAULT_SETTINGS
1962
+ const mentionOnly = settings.mentionOnly !== false
1963
+ /** 唤醒排除表:人类一律不算唤醒目标(见 HUMAN_IDS 注释的实测证据)。 */
1964
+ const wakeable = (id) => id !== message.from && !isHumanId(id)
1883
1965
  let targetList
1884
- if (targets === undefined || targets === null) {
1885
- targetList = room.members.filter((m) => m !== message.from)
1966
+ let targetSource
1967
+ let noTargetReason
1968
+
1969
+ if (targets !== undefined && targets !== null) {
1970
+ targetList = stringList(targets, 'targets', 'relay')
1886
1971
  if (targetList.length === 0) {
1887
1972
  throw new RoomError(
1888
- `room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他成员,且未显式指定 targets`
1889
- + ' —— 拒绝空转发(静默无操作)',
1973
+ `room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
1890
1974
  'ROOM_NO_TARGETS',
1891
- { roomId: room.id, messageId: mid, from: message.from },
1975
+ { roomId: room.id, messageId: mid },
1892
1976
  )
1893
1977
  }
1978
+ targetSource = 'explicit'
1979
+ // 显式 targets 可能混进人类 —— 剔除,但这是**调用方的错**,如实 warn 不静默
1980
+ const humans = targetList.filter((t) => isHumanId(t))
1981
+ if (humans.length > 0) {
1982
+ warn(`[cs-bots/room] relay 显式 targets 含人类身份 ${humans.join(',')} —— 已剔除(人不可被唤醒)`)
1983
+ targetList = targetList.filter((t) => !isHumanId(t))
1984
+ }
1985
+ } else if (mentionOnly) {
1986
+ // 从消息里解析「被 @ 的人」:mentions 字段 + text 里的 `@id`(与 admission.isAddressed 同口径)
1987
+ const mentioned = collectMentions(message, room.members)
1988
+ targetList = mentioned.filter(wakeable)
1989
+ targetSource = 'mention'
1990
+ if (targetList.length === 0) {
1991
+ targetSource = 'mention-only-no-target'
1992
+ noTargetReason = 'mention-only-no-target'
1993
+ }
1894
1994
  } else {
1895
- targetList = stringList(targets, 'targets', 'relay')
1995
+ targetList = room.members.filter(wakeable)
1996
+ targetSource = 'broadcast'
1896
1997
  if (targetList.length === 0) {
1897
1998
  throw new RoomError(
1898
- `room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
1999
+ `room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他可唤醒成员,且未显式指定 targets`
2000
+ + ' —— 拒绝空转发(静默无操作)',
1899
2001
  'ROOM_NO_TARGETS',
1900
- { roomId: room.id, messageId: mid },
2002
+ { roomId: room.id, messageId: mid, from: message.from },
1901
2003
  )
1902
2004
  }
1903
2005
  }
1904
2006
 
2007
+ // ── ③「没 @ 到谁」:合法空转发 —— 记事件、返回 relayed:0,**不抛错** ──────
2008
+ // 必须在后台账之前分流:否则每次安静发言都吃掉一次 relayLimit 配额。
2009
+ if (targetList.length === 0) {
2010
+ const at0 = normalizeNow(now, 'relay')
2011
+ appendRoomEvent(room.id, {
2012
+ type: 'relay',
2013
+ roomId: room.id,
2014
+ rev: room.rev,
2015
+ byId: actor ?? null,
2016
+ at: at0,
2017
+ messageId: mid,
2018
+ targets: [],
2019
+ attempted: 0,
2020
+ limit: room.relayLimit,
2021
+ targetSource,
2022
+ reason: noTargetReason ?? 'no-wakeable-target',
2023
+ })
2024
+ let audit0 = { written: true }
2025
+ try {
2026
+ appendRoomEvent(room.id, {
2027
+ type: 'relay_result',
2028
+ roomId: room.id,
2029
+ rev: room.rev,
2030
+ byId: actor ?? null,
2031
+ at: at0,
2032
+ messageId: mid,
2033
+ deliveries: [],
2034
+ })
2035
+ } catch (error) {
2036
+ audit0 = { written: false, reason: `relay-result-append-failed:${error?.message ?? error}` }
2037
+ diagnostics.relayResultAppendFailures += 1
2038
+ warn(`[cs-bots/room] 转发结果事件写入失败(投递已发生):${audit0.reason}`)
2039
+ }
2040
+ return {
2041
+ ok: true,
2042
+ roomId: room.id,
2043
+ messageId: mid,
2044
+ relayed: 0,
2045
+ attempted: 0,
2046
+ limit: room.relayLimit,
2047
+ remaining: Math.max(0, room.relayLimit - (relayCounts.get(relayKey(room.id, mid)) ?? 0)),
2048
+ deliveries: [],
2049
+ audit: audit0,
2050
+ targetSource,
2051
+ reason: noTargetReason ?? 'no-wakeable-target',
2052
+ }
2053
+ }
2054
+
1905
2055
  for (const t of targetList) {
1906
2056
  if (!room.members.includes(t)) {
1907
2057
  throw new RoomError(
@@ -1992,6 +2142,7 @@ export function createRoom(ctx, config = {}) {
1992
2142
  remaining: Math.max(0, limit - (relayCounts.get(rkey) ?? 0)),
1993
2143
  deliveries,
1994
2144
  audit,
2145
+ targetSource,
1995
2146
  }
1996
2147
  if (failed.length > 0) {
1997
2148
  result.reason = failed
package/src/roster.js CHANGED
@@ -831,6 +831,59 @@ export function createRoster(ctx, config = {}) {
831
831
  return commit(current.id, 'rename', by, at, before, next)
832
832
  }
833
833
 
834
+ /**
835
+ * 改预制体(preset)—— 2026-11-11 bots04 补的能力缺口。
836
+ *
837
+ * ## 为什么需要它
838
+ *
839
+ * `preset` 此前**只能**在 `create()` 时设置,之后没有任何接口能改。
840
+ * 实测后果:`myfree调研员`(t-36 挂 `agent-not-live` 的那个)preset 为空,
841
+ * 而空 preset = 走宿主默认预设 = **工具面没有保证**(连 `bot_*` 工具都不一定有)。
842
+ * 想修只能删掉重建 —— 那会丢掉 sessionId 与全部历史关联,代价过高。
843
+ *
844
+ * ## 语义边界
845
+ *
846
+ * - **只改 preset,不重建会话**:已经起来的会话不会被换预设。
847
+ * 这是诚实的边界 —— 换预设真正生效要等该会话下次启动。
848
+ * 调用方要能看见这一点,所以返回值里带 `appliesOnNextSession: true`。
849
+ * - 退役 Bot 不可改(与 `rename`/`setCwd` 同口径)。
850
+ * - CAS:必须带 `expectedRev`。
851
+ * - 空串 / null ⇒ 清除 preset(回到宿主默认)。这与 `create` 里
852
+ * 「不传/空串则不写 preset 字段」是同一口径,避免出现 `preset: ''` 这种半状态。
853
+ *
854
+ * @param {{ id: string, preset: string|null, byId: string, expectedRev: number, now?: number }} input
855
+ * @returns {object} Bot(含 `appliesOnNextSession`)
856
+ * @throws {RosterError} 不存在 / 已退役 / 缺字段 / rev 不匹配
857
+ */
858
+ function setPreset(input = {}) {
859
+ const { preset, byId, now } = input ?? {}
860
+ const current = loadForCas(input, 'setPreset')
861
+ const by = requireString(byId, 'byId', 'setPreset')
862
+ assertNotRetired(current, 'setPreset', '换预制体')
863
+
864
+ const raw = preset === undefined || preset === null ? '' : String(preset).trim()
865
+ const nextPreset = raw === '' ? undefined : raw
866
+ if ((current.preset ?? undefined) === nextPreset) {
867
+ throw new RosterError(
868
+ `roster.setPreset:Bot ${current.id} 的 preset 已是 ${JSON.stringify(nextPreset ?? null)}——无需变更`,
869
+ 'ROSTER_NOTHING_TO_SET_PRESET',
870
+ { id: current.id, preset: nextPreset ?? null },
871
+ )
872
+ }
873
+
874
+ const at = normalizeNow(now, 'setPreset')
875
+ const before = clone(current)
876
+ const next = clone(current)
877
+ next.rev = current.rev + 1
878
+ if (nextPreset === undefined) delete next.preset
879
+ else next.preset = nextPreset
880
+ next.presetUpdatedAt = at
881
+ next.presetUpdatedBy = by
882
+ const committed = commit(current.id, 'setPreset', by, at, before, next)
883
+ // 诚实告知:换 preset 不会立刻影响已存在的会话
884
+ return { ...committed, appliesOnNextSession: true }
885
+ }
886
+
834
887
  /**
835
888
  * 退役 → **终态,但释放名额**(名字可复用、cwd 可回收;§3.1 "关键" 段)。
836
889
  *
@@ -1408,6 +1461,7 @@ export function createRoster(ctx, config = {}) {
1408
1461
  hasSession,
1409
1462
  rename,
1410
1463
  retire,
1464
+ setPreset,
1411
1465
  setCwd,
1412
1466
  setPrepareCommand,
1413
1467
  getProfile,
package/src/settings.js CHANGED
@@ -16,8 +16,8 @@ import { resolve, sep, isAbsolute, join } from 'node:path'
16
16
 
17
17
  /** 设置项定义:默认值 + 校验。 */
18
18
  export const SETTING_SPECS = Object.freeze({
19
- /** 任务默认租约时长(ms):owner claim 后多久未心跳即视为失联。 */
20
- defaultLeaseMs: { def: 5 * 60_000, min: 10_000, max: 24 * 60 * 60_000, label: '任务租约默认时长(ms)' },
19
+ /** 任务默认占用时长(ms):owner claim 后多久未心跳即视为失联。 */
20
+ defaultLeaseMs: { def: 5 * 60_000, min: 10_000, max: 24 * 60 * 60_000, label: '任务占用默认时长(ms)' },
21
21
  /** 唤醒节流窗口(ms):同一目标在窗内最多唤醒一次。 */
22
22
  throttleMs: { def: 5_000, min: 0, max: 60 * 60_000, label: '唤醒节流窗口(ms)' },
23
23
  /** 每目标唤醒队列上限(背压阈值)。 */