Skip to content

feat(subject): 增加基于飞书上下文的主体监听 - #1252

Open
qiaomu427 wants to merge 4 commits into
deepcoldy:masterfrom
qiaomu427:codex/subject-listener
Open

feat(subject): 增加基于飞书上下文的主体监听#1252
qiaomu427 wants to merge 4 commits into
deepcoldy:masterfrom
qiaomu427:codex/subject-listener

Conversation

@qiaomu427

@qiaomu427 qiaomu427 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

改了什么

  • 在现有 messageListeners 上增加向前兼容的 behavior: "subject";缺省仍为普通提示词监听
  • 未被明确 @ 的群消息进入 Subject;明确 @ 当前 Bot 始终走原有普通会话和可见回执
  • Subject 从飞书群资料、发送者和增量消息记录获取上下文,不依赖 CLI session transcript
  • 按 Bot + 群串行处理并持久化成功游标;冷启动或游标丢失时回退最近 N 条,默认 20
  • 无需介入时完全静默;仅在实际回复送达或明确 BOTMUX_NOTHING_TO_SEND 时推进游标
  • Dashboard 支持选择 Subject、配置 fallbackMessages、保存、预览和试运行;非法配置返回 400 且不修改 bots.json
  • 将可信 Subject 协议提取为稳定模块,并新增可测试的 turn 准备边界,集中校验飞书 trigger、解析发送者、读取群资料/游标/消息快照和生成 prompt;继续复用现有 CLI、session 与 worker

为什么

让 Bot 在未被明确指派时,先理解群、说话人和对话现场,再判断是否静默、回复、执行或路由已有能力,同时保留 @ 的确定性工作场景。协议与现场准备职责独立后,Subject 仍是主体层 Skill,CLI 仍是思考和本地操作工具,不需要第二套运行时。

影响面

  • 涉及公共 Lark message-listener、daemon/worker turn 生命周期和 Dashboard 配置路径
  • Subject 特例按 turnId 隔离;legacy listener、私聊、明确 @、普通话题/群会话与 adopt/restore 语义不变
  • CLI adapter 与 Pty/Tmux/Zellij/Herdr 等后端协议未修改;handoff/workflow/schedule 继续走现有能力
  • 当前 Subject 数据源只接受 Lark;新增拆分不包含其它 IM、公共配置变更或第二套 session/runtime

验证

  • 前序功能验证: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 passed
  • 前序端到端验证:bun x vitest run --project e2e test/subject-listener-runtime.e2e.ts test/dashboard-message-listener-subject.e2e.ts:2 files,2 tests passed
  • 架构拆分与变基后:bun x vitest run --project unit test/subject-listener-turn.test.ts test/message-listener.test.ts:2 files,42 tests passed
  • 架构拆分与变基后:bun 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 passed
  • 架构拆分与变基后:bun x vitest run --project e2e test/subject-listener-runtime.e2e.ts:1 file,1 test passed
  • bun run build:通过 TypeScript、scripts typecheck、Dashboard bundle、dist audit 与 embedded-assets audit

未切换或重启 live daemon,本次验证未影响当前运行中的 Bot;Dashboard 截图与真实飞书监听验收留到最终 R-5。

@qiaomu427
qiaomu427 requested a review from deepcoldy as a code owner September 4, 2026 08:42
@qiaomu427
qiaomu427 force-pushed the codex/subject-listener branch from 6181381 to 0b69d27 Compare September 4, 2026 08:45
@qiaomu427
qiaomu427 force-pushed the codex/subject-listener branch from a67f2b0 to f311dd2 Compare September 4, 2026 09:52
@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR,设计思路清晰,.plan/ 里的分层拆解和自查记录都很扎实。下面是自动评审的初步意见,最终以维护者审阅为准。

我在最新 origin/masterdb6a30823)上核对了基线(PR 分支已在其之上,无需 rebase),本地 bun run build 通过,PR 相关 7 个单测文件 626 passed / 1 skipped,2 个 e2e passed,CI 4/4 绿。

