Skip to content

feat(vc): 会议角色预设产品化——全局共享目录+入会身份/实时语音默认+启动权限体检 - #916

Open
deepcoldy wants to merge 19 commits into
masterfrom
vc-plan-b
Open

feat(vc): 会议角色预设产品化——全局共享目录+入会身份/实时语音默认+启动权限体检#916
deepcoldy wants to merge 19 commits into
masterfrom
vc-plan-b

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

改了什么

会议角色预设从「每个 bot 各自配置」升级为「全 fleet 共享目录」,并补齐入会所需的确定性默认,让「任意 bot 被拉进会即可用」真正成立。同时把这条线上此前散落的会中发言修复(Plan B 重构、idle-gap 授权链等)一并收拢。

主要改动:

  • 共享预设目录:角色预设改存全局 vcMeetingAgent.consumerCatalog;读路径把执行方绑定为「收到会议事件的 bot 自己」,预设类型上不再带 agentAppId,从根上消除「拉 A 进会却把 B 拉进监听群」。退役 daemon 启动时的 per-bot 预设播种写盘(既会永久遮蔽共享目录、又是拉错 bot 的源头)。
  • 读/存校验分向:读路径逐条 forgiving(坏条目只丢自己,Dashboard 与 daemon 丢同一批,页面不会列出 daemon 不跑的角色);存路径严格给字段级错误。
  • per-bot 默认角色:新增 meetingConsumer.catalogDefaultConsumerId,每个 bot 可从共享目录挑一个进会默认角色,不挑则跟随全局默认(Dashboard「按 Bot 会议开关」行加下拉)。
  • 入会身份默认larkCliProfile 缺失时读路径默认成 bot 自己的 appId(确定性、非跨 app、无 secret),配合已有的自动 profile 注册,不再需要先手动点「配置权限」才能入会。修此前「拉非默认 bot 进会报缺 larkCliProfile」。
  • relay/seed 可当会议 agent:二者是 Claude Code fork、落盘 JSONL 同构,opt-in reliableTurnTerminal(已核实其 transcript 带权威 end_turn + 回合标记)。修「relay bot 入会报 turn_terminal_unsupported」。
  • 实时语音默认开启:未显式关即视为开;语音 WebSocket 按需建连——只在 bot 真要发言时才连,纯监听 bot 不空挂连接。
  • 启动权限体检:覆盖「默认开」的 bot(改走 vcMeetingAgentConfigActive,不再只认显式 enabled:true),缺 VC/实时语音 scope 时启动即报错并私信 owner;「配置权限」按钮降级为仅当确实缺权限时的兜底。

为什么

  • 43/47 个 bot 从没配过 vcMeetingAgent,旧模型下被拉进会只能干听、选不到预设;且旧 bootstrap 会把「另一个 bot」的 appId 焊进预设,导致拉错 bot 进群。
  • 入会需要两样 per-bot 东西——角色预设 + larkCliProfile 入会身份——旧实现两样都要人工配,产品化「装好即用」被这两道坎挡住。二者都是确定性默认,本就不该要人工。

影响面

  • 公共层bot-registry.ts(新增 catalogDefaultConsumerId 归一化)、global-config.ts(共享目录结构解析)、daemon.tseffectiveVcMeetingAgentConfig 读路径绑定 + 入会身份/实时语音默认)、im/lark/event-dispatcher.ts(启动权限体检门)、dashboard.ts + dashboard/vc-consumer-profiles-api.ts + 前端 section/i18n。
  • 跨 CLI:仅动了 relay/seed 两个 Claude-family 适配器的 reliableTurnTerminal opt-in;共用基类 createClaudeFamilyAdapter 未改,其它 20+ CLI 不受影响。
  • 跨会话类型:VC 会议会话(Plan B 普通 chat-scope);普通话题/群会话不受影响。
  • 跨平台:daemon 跑在 Linux;改动为纯 config/读路径逻辑,无平台特定路径。

测试验证

VC 相关 9 个测试文件全绿:
  vc-meeting-shared-consumer-catalog / vc-meeting-daemon-session /
  dashboard-vc-consumer-profiles-api / dashboard-vc-consumer-profiles-ui /
  write-input / api-only-mode-wiring / vc-meeting-join-profile /
  bot-registry / event-dispatcher
→ Test Files 9 passed | Tests 825 passed

tsc --noEmit:exit 0
pnpm build:成功
与 origin/master:0 behind / 17 ahead(已 merge 最新 master,仅 global-config.ts 一处 keep-both 冲突,已解)

飞书真机验证项(部署后):拉非默认 bot / relay bot 进会能选到预设并入会;per-bot 默认角色生效;实时语音按需发言。

🤖 Generated with Claude Code

