feat(subject): 增加基于飞书上下文的主体监听 - #1252
Conversation
6181381 to
0b69d27
Compare
a67f2b0 to
f311dd2
Compare
|
感谢这个 PR,设计思路清晰, 我在最新 先说确认没问题的部分
建议合入前修复F1(阻断)静默承诺有 4 处漏点:
|
| 位置 | silent | subject |
|---|---|---|
worker-pool.ts:9661/9662 auxUiSuppressedFor |
✅ | ✅ |
worker-pool.ts:11381/11382 managedAuxUiSuppressed |
✅ | ✅ |
worker-pool.ts:7527 failOrdinaryImDelivery |
✅ | ❌ |
worker-pool.ts:7558 delayOrdinaryImDelivery |
✅ | ❌ |
worker-pool.ts:10790 fork error |
✅ | ❌ |
worker-pool.ts:11405 managedFinalOutputSuppressed |
✅ | ❌ |
可达性我核过了,不是理论问题:shouldTrackOrdinaryImDelivery(worker-pool.ts:7674)的准入是 turnId.startsWith('om_') && dispatchAttempt === undefined && prompt 非空。Subject 的 turnId 就是飞书 om_ 消息 id(daemon.ts:18627 传的 replyAnchorId),prompt 非空,dispatchAttempt 为 undefined —— 三条全中,Subject turn 确实进这个 tracker。后果:
7558:worker 冷启动稍慢、宿主有压力 → 群里冒一句「输入还在等 worker,请勿重发」。这条不需要真失败,软超时就触发,最容易命中。7527:receipt 重试耗尽 / workerGeneration 变化 / IPC 抛错 → 「投递失败请重发」。10790:fork 层失败(spawn ENOENT、cwd 消失、fd 耗尽)→ 「启动失败」。这条注意:它是forkWorker里直调cb.sessionReply,绕过managedAuxUiSuppressed,所以 11381 那道闸兜不住它。好消息是此时registerSubjectListenerTurn已执行(18627 在 fork 之前),isSubjectListenerTurn会返回 true,补一个||即可。
另外 worker-pool.ts:1404 ordinaryTurnRecoveryEligible 既无 silent 也无 subject 判定,而 11057 会把 om_ 前缀 + 非空 prompt 的 turn 登记进 recovery 协调器 —— Subject 满足条件。续跑两次仍失败后,1537 会发一张带「重试」按钮的失败卡。这条 silent-schedule 同样受影响(属既有问题,非本 PR 引入),但 Subject 的设计承诺是「绝对静默」,性质更重,建议一并处理。
建议修法:与其逐点补 isSubjectListenerTurn,更彻底的是把 registerSubjectListenerTurn 从 daemon.ts:18627 前移到 prompt 准备点之后立刻执行。当前注册点在 handleNewTopicAdmitted 很靠后的位置,导致 17952→18627 这段区间里 ds 上根本没有 Subject 标记,所有 turn-exact 闸(streamingCardDisabledFor、noteTurnReceived、auxUiSuppressedFor)在这段都天然失效,只能靠局部 isSubjectListener 布尔量逐个补 —— 你已经补了 2 处(17848 语音、18292 needLogin),但 18604/18614/18654 的 replyInvalidWorkingDirs 还没补,它末尾(daemon.ts:17203)是无条件 sessionReply。前移注册点能一次性关掉这一整类。
F2(阻断)handleThreadReplyAdmitted 整条链零 Subject 处理
daemon.ts:18571:claimNewDaemonSession 返回 existing_owner 时,带着 Subject 的 ctx 直调 handleThreadReplyAdmitted。而这个函数(约 1640 行)从头到尾不读 ctx.messageListener?.behavior,只在 20005 把 !!ctx.messageListener 喂给 computeCodexAppSteerable(与静默无关)。
和 new-topic 路径构成两组明确的不对称:
| new-topic(已挡) | thread-reply(未挡) | 触发条件 |
|---|---|---|
17848 语音转写失败 |
19344 |
语音消息转写失败 |
18292 needLogin |
19921 |
附件需登录 |
还有 20194/20260(「请先选库」)、20475(选库卡)。
更要紧的是 completion gate 不会被 settle:subjectListenerCompletion 全部 5 个引用都在 handleNewTopic 里(17668/17669/17953/18624/18630),handleThreadReply 一个都没有。走到这条路的 Subject turn 既不会 claimed 也不会 settle,而 dispatchHumanMessageViaHandlers 的 lane 尾部在 await completed 上等 —— 该群的 Subject FIFO 永久卡死,后续所有 ambient 消息全部排队不再处理,且没有任何超时兜底(subjectListenerDispatchLanes 无深度上限、无 staleness 清理)。
补充一点关于 noteTurnReceived 的闸:它查的是 ds.subjectListenerTurns(per-DaemonSession),而 reroute 场景下 turn 交给了竞态赢家那个 ds,它的表里没有这个 turnId → 闸静默失效,reaction 会加上去(20448 等处)。任何跨 ds 的 turn 转移都有这个通用弱点。
R-1 在你自己的 .plan/subject-listener/review-followups.md 里已标为「必须修复」,这条与之一致 —— 目前代码里确实还没做。
F3(阻断)cursor_lost 时全量扫历史,无绝对上界
subject-listener-context.ts:101-108 的 stopAfter,在有 cursor 时只有一个终止条件:messageIdOf(message) === input.cursor.messageId。游标消息被撤回/删除/超出保留期后,这个条件永不成立,listChatMessagesUntil 会一路翻页到聊天历史开头,然后才 用 slice(-fallbackMessages) 裁成 20 条。
我用桩模拟 5000 条消息的群实测:continuous 路径只扫 6 条,cursor_lost 路径扫满 5000 条 = 100 次 API 分页请求,最终只用 20 条。
对比同一个 helper 的其它三个调用方,它们都有绝对上界:
event-dispatcher.ts:2548—seenCount >= MESSAGE_LISTENER_BACKFILL_SCAN_LIMIT(100) 或 时间窗dashboard-ipc-server.ts:3894—seenCount >= max(100, limit*5)或 时间窗summary-command.ts:359—kept >= range.limit或sinceMs
Subject 是唯一没有的。建议在 stopAfter 里加 seenCount 上界(比如 fallbackMessages 的若干倍,或复用 MESSAGE_LISTENER_BACKFILL_SCAN_LIMIT 量级)+ 一个时间窗,两者取或。
F4(阻断)Subject prompt 无任何长度上限
legacy 路径在 renderMessageListenerPrompt(message-listener.ts:541)用 truncateUtf8(..., MAX_MESSAGE_LISTENER_PROMPT_BYTES) 截到 32 KiB;renderSubjectMessageListenerPrompt(571-604)对 snapshot.messages 完全不截断,整条 JSON 原样 escapeXml 后拼进去。
实测(fallbackMessages 上限 200):
| 消息数 | 单条正文 | prompt 大小 |
|---|---|---|
| 20 | 200 B | 14.2 KiB |
| 200 | 200 B | 125.7 KiB |
| 200 | 2 KB | 477.3 KiB |
| 200 | 20 KB | 3.9 MiB |
MAX_SUBJECT_FALLBACK_MESSAGES = 200 是管理员可配的,飞书单条消息可以很长(富文本/卡片 JSON 尤其),所以 MB 级 prompt 是配置可达的,不是极端构造。建议对整体 prompt 或 per-message 正文加字节上限。
另外与此叠加的一个放大效应:游标只在「可见回复已送达」或「明确 BOTMUX_NOTHING_TO_SEND」时推进。若模型输出的是散文 + sentinel 或纯散文,isBridgeNothingToSendFinal(bridge-fallback-gate.ts:114)要求「剥掉 sentinel 后为空」才算静默证据 → 拿不到 nothing_to_send → 走 scheduleAmbiguousSubjectListenerSettlement 30s 超时,游标不推进。我实测了游标卡住后的效果:后续每个 turn 都会把「卡住位置之后的全部消息」重新喂一遍,条数随时间单调增长(trigger om_8→5 条、om_10→7 条、om_12→9 条)。也就是说模型稍不守协议,成本会持续爬升。建议明确一下这种情况的兜底。
非阻断
-
N1:
readSubjectListenerCursor(cursor-store.ts:55-59)用裸catch { return undefined },把 EACCES/EIO 这类真实 I/O 故障和「文件损坏」压成同一个返回值,调用方无法区分。注释写的是「Corrupt, partially migrated, or absent state is deliberately fail-open」——损坏/缺失 fail-open 合理,但真实 I/O 故障静默降级成cold_start会导致每轮重喂最近 N 条。这正是你.plan里 R-2 标的「必须修复」。(我是 root,chmod 000无法造出 EACCES 前置条件,所以这条我按代码路径 + 损坏文件代理验证,不是直接复现。) -
N2:每条未被 @ 的群消息都 spawn 一个完整 CLI 会话(
replyPolicy.sessionMode = 'per_message'),且 Subject 不要求配contentPolicy(matchesContentPolicy在 policy 缺省时直接return true)。一个中等活跃的群会持续起进程。建议文档里明确建议配contentPolicy收窄,或对 Subject 加节流。 -
N3:
.plan/subject-listener/**(15 个文件、约 1000 行)是本 PR 引入的新目录,master 上没有.plan/,.gitignore也没有相关条目。里面是 sprint 计划、eval rubrics、feedback 记录。想确认一下这是有意长期入库,还是开发过程产物 —— 如果是后者,建议加进.gitignore。(内容我扫过,没有真人姓名或内部协作花名,这点没问题。) -
N4:
daemon.ts:18670的 repo picker 发卡点,当前靠「18355那段给pinnedWorkingDir强行赋值 +statSync失败会 throw」这个间接不变量挡住,没有显式 Subject 断言。我没能构造出绕过路径,所以不算缺陷;但一旦有人把那处 throw 改成 warn+continue,这里会立刻变成裸奔的发卡点。建议补一句显式断言把不变量钉死。 -
N5(测试质量):
subject-listener-context.test.ts:152-156那条大整数用例,用的两个 17 位值90071992547409930/...929在Number()下塌成同一个 double(都是90071992547409940,我实测确认)。于是它实际走的是同一道闸里的=== 0相等分支,看似在测精度,其实没测到精度。我把compareSubjectListenerCreateTime变异成Number(left) - Number(right),这条用例照样绿。
需要说清的是严重度不高:真实飞书create_time是 13 位毫秒值,距 2^53 还有约 5000 倍余量,所以当前字符串比较属于「正确且防御性」,精度问题用真实数据不可达。这条是测试没钉住实现,不是线上缺陷。建议补一条直测compareSubjectListenerCreateTime的用例,取一对不会塌成同一 double 的值。 -
N6(测试质量):
renderMessageListenerPrompt里if (!subjectContext) throw new Error('Subject listener prompt requires Lark context')(message-listener.ts:536-537)零断言覆盖(grep "requires Lark context" test/无命中),改成return ''后 83 项全绿。这道闸目前生产不可达(3 个调用点结构上互斥),属「承重但无覆盖」的防御性闸 —— 结论是补测而非删:它守的是「将来新增调用点忘传 context 时,不要静默降级成空 prompt」,而空 prompt 意味着整个可信协议注入丢失。 -
N7:
isSubjectListenerTurn的单测(subject-listener-runtime.test.ts:213)是对谓词直调的断言。把该谓词改成永远false后,只有这条直调断言红;把它临时注掉再跑,8 项全绿 —— 说明下游那些sessionReply/buildStreamingCard/addReaction的not.toHaveBeenCalled()在该 fixture 下本来就不会被调用,绿不构成证据。即「Subject 轮次不泄漏卡片/reaction」这条对外可见属性,目前靠直调断言而非行为断言钉住。这条与 F1 是同一个根因,修 F1 时建议一并补行为断言。顺带一个定位提示:cursor 的测试实际都在
subject-listener-context.test.ts里,message-listener-store.test.ts对 cursor 零覆盖 —— 后续加 cursor 用例时别按文件名找错地方。
做得好的一处(值得保留):prepareSubjectListenerTurn 的校验闸不只「存在」,它的相对位置也被钉住了。我把整道 if (...) throw 形状一字不改地搬到函数末尾(return 之前),结果 tsc 直接 TS2322(早退的类型收窄消失),测试也红在 subject-listener-turn.test.ts:201-204 的 4 个 not.toHaveBeenCalled()(resolveSender / getChatContext / readSubjectListenerCursor / listChatMessagesUntil)。即「任何现场读取前拒绝、零副作用 fail-closed」这个顺序属性被显式守护了,不是只守了「闸在不在」。
关于验证
PR 描述里的测试数据我复核一致。但想指出一点:F1/F2 这两类漏点,现有 626 个单测全绿也发现不了 —— 它们都在 worker 生命周期的异常分支(IPC 超时、fork 失败、CAS 竞态 reroute),单测的 mock 边界够不到。如果修 F1/F2,建议补的是能走到那些分支的用例(而不是只断言 isSubjectListenerTurn 本身的返回值),否则同类漏点还会再出现。
以上是自动评审的初步意见,可能有误判,最终以维护者审阅为准。F1~F4 里如果有你认为是设计取舍而非缺陷的,欢迎直接说,我再核。
|
这是自动评审的复审意见(第二位评审),接续上一条首评。最终以维护者审阅为准。 我在最新 对首评 4 个阻断的复核F1(阻断)—— 确认,最实的一条。 4 处漏点( F2 —— 复核后认为首评的阻断理由(「FIFO 永久卡死」)不成立,应降级为理论风险 / 防御性加固。 两条独立理由:
但 F2 指出的不对称本身是真的: F3(阻断)—— 确认。 F4(阻断)—— 核心问题确认,但「放大效应」描述需更正。 Subject prompt 确实无字节上限(legacy 截 32KiB),200 × 20KB = 3.9 MiB 配置可达,应加上限。但首评称「模型输出散文+sentinel 或纯散文 → 拿不到 nothing_to_send → 游标不推进 → 条数单调增长」——这一步不对:散文(无论带不带 sentinel)会作为 对首评三个问题的回答
非阻断N1–N7 复核属实(抽查了 N1 裸 catch、N3 以上为自动评审初步意见,可能有误判,最终以维护者审阅为准。 |
更正(复审后自查)复审指出我上面 F2 和 F4 的部分论证不成立,我逐条独立复核后确认更正如下。原文保留在上面便于对照,但请以本节为准。 F2 降级为「理论风险 / 建议合入前修」,不再是阻断我原文写「该群 Subject FIFO 永久卡死」,这个结论是错的。两处独立兜底我漏看了:
我进一步核了 另外 reroute 可达性:Subject 的 session key 是 不对称本身仍然是真的: F4 核心结论不变,但「放大效应」那段是错的「prompt 无字节上限」成立,200×20KB = 3.9 MiB 配置可达,仍建议加上限。 但我原文推的「散文 / 散文+sentinel → 拿不到
真正不推进游标的只有「模型整轮零输出」(走 30s ambiguous 超时),而那是设计好的 fail-closed,且需要持续零输出才会累积。我先前那份「游标卡住 → 单调增长」的实测(om_8→5、om_10→7、om_12→9 条)本身没错,但它证明的是「假如游标卡住会怎样」,我错在把它当成了散文场景的必然后果 —— 前提不成立,推论就不成立。 所以 F4 请按「单轮 prompt 可能达 MB 级」这个理由看,而不是「游标卡住导致成本无界增长」。 维持阻断的三条F1(4 处静默漏点)、F3( 补充一条非阻断已 基线更新master 已从 以上仍是自动评审意见,最终以维护者审阅为准。 |
改了什么
messageListeners上增加向前兼容的behavior: "subject";缺省仍为普通提示词监听BOTMUX_NOTHING_TO_SEND时推进游标bots.json为什么
让 Bot 在未被明确指派时,先理解群、说话人和对话现场,再判断是否静默、回复、执行或路由已有能力,同时保留 @ 的确定性工作场景。协议与现场准备职责独立后,Subject 仍是主体层 Skill,CLI 仍是思考和本地操作工具,不需要第二套运行时。
影响面
验证
bun x vitest run --project unit test/subject-listener-context.test.ts test/subject-listener-runtime.test.ts test/message-listener.test.ts test/event-dispatcher.test.ts test/bridge-final-output-retry.test.ts test/message-listener-store.test.ts test/dashboard-ipc.test.ts:7 files,704 tests passedbun x vitest run --project e2e test/subject-listener-runtime.e2e.ts test/dashboard-message-listener-subject.e2e.ts:2 files,2 tests passedbun x vitest run --project unit test/subject-listener-turn.test.ts test/message-listener.test.ts:2 files,42 tests passedbun x vitest run --project unit test/subject-listener-context.test.ts test/subject-listener-runtime.test.ts test/event-dispatcher.test.ts:3 files,345 tests passedbun x vitest run --project e2e test/subject-listener-runtime.e2e.ts:1 file,1 test passedbun run build:通过 TypeScript、scripts typecheck、Dashboard bundle、dist audit 与 embedded-assets audit未切换或重启 live daemon,本次验证未影响当前运行中的 Bot;Dashboard 截图与真实飞书监听验收留到最终 R-5。