先说确认没问题的部分

  • 注入防护有效:我用 5 种破标签形态(闭合 </lark_history>、伪造 <subject_protocol trusted="true">、属性引号逃逸、实体绕过、前导空格)打 renderSubjectMessageListenerPrompt,全部被 escapeXml 收在 trusted="false" 信封内。为确认探针不是哑弹,我另写了一份不转义的对照实现,同样载荷下立刻检出 breakout(trusted 标签 1→5),说明 CONTAINED 结论成立。
  • 可信/不可信分层:协议与管理员 scope 走 trusted="true",飞书数据全部进 trusted="false",边界干净。
  • 游标单调性compareSubjectListenerCreateTime 用字符串长度+字典序比较,规避了大整数 Number 精度丢失;相同 createTime 不同 messageId 时保留旧游标(fail-closed),实测确认。
  • 命令注入不可达:Subject prompt 首字符必为 <parseSlashCommandInvocation 不会命中,斜杠命令分支结构上走不到。
  • 配额不误伤listenerAuthorized: !!messageListenerenforceMessageQuotaForCliInput 第一行短路,不会发额度卡。
  • 重启不重放claimMessageOnce 持久化去重,冷启动 backfill 不会把已处理消息再跑一遍。

建议合入前修复

F1(阻断)静默承诺有 4 处漏点:isSilentScheduledTurn 有闸、Subject 没有

这是最实的一条。全仓 isSilentScheduledTurn 共 6 个调用点,其中只有 3 处配了 isSubjectListenerTurn

位置 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

可达性我核过了,不是理论问题:shouldTrackOrdinaryImDeliveryworker-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,更彻底的是把 registerSubjectListenerTurndaemon.ts:18627 前移到 prompt 准备点之后立刻执行。当前注册点在 handleNewTopicAdmitted 很靠后的位置,导致 17952→18627 这段区间里 ds 上根本没有 Subject 标记,所有 turn-exact 闸(streamingCardDisabledFornoteTurnReceivedauxUiSuppressedFor)在这段都天然失效,只能靠局部 isSubjectListener 布尔量逐个补 —— 你已经补了 2 处(17848 语音、18292 needLogin),但 18604/18614/18654replyInvalidWorkingDirs 还没补,它末尾(daemon.ts:17203)是无条件 sessionReply。前移注册点能一次性关掉这一整类。

F2(阻断)handleThreadReplyAdmitted 整条链零 Subject 处理

daemon.ts:18571claimNewDaemonSession 返回 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 不会被 settlesubjectListenerCompletion 全部 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-108stopAfter,在有 cursor 时只有一个终止条件:messageIdOf(message) === input.cursor.messageId。游标消息被撤回/删除/超出保留期后,这个条件永不成立,listChatMessagesUntil 会一路翻页到聊天历史开头,然后才slice(-fallbackMessages) 裁成 20 条。

我用桩模拟 5000 条消息的群实测:continuous 路径只扫 6 条,cursor_lost 路径扫满 5000 条 = 100 次 API 分页请求,最终只用 20 条。

对比同一个 helper 的其它三个调用方,它们都有绝对上界:

  • event-dispatcher.ts:2548seenCount >= MESSAGE_LISTENER_BACKFILL_SCAN_LIMIT(100) 时间窗
  • dashboard-ipc-server.ts:3894seenCount >= max(100, limit*5) 时间窗
  • summary-command.ts:359kept >= range.limit sinceMs

Subject 是唯一没有的。建议在 stopAfter 里加 seenCount 上界(比如 fallbackMessages 的若干倍,或复用 MESSAGE_LISTENER_BACKFILL_SCAN_LIMIT 量级)+ 一个时间窗,两者取或。

F4(阻断)Subject prompt 无任何长度上限