deepcoldy and others added 17 commits August 15, 2026 06:52
去掉「全局会议监听 bot」限制,让每个开了 vcMeetingAgent 的 bot 各自处理自己
收到的会议事件,配合 per-bot 预设(方案 B)。

1. 去全局 listener pin: vcMeetingAgentGlobalListenerAppId() 恒返 undefined,
   4 处 guard(restore/rejoin/confirm/push)全短路成「每 bot 各自处理」——
   这本就是 listenerBotAppId 未设时的语义。老 listenerBotAppId 配置忽略并在
   首次触发时 warn 一次(break change,不做迁移)。
2. 「未操作选择卡时」概念重命名为「默认行为」(defaultMode/defaultConsumers
   两个 i18n 键 zh+en),帮助文案讲清:默认走「启用所选预设」,选「仅监听」则
   只记录;未勾选任何默认预设时即使选启用预设也退化为仅监听。超时兜底本就按
   defaultMode 走(bootstrap 默认 seed defaultMode:agents),语义不变仅正名。
3. 修离线 bot 在会议 agent 下拉显示 appId: onlineBotName 只查在线注册表,
   离线 bot 返 undefined→兜底成 cli_xxx。新增 readPersistedBotNames() 读
   bots-info.json 的飞书探测名,离线 bot 也显示友好名。

测试: 两个旧的 pin-拦截测试改写成「pin 被忽略、每 bot 各自入会」;新增
offline-bot-persisted-name 用例;更新「默认行为」文案断言。VC 6 套件 234 绿。

影响面: 仅 VC 会议 consumer 编排 + dashboard 展示。跨 bot: 去 pin 后同一场
会议多个 bot 可各自独立跑各自预设(不防重,符合每 bot 都能进会的初衷)。
不涉及跨平台/跨后端差异。权限自愈(自动补 scope/event)作为后续 PR。

(cherry picked from commit 45f2404d874e4e0e94e83f25cba2b829e647302c)
把当前已 build 部署到 fleet 的 VC 监听相关改动固化成分支基线,便于 Plan B
重构逐阶段 diff / 必要时回滚到当前运行态。含:
- 启动时自动探测并订阅 VC 事件(open-platform-automation 只读探测 +
  event-dispatcher ensureVcMeetingEventsSubscribed)
- 会中文字输出默认策略 approval→allow(可 dashboard 切回需授权)
- vcMeetingAgent 默认开启(仅 apiOnly / 显式 enabled:false 关闭)
- receiver 流式卡片可选暴露(exposeReceiverStreamingCard)+ 相关抑制/文案
- textOutputPolicy / voiceOutputPolicy 配置解析

已去除临时 vc-card-diag 诊断日志。tsc --noEmit 干净。此基线仅作 checkpoint,
其中卡片暴露/抑制类改动将被后续 Plan B 重构(会议 agent 改为普通会话)取代。
方向B重构第一阶段,单独修好主诉求(普通消息/@消息/字幕都能进同一个可交互会话)。

根因与改动:
- activeSessionKey(core/types.ts)不再把带 vcMeetingReceiver 标记的会话按
  `vc-receiver:${sessionId}` 单独建 key,统一落到普通 `(chatId, appId)` 槽。
  这是"会议监听交互全废"的一行根因——旧 key 把会议会话和普通 chat 会话分成
  两个宇宙,普通 IM(按 chat anchor 路由)永远找不到会议会话。
- persistedActiveSessionKey(worker-pool.ts)同步改,保证重启恢复用同一个 key,
  不出现 live/restore 双 key 裂脑。
- ensureVcMeetingReceiverSession(daemon.ts)改成"取/建监听群的普通会话":同
  (chatId,appId) 槽已有会话则复用并 stamp 会议投递身份 metadata,否则建一个
  worker:null 冷会话(形状同普通自建会话,首次投递时冷 fork spawn)。放松旧的
  "一 session=一 exact meeting/member/epoch"强校验(普通会话模型下一个群可承载
  多场会议投影)。抽出 stampVcMeetingBinding。
- maybeCatchUpVcMeetingConsumerBeforeTurn 不再返回 `vc-receiver:` anchorOverride
  (普通会话 anchor=chatId 天然命中);仍保留 ambiguous/lagging 时 block、@mention
  follow-up 时 stamp vcMeetingImTurnOrigin。
- dispatch.ts foldableChatSessionAppIds 去掉 vcMeetingReceiver 排除(会议会话现在
  就是普通 chat 会话,mention 应折回它);deferredScheduleRun 仍排除。

vcMeetingReceiver 标记保留,从"路由 key"降级为"纯投递/会中发言 metadata"
(Stage6 会中发言仍需)。抑制/守卫/恢复解耦留待后续阶段。