legacy 路径在 renderMessageListenerPromptmessage-listener.ts:541)用 truncateUtf8(..., MAX_MESSAGE_LISTENER_PROMPT_BYTES) 截到 32 KiB;renderSubjectMessageListenerPrompt571-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 或纯散文,isBridgeNothingToSendFinalbridge-fallback-gate.ts:114)要求「剥掉 sentinel 后为空」才算静默证据 → 拿不到 nothing_to_send → 走 scheduleAmbiguousSubjectListenerSettlement 30s 超时,游标不推进。我实测了游标卡住后的效果:后续每个 turn 都会把「卡住位置之后的全部消息」重新喂一遍,条数随时间单调增长(trigger om_8→5 条、om_10→7 条、om_12→9 条)。也就是说模型稍不守协议,成本会持续爬升。建议明确一下这种情况的兜底。

非阻断

  • N1readSubjectListenerCursorcursor-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 不要求配 contentPolicymatchesContentPolicy 在 policy 缺省时直接 return true)。一个中等活跃的群会持续起进程。建议文档里明确建议配 contentPolicy 收窄,或对 Subject 加节流。

  • N3.plan/subject-listener/**(15 个文件、约 1000 行)是本 PR 引入的新目录,master 上没有 .plan/.gitignore 也没有相关条目。里面是 sprint 计划、eval rubrics、feedback 记录。想确认一下这是有意长期入库,还是开发过程产物 —— 如果是后者,建议加进 .gitignore。(内容我扫过,没有真人姓名或内部协作花名,这点没问题。)

  • N4daemon.ts:18670 的 repo picker 发卡点,当前靠「18355 那段给 pinnedWorkingDir 强行赋值 + statSync 失败会 throw」这个间接不变量挡住,没有显式 Subject 断言。我没能构造出绕过路径,所以不算缺陷;但一旦有人把那处 throw 改成 warn+continue,这里会立刻变成裸奔的发卡点。建议补一句显式断言把不变量钉死。

  • N5(测试质量)subject-listener-context.test.ts:152-156 那条大整数用例,用的两个 17 位值 90071992547409930 / ...929Number()塌成同一个 double(都是 90071992547409940,我实测确认)。于是它实际走的是同一道闸里的 === 0 相等分支,看似在测精度,其实没测到精度。我把 compareSubjectListenerCreateTime 变异成 Number(left) - Number(right),这条用例照样绿。
    需要说清的是严重度不高:真实飞书 create_time 是 13 位毫秒值,距 2^53 还有约 5000 倍余量,所以当前字符串比较属于「正确且防御性」,精度问题用真实数据不可达。这条是测试没钉住实现,不是线上缺陷。建议补一条直测 compareSubjectListenerCreateTime 的用例,取一对不会塌成同一 double 的值。

  • N6(测试质量)renderMessageListenerPromptif (!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 意味着整个可信协议注入丢失。

  • N7isSubjectListenerTurn 的单测(subject-listener-runtime.test.ts:213)是对谓词直调的断言。把该谓词改成永远 false 后,只有这条直调断言红;把它临时注掉再跑,8 项全绿 —— 说明下游那些 sessionReply/buildStreamingCard/addReactionnot.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 里如果有你认为是设计取舍而非缺陷的,欢迎直接说,我再核。

@deepcoldy

Copy link
Copy Markdown
Owner

这是自动评审的复审意见(第二位评审),接续上一条首评。最终以维护者审阅为准。

我在最新 origin/master86e99a3cf,比首评时的 db6a30823 多了 #1202)上把 PR 分支本地 rebase(无冲突),bun run build 通过,PR 相关单测 72 passed、2 个 e2e passed,master 新增的 session-occupancy 测试也通过。CI 4/4 绿跑在旧 base 上,合入前建议 rebase 后重跑一次 CI。

对首评 4 个阻断的复核

F1(阻断)—— 确认,最实的一条。 4 处漏点(worker-pool.ts 7527 / 7558 / 10790、1404)逐一核过,shouldTrackOrdinaryImDelivery 三条准入(om_ 前缀 + dispatchAttempt undefined + prompt 非空)Subject turn 全中。其中 7558 的软超时只有 2 秒(ORDINARY_IM_ACK_SETTLEMENT_TIMEOUT_MS),worker 冷启动稍慢就在群里发「输入还在等 worker」——与 PR 核心承诺「完全静默」直接冲突。注册点(daemon.ts 18634)在 fork(18644)之前,补 isSubjectListenerTurn 判定即可兜住。

F2 —— 复核后认为首评的阻断理由(「FIFO 永久卡死」)不成立,应降级为理论风险 / 防御性加固。 两条独立理由:

  1. reroute 路径当前不可达:Subject turn 的 session key 是 (messageId, appId)(thread-scope 的 rootMessageId = 自己的 messageId),而 claimMessageOnce 按 message_id 持久化同步去重——同一条消息不会被投两次,没有并发抢同一 key 的第二个 creator,existing_owner 对 Subject turn 结构上不触发。
  2. 即使进入也不会卡死 FIFOhandleNewTopic 的 finally(daemon.ts 17674-17677)对未 claimed 的 Subject turn 一律 settle('failed');lane 尾部(event-dispatcher.ts 2495)对直接走 handleThreadReply 的路径也有同样兜底。dispatch() 只要返回或抛错,gate 就 settle、lane 就释放;worker 挂死也有 turnTimeoutMs 兜底强制 terminal。

但 F2 指出的不对称本身是真的handleThreadReplyAdmitted 整条链零 Subject 处理(语音转写失败、needLogin、选库卡、ingress 失败通知),且这种 turn 不会被 registerSubjectListenerTurn 登记 → 游标不推进。作者自己的 .plan/subject-listener/review-followups.md R-1 已标「必须修复」,与此一致。建议合入前修,但理由是「静默保证的防御纵深」,不是「FIFO 卡死」。

F3(阻断)—— 确认。 stopAfter 有 cursor 时唯一终止条件是撞见 cursor 那条消息,无 seenCount 上界、无时间窗;同一 helper 另外 3 个调用方都有绝对上界。cursor 消息被撤回/删除后全量扫历史(5000 条 ≈ 100 次分页)只为取 20 条,且整段扫描期间该群 Subject FIFO 被占住。

F4(阻断)—— 核心问题确认,但「放大效应」描述需更正。 Subject prompt 确实无字节上限(legacy 截 32KiB),200 × 20KB = 3.9 MiB 配置可达,应加上限。但首评称「模型输出散文+sentinel 或纯散文 → 拿不到 nothing_to_send → 游标不推进 → 条数单调增长」——这一步不对:散文(无论带不带 sentinel)会作为 final_output 投递,投递成功即经 commitSubjectListenerTurnCursor(..., 'final_output')worker-pool.ts 15067)推进游标,e2e 日志里也能看到 evidence=final_output 的提交。游标真正不推进的只有「模型整轮零输出」(走 30s ambiguous 超时),那是设计好的 fail-closed,且需模型持续零输出才会累积,并不常见。大小上限仍需独立加,理由是「单轮 prompt 可能 MB 级」,不是「游标卡住导致单调增长」。

对首评三个问题的回答

  1. F2 算阻断还是理论风险:理论风险(当前不可达),不是阻断。但作者已自列 R-1 必须修复,建议合入前一并修。
  2. 11405 managedFinalOutputSuppressed 是否有意为之:是有意的。final_output 正是 Subject 设计的发言通道(e2e 断言 evidence=final_output 提交游标),不该挡。「模型不严格输出 sentinel 时散文直接进群」是该产品形态的固有属性——模型「决定发言」本身就是通过产出回复实现的,属设计权衡,不是漏。同意首评定性。
  3. 变异测试同侪写者污染:本轮关键结论(F1 可达性、F2 不可达、F4 的 final_output 推进路径)均从代码独立核出,不依赖变异探针。git diff --quiet 前置检查是好习惯。

非阻断

N1–N7 复核属实(抽查了 N1 裸 catch、N3 .plan/ 已入库且 .gitignore 无条目、N5 两个 17 位值在 Number() 下塌成同一 double、N6 零断言覆盖、N7 仅直调断言)。另补一条:claimed turn 的 completion gate 没有硬性看门狗——worker 挂死时 lane 要等 turnTimeoutMs 才释放,虽有上界,建议给 gate 本身加超时兜底。


以上为自动评审初步意见,可能有误判,最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner

更正(复审后自查)

复审指出我上面 F2 和 F4 的部分论证不成立,我逐条独立复核后确认更正如下。原文保留在上面便于对照,但请以本节为准。

F2 降级为「理论风险 / 建议合入前修」,不再是阻断

我原文写「该群 Subject FIFO 永久卡死」,这个结论是错的。两处独立兜底我漏看了:

  1. daemon.ts:17670finally 对未 claimed 的 Subject turn 一律 settle('failed')
  2. event-dispatcher.ts:2493 lane 尾部有同样的 if (!completion.claimed) completion.settle('failed')

我进一步核了 claimed = true唯一写入点是 worker-pool.ts:822registerSubjectListenerTurn),而它只在 handleNewTopic 路径被调用。所以走 handleThreadReply 的 turn 必然保持 claimed === false → 必然被 2493 那行 settle。且 handleThreadReply 自己的 .catch(notifyOrdinaryIngressFailure)19312)会让 dispatch 正常 resolve 而非抛出,正好落到 2493。lane 不会卡死。

另外 reroute 可达性:Subject 的 session key 是 (messageId, appId),而 claimMessageOncemessage_id 持久化同步去重,同一条消息不会被投两次 → 不存在抢同一 key 的第二个 creator,existing_owner 对 Subject 结构上不触发。我原文说「没能构造出确定的并发序列」,那份保守是对的,但我不该在没有可达性证明的情况下把后果写成阻断级。

不对称本身仍然是真的handleThreadReplyAdmitted 零 Subject 处理,19344178481992118292 两组不对称成立,且这种 turn 永不登记 ⟹ 游标不推进。建议合入前修,但理由是「静默保证的防御纵深」,不是「FIFO 卡死」。 这也正是作者 .plan 里 R-1 已自列为必须修复的那一条。

F4 核心结论不变,但「放大效应」那段是错的

「prompt 无字节上限」成立,200×20KB = 3.9 MiB 配置可达,仍建议加上限

但我原文推的「散文 / 散文+sentinel → 拿不到 nothing_to_send → 游标不推进 → 后续每轮重喂、条数单调增长」这条链不成立。散文会作为 final_output 正常投递,而投递成功即提交游标 —— 两条路径我都核过:

  • worker-pool.ts:15067(正常投递后 commitSubjectListenerTurnCursor(..., 'final_output')
  • worker-pool.ts:14172(dedupe 分支同样提交并 settle)

真正不推进游标的只有「模型整轮零输出」(走 30s ambiguous 超时),而那是设计好的 fail-closed,且需要持续零输出才会累积。我先前那份「游标卡住 → 单调增长」的实测(om_8→5、om_10→7、om_12→9 条)本身没错,但它证明的是「假如游标卡住会怎样」,我错在把它当成了散文场景的必然后果 —— 前提不成立,推论就不成立。

所以 F4 请按「单轮 prompt 可能达 MB 级」这个理由看,而不是「游标卡住导致成本无界增长」。

维持阻断的三条

F1(4 处静默漏点)、F3(cursor_lost 全量扫历史无上界)、F4(prompt 无字节上限)维持。F1 的严重度还要往上调一点:7558 那条软超时只有 2s(ORDINARY_IM_ACK_SETTLEMENT_TIMEOUT_MS),worker 冷启动稍慢就会在群里发「输入还在等 worker」,直接打穿「完全静默」承诺。

补充一条非阻断

claimed 的 completion gate 没有自己的看门狗:worker 挂死时该群 lane 要等 turnTimeoutMs 才释放。有上界所以非阻断,但建议给 gate 本身加一道超时兜底。

基线更新

master 已从 db6a30823 前进到 86e99a3cf#1202 occupancy 写 SQLite)。我把 PR 本地 rebase 到最新 master:无冲突bun run build 通过,PR 相关 7 个单测文件 626 passed / 1 skipped、2 个 e2e passed,master 新增的 session-occupancy.test.ts 17 项也通过。当前 CI 4/4 绿是跑在旧 base 上的,合入前建议 rebase 重跑。

以上仍是自动评审意见,最终以维护者审阅为准。

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.

2 participants