测试:
- 改 vc-meeting-daemon-session(activeKey 现=ordinaryChatKey、无 anchorOverride)、
  session-resume(旧"三 vc-receiver key 不塌缩"→Plan B"塌缩到普通槽+胜者保留+
  重复 loser 关闭")、dispatch(拆成 deferred 仍排除 + VC 现可折回两条)。
- 新增聚焦测试:activeSessionKey 落普通槽不含 vc-receiver: 前缀且 map 命中。
- tsc --noEmit 干净;相关 VC/routing/lifecycle 单测全绿(vc-daemon-session 126、
  session-resume 39、dispatch 93、session-reply-thread-anchor 18、
  im-routing/recovery/delivery/lifecycle 506)。全量单测除既有基线(skill-doctor)
  与并行争用 flake(隔离复跑均绿)外通过。

未 build 未部署:重构期间只跑 tsc+vitest,不动 live dist。
方向B重构第二阶段。会议 agent 变成监听群普通会话后,原来"凡是会议会话就
一刀切抑制卡片/reaction/输出"的判断会顺带误伤普通用户消息(=之前报的
"手动@机器人也不回复")。核心是引入统一判据区分「会议驱动 turn」与「普通
用户 turn」,只对前者保留 receipt-fenced 抑制,后者一律走正常回复路径。

新增共享判据 isMeetingDrivenTurn(ds,turnId,dispatchAttempt)(worker-pool.ts 模块级):
vcMeetingReceiver && (dispatchAttempt!==undefined || 有 stamped 会议@来源)。
只有字幕投递 turn 或带会议来源的@追问才算 meeting-driven。

改动(全部把 blanket `if(vcMeetingReceiver)` 换成 meeting-driven 判据):
- managedAuxUiSuppressed / managedFinalOutputSuppressed:普通用户 turn 落
  ordinaryManagedSuppression;仅 meeting-driven 走 receipt/durable 策略
- worker.on('error') fork 诊断、failOrdinaryImDelivery 投递失败上报:普通
  用户 turn 的失败正常上报给用户,仅 meeting-driven 静默 fence 到 receipt 链
- shouldTrackOrdinaryImDelivery:普通用户 turn 现在被 track 进 receipt-ACK
  + 重试(失败能上报);仅 meeting-driven 走 receipt/lease 路径
- finishTurnReactions / noteTurnReceived(daemon.ts):普通用户消息正常
  ✋→✅ 进度 reaction;仅 stamped 会议@追问不加 reaction
- deliverFinalOutput 最终输出主投递门:managedDecision 只对 meeting-driven turn
  计算,普通用户 turn 的 final_output 走普通投递(否则被判 origin_unproven 静默丢)

保留:silent/listener_thread 的 responseMode 群发策略(功能,非 bug)、
evaluateVcMeetingManagedSend 各 policy 输入、sessionReply 的 sourceSessionId
归属。relay/fork/resume/dashboard-trigger 守卫留待 Stage 3;boot-recovery
留待 Stage 4;lifecycle-hooks 对 receiver 的安全 fail-closed 暂留待评估。

删除:流式卡片专用抑制 vcReceiverStreamingCardSuppressed + exposeReceiverStreamingCard
配置字段 + /card 的 vc_receiver 专用文案(i18n zh/en)。dispatchTurn 现在为
每个字幕投递 turn 都 arm 流式卡片(去掉 exposeReceiverStreamingCard opt-in
门)——修好"看不到流式卡片";silent turn 仅抑制 final output,不抑制 live 卡片。

测试:tsc --noEmit 干净;api-only-mode 源锁多断言改为 isMeetingDrivenTurn 三处
新契约;recall-frozen-cards 两测反转(卡片/usage patch 现在该出);
session-lifecycle-start 拆成"普通用户 turn fork 失败该上报"+"字幕投递 fork
失败仍 fence";turn-reactions 反转(会议会话普通 turn 该有 reaction);
bot-registry/command-handler 去 exposeReceiverStreamingCard/vc_receiver 断言。
全部 Stage-2-touched 单测 711 绿(daemon-session 126 等);全量除既有基线
skill-doctor + 并行 flake(隔离复跑绿)外通过。未 build/未部署。
方向B重构第三阶段。会议 agent 变普通会话后,原来禁止它 relay/fork/resume/
被 dashboard 触发的守卫都不再成立——普通会话本该支持这些操作。

删除的守卫:
- transferSession(worker-pool):去 vc_receiver_not_relayable,会议会话可像任何
  会话一样 relay(sessionId 不变,投递账本仍按 sessionId 命中)
- forkSession(worker-pool):去 vc_receiver_not_forkable,可 fork
- 中转目标冲突扫描(worker-pool):去掉把 vcMeetingReceiver 会话排除在同 anchor
  兄弟之外的例外——它现在住普通 chat key,同 anchor 就是合法冲突,应计入
- resumeSession(session-manager,pre-lock + 锁内双查):去 vc_receiver_managed,
  关闭的会议会话可 resume 回普通 chat 槽(原注释担心的"塌缩进普通槽"正是 Plan B
  预期行为);从返回类型 union 里移除该错误码
- dashboard/HTTP trigger 端点(dashboard-ipc):去 managed_receiver_requires_delivery_endpoint,
  普通 trigger 可寻址会议会话(botmux send / dashboard);字幕投递仍走自己的
  fenced 投递路径,此端点只承载普通用户发起的 turn

测试:tsc --noEmit 干净;session-resume「拒绝 resume 会议 receiver」改为「Plan B
可 resume,此处因 anchor 已被普通 chat 占用而落 anchor_occupied」;dashboard-ipc
「拒绝 resume」改为「Plan B 成功 resume 进普通槽 + fork」。fork/transfer/relay-pickup
的 adopt_* 用例不受影响。relay/fork/resume/dashboard 相关 268 绿;全量除既有基线
skill-doctor + 并行 flake(隔离复跑绿)外通过。未 build/未部署。
方向B重构第四阶段。原计划担心启动恢复(boot recovery)按 vcMeetingReceiver
marker 判定"该走恢复",Plan B 下 marker 也在普通用户 turn 会话上,会误伤。

核查后确认**无需改生产代码**:
- 恢复入口 ambiguousOnBoot 来自 reconcileVcMeetingDeliveriesOnBoot(扫描投递
  账本里 stale 的 dispatched receipt),本就按"有在途投递 receipt"判定,不是
  "session 有 marker"。普通用户 turn 没有投递账本条目,结构上不可能进恢复。
- 恢复循环用 findActiveBySessionId(ref.receiverSessionId) 找会话(按 sessionId,
  不受 activeSessions key 从 vc-receiver 改成普通 chat key 影响);Stage1 已把
  persistedActiveSessionKey 统一成普通 key,重启后会话注册在普通槽,恢复照样找到。
- 会话若在 Stage1 restore-collapse 中作为同 key 冲突 loser 被关闭,
  findActiveBySessionId 返回 undefined → 落 vcMeetingRuntimeLeaseRecovery
  (kill+probe 孤儿 pane),是正确的 fail-closed。
- worker.ts reset_ambiguous_receiver 的 marker 校验是"确认目标确实是会议会话"
  的正确性 sanity check(仅当会话 active 且 marker 在时才到达),保留。

新增 2 个回归测试锁定不变量:①源锁断言恢复 eligibility 走 ledger 派生的
ambiguousOnBoot + findActiveBySessionId,非 marker 扫描;②无投递 receipt 的
会话(普通用户 turn)isBlocked 返回 false。recovery-lifecycle 6 绿。tsc 干净。
方向B重构第五阶段(durable 状态迁移)。原计划担心真实部署的旧 vcMeetingReceiver
持久化行在重启后需要专门迁移。核查后确认**无需专门迁移代码**:

- 旧会议行(带 marker 持久化):重启走 restoreActiveSessions → persistedActiveSessionKey
  (Stage1 已统一成普通 chat anchor),全部塌缩到普通 (chatId,appId) 槽;有竞争则
  CAS 选胜者关重复 loser,无竞争则原样恢复且 marker 保留。已由 Stage1 的
  「塌缩」测 + 本阶段新增「独行旧会议行原样恢复」测覆盖。
- 会议运行时监控记录(runtime store,按 meetingId):由 restoreVcMeetingRuntimeSessionsForBot
  独立 rehydrate,与 marker key 无关;会议活跃时重新 ensureVcMeetingReceiverSession
  (Stage1 已改成取/建普通会话)。
- 投递账本 receipt:deliveryKey 含 target.sessionId,而 sessionId 在 key 变更中
  稳定不变(只是 activeSessions 的 map KEY 从 vc-receiver 改普通),故 receipt 仍按
  sessionId 命中,无需硬迁哈希。
- 全仓已无任何代码构造/读取旧 `vc-receiver:` key 格式(仅剩一句注释)。marker 保留
  不删,旧行与新行读取一致,无版本 skew,「读旧 marker 一个 release」的顾虑也消解。

新增 1 个迁移回归测试:独行旧会议行重启后落普通 chat key、marker 作为投递
metadata 原样保留、会话 active。session-resume 40 绿。tsc 干净。
方向B重构第六阶段。申晗定"会中往会议里发文字/语音"是刚需必须保留。

核查确认**无需改代码即完整保留**:整条会中发言链(request-output → daemon
/api/vc-meetings/action-request → 转发 listener daemon 的 managed-action)的每个
授权点都按 sessionId + 保留的 vcMeetingReceiver marker + managedTurnOrigin /
vcMeetingImTurnOrigin 判定,**没有一个依赖 Stage1 改动的 activeSessions map key**:
- action-request 入口(daemon.ts):findActiveBySessionId(按 sessionId)+
  `if(!ds?.session.vcMeetingReceiver)` 门(marker 保留 → 仍通过)
- managed_turn_origin IPC arm ds.managedTurnOrigin(worker-pool):按 sessionId +
  worker 代次判定,与 map key 无关
- evaluateVcMeetingManagedSend(worker.ts request-output origin):receiverSession
  = !!session.vcMeetingReceiver(marker 保留 → true),按 delivery receipt /
  imOrigin 授权
这正是 Plan B 决定「保留 marker 作纯 metadata 而非删除」的收益。

验证:action-gate 25 / send-policy 16 / action-store 35 / listener-output-protocol /
realtime-voice 16 全绿;daemon-session 里 submitManagedImOutput 全链用例(会中文字
输出 202 pending 接受 + 各拒绝路径)在「会议 agent = 普通会话(activeKey ===
ordinaryChatKey)」前提下通过。新增 1 源锁测试钉住 action-request 仍按保留 marker
识别会议 agent,防未来清理 marker 时静默弄坏会中发言。tsc 干净;全量除既有基线
skill-doctor + 并行 flake(隔离绿)外通过。

至此 Plan B 六阶段全部完成(分支 vc-plan-b),未 build/未部署,待申晗确认后
switch:here + daemon:restart 飞书实测。
Plan B 实测发现的真 bug。会议 agent 现在是普通会话,会穿插普通 IM turn 且在
turn 之间到达 idle。会中发言的授权凭证 ds.managedTurnOrigin 在字幕投递 turn 的
terminal 边缘被清除,而模型往往在投递 turn 结束(或穿插的 IM turn 之后)才真正
执行 request-output——此时 in-memory origin 已没,action-request 报 origin_unproven,
即便持久化投递 receipt 仍完整授权它。

修复:action-request 授权点在 live managedTurnOrigin 缺失、但 CLI 带来了 claimed
delivery origin(originTurnId + originDispatchAttempt)时,回退用 evaluateVcMeetingManagedSend
按 sessionId+turnId+dispatchAttempt 对**持久化 receipt** 重新验证(与 final-output
路径同一条权威链):receipt 必须存在于本 receiver session、attempt 匹配、状态为
dispatched/completed、projection 仍 active、且非 silent 投递。只有 listener_thread
决策才合成 verified origin。这不放松任何边界(receipt 本身授权不了的一律拒),
只是跨过 idle 间隙。

安全性:普通用户 turn(om_ turnId 无 receipt)走不进这条回退——claimedAttempt
必须有值且 receipt 必须命中,否则回退不触发,正确落回 IM-origin 路径。

测试:tsc 干净;evaluateVcMeetingManagedSend 的 completed-receipt/allowTerminalReceipt
授权链 + failed/ambiguous 不授权的负例在 send-policy 测已覆盖;新增 api-only-mode
源锁钉住 action-request 的回退分支结构(live 缺失→durable receipt→listener_thread)。
action-gate/action-store/im-reply/dashboard-ipc/delivery-receiver/daemon-session 全绿。
申晗定的方向B。responseMode:silent 本应只表示"不往监听群自动刷屏",却在
evaluateVcMeetingManagedSend 里一票否决了会中 request-output——导致"会议主持"
(facilitator 模板自带 responseMode:silent,角色指令却要求会中发言)永远无法在
会里说话,agent 自己推断"physically gated closed"转入纯观察。

根因梳理:
- 会中输出(request-output → hub managed-action)与监听群 auto-post(final_output /
  botmux send)本是两条独立通道。会中输出的授权在 hub 的 authorize 回调
  (capability + textOutputPolicy/voiceOutputPolicy),**本就不看 responseMode**;
  action-request 主路径用 verifyVcMeetingManagedOriginClaim(也不看 silent)。设计
  文档(send-policy 注释)早就写明这个分离。
- 但上一个 commit 修 idle-gap 时,回退用了 evaluateVcMeetingManagedSend——它含
  silent 一票否决——把 silent 挡会中输出的行为**重新引回**了会中通道。

修法(外科式):给 VcMeetingManagedSendOrigin 加 forInMeetingOutput 标志。设 true
时(仅会中输出授权路径用)仍校验 receipt 身份/在途(存在于本 session、attempt
匹配、active projection、dispatched/completed),但跳过 silent 一票否决——因为
silent 只管 auto-post,会中输出由 hub 的 policy 门控。监听群通道所有调用点
(worker-pool final_output、cli botmux send、sandbox relay)都不带该 flag,silent
继续抑制群发。只有 daemon action-request 的 idle-gap 回退带 forInMeetingOutput。

效果:silent facilitator "群里安静、会里能说"。主路径(live origin 在)本就
silent-free 现在与回退一致。

测试:tsc 干净;send-policy 新增3测(silent 在 in-meeting 通道放行 + 默认
listener 通道仍 silent_delivery 抑制 + forInMeetingOutput 不绕过身份/在途校验);
api-only-mode 源锁更新钉 forInMeetingOutput;action-gate/daemon-session/全量单测
除既有基线+并行flake外通过。未 build,下一步 rebuild 重新部署。
上一个 idle-gap 修复只补了 daemon 半边。实测(facilitator 真机)发现 CLI 半边根本
没把投递身份传过来:cli request-output 的 originTurnId/originDispatchAttempt 取自
"当前活跃 turn 的进程树 marker"(resolveSessionContext→live marker),而 marker 的
turn 字段在字幕投递 turn 结束时被清。agent 通常在投递 turn 结束、idle 之后才决定
发言并执行命令,此时 marker 已空→CLI 发的 action-request 不带 originDispatchAttempt
→daemon 的 durable 回退条件(claimedAttempt!==undefined)不满足→回退没跑→仍 origin_unproven。

修复:
- 新增 latestVcMeetingDeliveryForSession(delivery-store):按 receiverSessionId 反查
  最近一条**可授权**(dispatched 或 completed,含刚 completed 的终态)投递 receipt。
  区别于 listActiveVcMeetingDeliveriesForSession(跳过 terminal,idle 后返回空)。
- cli/vc-agent request-output:live marker 缺 turnId/dispatchAttempt 时,回退用
  latestVcMeetingDeliveryForSession 取回本会话刚处理那轮投递的 deliveryKey+attempt
  再发给 daemon。live marker 在时仍优先(in-flight turn)。

安全:回退按 sessionId 查账本,CLI 只能取到**自己会话**的投递(BOTMUX_SESSION_ID);
daemon 侧仍用 forInMeetingOutput 的 durable 授权二次校验(receipt 属本 session +
dispatched/completed + active projection),CLI 无法伪造他人投递身份。

测试:delivery-store 新增3测(just-completed 终态可取回而 listActive 跳过 / 只返回
最近可授权且排除 failed/abandoned / 未知会话 undefined);tsc 干净;send-policy/
api-only-mode/action-gate/daemon-session/全量单测除既有基线+并行 flake 外通过。
下一步 rebuild 重新部署。
真机复现走通授权后,发现更深一层:会中发言的动作门(vc-meeting-action-gate:566)
要求投递 receipt 处于 dispatched 才允许创建"会中发言"动作。但主持人往往在字幕
投递那轮**处理完(completed)之后**才决定发言,receipt 已是 completed→被拒
delivery_not_dispatched。这与前几关是同一个"假设发言发生在 live turn 进行中"的
老假设,散落在授权链不同层。

修复:动作门状态检查放开为 dispatched 或 completed。安全性:completed 是终态,
不会再有 attempt N+1 接管(那正是这道门原本要防的竞态),且 dispatchAttempt 相等
校验仍钉死到确切那次;failed/ambiguous/abandoned 仍 fail closed。此函数
(requestVcMeetingManagedAction)唯一调用点是 submitVcMeetingManagedAction(会中
managed-action 路径),不影响监听群 auto-post。

授权后流程无更深 receipt-status 门:text(allow)直接 executeVcMeetingManagedProviderPlan
发送,voice(approval)presentVcMeetingManagedApproval 弹审核卡,均不再看 receipt 状态。

测试:action-gate 新增2测(completed 投递可创建动作 / ambiguous 仍拒);
delivery-store + send-policy + daemon-session + api-only-mode 全绿(280 affected)。
tsc 干净。下一步 rebuild 重新部署,这次整条链(CLI 回取身份→daemon durable 授权→
动作门接受 completed→发送/审核)走通。
四项联动修复,全部针对真机实测暴露的问题:

1. 会中发言授权第5道门:action-request 的 durable receipt 回退原先只在
   live origin 已被清(idle)时才跑,而 live 验证只认 rotating capability,
   非沙盒会话没有 capability 传输通道(managedOriginChannelRequired 仅
   darwin isolate/credential-only bwrap)→ 投递 turn 进行中发言必然
   origin_unproven。回退条件放宽为「live 验证失败即可用 durable receipt
   重验」,receipt 校验强度不变(sessionId+attempt 匹配+dispatched/completed
   +projection 活跃),不放大授权面。source-lock 测试同步锁新语义。

2. 字幕投递响应性:授权用户(instruction source)的发言/聊天一律视为
   fast signal(原先只有 @ 聊天和带问号的发言),操作者说话即时注入不再
   等 30s tick;默认 minBatchChars 400→1,去掉「攒够字数才投」的默认
   行为(保留 per-bot 配置可调回)。

3. 会议主持模板 v2:参会者点名/提问/明确请求发言时必须立即通过会中
   输出响应,直接请求优先于「是否有主持价值」的自行判断;门禁拒绝时
   不静默放弃。

4. 语音默认策略:realtimeVoice.enabled 本身就是显式 opt-in,开启后
   默认与文字对齐为直接发送(allow),可经 per-bot voiceOutputPolicy
   收紧为 approval/deny;未开 realtimeVoice 仍硬 deny 不变。

dashboard 会话状态投影:worker init 完成但首个空闲提示未出现期间
(首 turn 进行中,screen updates 被抑制),状态从「启动中」改投「工作中」
——会议会话 spawn 即注入长投递 turn,原先整个首 turn 都显示启动中。

测试:11 相关套件 495 测全绿;voice 审核路径用例显式钉
voiceOutputPolicy:'approval'(它们测的就是审核流,不该依赖默认值);
新增授权用户发言即时注入/状态投影/第5关 source-lock 回归。
VC 会议设置(consumer profiles section)此前只能编辑角色预设,per-bot 的
会中输出策略(文字/语音 allow/approval/deny、realtimeVoice 开关)只能手改
bots.json——「默认放行、可在 dashboard 切回审核」承诺的 dashboard 半边
一直缺位。本次补全:

- GET /api/vc-meeting/consumer-profiles 的 agentOptions 附带每个 bot 的
  已配置策略与实际生效值(与 daemon 默认推导逻辑保持一致:文字默认
  allow;语音未开 realtimeVoice 硬 deny、开启后默认 allow)
- PUT 新增可选 botOutputPolicies:先严格校验(未知 appId/非法值/重复
  422 拒绝,不写任何东西),快照提交成功后经 config-store 的带锁
  read-modify-write(rmwBotEntry)写 bots.json,再对涉及 bot 热重载;
  写失败 503 fail-loud,前端重新 GET 收敛真实落盘状态
- 设置页新增「Bot 会中输出策略」子块:每 bot 一行,文字/语音三态下拉
  (默认/直接发送/需审核/禁止)+实时语音开关+生效值提示;有自定义配置
  或在线的 bot 排前,滚动容器防 49 bot 撑爆页面;仅提交与载入基线有
  差异的行,避免并发覆盖其它 bot
- i18n 中英词条与样式

测试:dashboard-vc-consumer-profiles-api 54 测全绿(新增 patch 应用/
校验拒绝/写失败 503/生效值推导 4 用例;DTO 断言补新字段)。
第5关放宽回退条件后,原日志固定写「live origin cleared at turn terminal」
已不准确——非沙盒会话在投递 turn 进行中(live origin 仍在、只是没有
capability 传输通道)也会走这条回退。日志按实际情形分叉,避免后续排查
被措辞误导。
会议预设从 per-bot 配置升级为全 fleet 共享目录,任意 bot 被拉进会即可用;
并补齐入会所需的两项确定性默认,让"装好即用"真正成立。

主要改动:
- 角色预设改为全局共享目录(vcMeetingAgent.consumerCatalog),读路径把执行方
  绑定为收到会议事件的 bot 自己;预设类型上不再带 agentAppId,从根上消除
  "拉 A 进会却把 B 拉进监听群"。退役 daemon 启动时的 per-bot 预设播种写盘。
- 读/存校验分向:读路径逐条 forgiving(坏条目只丢自己,Dashboard 与 daemon
  丢同一批),存路径严格给字段级错误。
- per-bot 默认角色覆盖:新增 meetingConsumer.catalogDefaultConsumerId,每个 bot
  可从共享目录挑一个进会默认角色,不挑则跟随全局默认。
- 入会身份 larkCliProfile 缺失时读路径默认成 bot 自己的 appId(确定性、非跨 app、
  无 secret),配合已有的自动 profile 注册,不再需要手动"配置权限"才能入会。
- relay/seed(Claude Code fork,落盘 JSONL 同构)opt-in reliableTurnTerminal,
  可当会议 agent。
- 实时语音默认开启(未显式关即视为开);语音 WebSocket 按需建连,只在真要发言时
  才连,纯监听 bot 不空挂。
- 启动权限体检覆盖"默认开"的 bot(改走 vcMeetingAgentConfigActive),缺 VC/实时
  语音 scope 时启动即报错并私信 owner;"配置权限"按钮降级为仅当确实缺权限时的兜底。

测试:VC 相关 6 个测试文件 410 用例全绿;tsc + build 通过。

Co-Authored-By: Claude <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner Author

自动化代码评审初步发现以下问题,建议合入前处理;最终以维护者审阅结论为准。

  1. 阻断:历史 seeded 配置在共享目录显式清空或全量无效时会被原样复活。

    bindVcMeetingConsumerCatalogToBot() 在 135 行已经把 v1/v2 seeded block 识别为“不是操作者配置”,但 143/150 行遇到 profiles: [] 或所有条目校验失败时直接 return cfg。因此旧 seeded block 会重新生效,连同它可能指向另一个 bot 的 agentAppId 一起保留;这既违反“显式清空 = 不继承任何角色”,也重新打开了本 PR 要消除的“拉 A、执行方却是 B”路径。

    最小复现:给 cfg.meetingConsumer 放一条带 defaultProfileBootstrapagentAppId=cli_other 的 seeded minutes,再注入 { profiles: [], defaultMode: 'listenOnly', defaultConsumerIds: [] };返回值仍是 defaultMode=agents/defaultConsumerIds=['minutes']/agentAppId=cli_other。全量 broken catalog 也一样。

    建议:一旦判定为 seeded,后续任何 fallback 都不能再返回原始 seeded profiles;显式空目录和全坏目录都应安全地去掉/中和 seeded selection,并补 v1/v2 × explicit-empty/all-invalid 回归测试。

  2. 阻断:Dashboard 写入的两个显式关闭开关在重载时失效。

    本 PR 把 VC 与实时语音改成“未配置即开启,只有 false 才关闭”,Dashboard 也分别写入 vcMeetingAgent.enabled=falsevcMeetingAgent.realtimeVoice.enabled=false;但 normalizeVcMeetingAgentConfig()normalizeVcMeetingRealtimeVoiceConfig() 仍都只保留 enabled === true。读取 bots.json 后两个 false 都被丢掉,于是 vcMeetingAgentConfigActive() 得到 {}(仍 active),vcMeetingRealtimeVoiceEnabled() 也重新判为 true;页面再次 GET 也会显示成已开启。

    我用 parseBotConfigsFromText() 解析 { vcMeetingAgent: { enabled:false, realtimeVoice:{enabled:false} } },结果 parsed.vcMeetingAgentundefined,随后 active config 为 {},两个关闭判定均为 false。建议两层 normalizer 都显式保留 false,并增加从 Dashboard RMW 写盘 → loadBotConfigs → effective config 的端到端回归。

  3. 次要:共享目录的 forgiving 读路径没有处理跨条目冲突。

    normalizeVcMeetingConsumerProfilesForgiving() 只逐条调用 normalizer,没有执行注释里所说的 resolver 跨条目检查。手改出两个相同 id 的单条合法 profile 时,两条都会进入绑定结果,随后 daemon 的 resolveVcMeetingConsumerProfiles() 报 duplicate,仍可能让全 fleet 的会议角色路径失败。建议至少确定性保留第一条、丢弃后续重复项并告警,或在 forgiving 层补 resolver 归并策略。

验证:当前 head e91ae8d3c 上,PR 描述列出的 9 个测试文件均通过(9 files / 825 tests),pnpm build 通过;上述两个阻断点均通过独立最小复现触发,说明需要新增覆盖。

deepcoldy and others added 2 commits August 18, 2026 07:04
「按 Bot 的会议开关」表随 fleet 变大后上下滚动体验差、无法定位;每行 bot 名后
平铺的能力警告(尤其「未开沙盒:会议为不可信输入,bot凭证暴露…」)很长,喧宾夺主。

- 加搜索框:按 bot 名称 / appId 过滤行,保存/加载期间冻结。
- 能力缺口收成一个 ⚠,完整文案放 hover title,不再平铺整行。
- 无匹配时给空态提示。

测试:UI 21 用例全绿(新增搜索过滤用例 + 警告 title 断言)。

Co-Authored-By: Claude <noreply@anthropic.com>
…ck剥离

复审发现"VC/实时语音默认开(未配=开,只false才关)"翻转后,"关"这一侧两处没接住:

- normalizer 只 `if(enabled===true)` 留 true、丢 enabled:false:默认开翻转后
  undefined=active,丢 false=反转→显式 opt-out round-trip 后失效(重载即复活)。
  vcMeetingAgent 顶层 + realtimeVoice 两个 normalizer 都补 `enabled:false` 保留。
- bindVcMeetingConsumerCatalogToBot 的 seeded 块 fallback:catalog 显式清空 /
  全无效两处原样 `return cfg`,把焊死 foreign agentAppId 的播种块交回→"拉A拉B"
  从 fallback 复活。改成 seeded 情形 fallback 剥成仅监听(operator 内容仍原样返回)。

回归测试均过真实 parse→gate composition(非手搓对象),并经反向变异证非 vacuous:
两处 normalizer round-trip 测 + 两处 seeded fallback 剥离测,还原修复即变红。
VC 全套 9 文件 830 测绿;tsc + build 通过。

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant