现象
编排器的群聊回复在广播失败后直接丢弃:cognitiveOrchestrator.ts:818-824 是 try { await broadcastGroupChat(...) } catch { console.error('[Orchestrator] Broadcast failed:', ...); return }。回复文本、失败原因、以及"欠哪条消息一条回复"三者都不落任何表——git grep "INSERT\|UPDATE " -- src/main/services/cognitiveOrchestrator.ts 只有 4 处 UPDATE group_chat_tasks、零 INSERT。
一次瞬时故障(费率抖动 / 网关 5xx / Metalet 断连)= 一条回复凭空消失,用户与对端都无从知晓,机器人也不会再补。
三条实测事实(均以 upstream/main = 5026b235 为基线,非工作树)
- 触发消息的线索被同一个 tick 吞掉
[已证实]:失败分支在 :823 早退后不会回滚游标,tick() 继续走到 :991-994 把 last_processed_msg_id = maxProcessedId;下次 tick 的 id > last_processed_msg_id 再也选不中那条消息。失败与成功在游标层面不可区分。
- 占位消息会先上链,形成半状态
[已证实]:skill 特权路径在正文之前先广播一条 copyRespondingPlaceholder()(:697)。正文若失败,链上已有一条占位——用户看到的是"回了但没回全",比直接沉默更难诊断。
- 接口层把 ACK 的路断了
[已证实]:BroadcastGroupChatFn 返回 Promise<void>(:124-129),实现体丢掉了 sendGroupChatMessage 本可返回的 pinId(groupChatTransport.ts:300)。没有 pinId 就没有可回读的 ACK。
为什么现有机制救不了它
privateChatBackfillService / groupChatBackfillService 都是入站历史补拉(把别人的历史拉回本地),树内没有任何代码路径能把"本机一条发送失败、待投递的回复"重新广播出去。
groupSendLedger(R4 单发去重)故意进程内不持久化(注释:turn 不会跨重启),只回答"本轮是否已发过"。
metabot_chain_writes 也接不住它:群聊发送被双重排除——路径 /protocols/simplegroupchat(chainWriteLedger.ts:29-35)与 origin internal:group-chat(:41-49)都在排除集内。
设计提案(若方向被接受,我出 PR)
核心是把"发出去"当成一条有终态的持久义务,而不是一次 fire-and-forget:
- 义务:
(metabot_id, group_id, idempotency_key, trigger_msg_id, state, attempts, last_error, pin_id?)。
- 状态:
PENDING → SUBMITTED → CONFIRMED / ABANDONED;createPin 返回的 pinId 只标 SUBMITTED(只证明本机写入),只有外部回读命中才 CONFIRMED。
- 幂等键可直接算出:群聊加密是 AES-CBC + 固定 IV
0000000000000000 + key=群号前 16 字符(metaWebCrypto.ts:30,注意该函数名为 encryptGroupMessageECB 但实际是 CBC)⇒ 同 (content, groupId) 密文逐字节确定 ⇒ k = sha256(ciphertext_hex) 可在发送前算好,且重复投递在链上公开可见。
- 终止判据不另造口径:直接沿用树内现成的多源判据
verifyPinSources()(groupTaskDaemon.ts:9151-9180):verified = found && !notFound,即至少一源命中且无任何源硬 404;主源 getPinData、副源 metafile-indexer。单源 404 在索引滞后期不算"不存在"。
- 不重造骨架:
agentGame/runtime.ts:380-420 retryPendingWrite() 是树内唯一「写前登记意图 + 外部回显终止」的先例(退避 [2s,5s,15s,30s]),agent_game_write_log 的 UNIQUE (group_id, action_seq, event_id) 正是义务表的现成式样。
一条必须写进实现的硬约束
chainWriteBudget.ts:26-31 的报错文案已经把原则写清楚了:
"the underlying on-chain operation was abandoned, NOT cancelled — it may still land. Do NOT blindly re-send the same content: first verify whether it arrived (group log / on-chain lookup), then retry only if it is genuinely missing."
我们自己在私信侧刚吃过反面的教训:投递层重试没有 ACK 终止条件,导致同一段 1501 字节的内容在 40 分钟内被逐字重复上链 5 次、对端四次投诉。所以本设计里:SUBMITTED 后的冷却期必须长于实测索引延迟,否则"还没被确认"会被误判成"发送失败"而重发,精确重演那个事故。冷却期要用实测值定成显式常量 + 断言,不做魔法数字。
想确认的一件事
这个方向上游是否接受?接受的话我按上面的边界出 PR(含「单源 404 不算不存在」与「冷却期内禁止重发」两组回归用例);如果不接受、或你们已有别的计划,我就不占用 review 资源。
现象
编排器的群聊回复在广播失败后直接丢弃:
cognitiveOrchestrator.ts:818-824是try { await broadcastGroupChat(...) } catch { console.error('[Orchestrator] Broadcast failed:', ...); return }。回复文本、失败原因、以及"欠哪条消息一条回复"三者都不落任何表——git grep "INSERT\|UPDATE " -- src/main/services/cognitiveOrchestrator.ts只有 4 处UPDATE group_chat_tasks、零 INSERT。一次瞬时故障(费率抖动 / 网关 5xx / Metalet 断连)= 一条回复凭空消失,用户与对端都无从知晓,机器人也不会再补。
三条实测事实(均以
upstream/main=5026b235为基线,非工作树)[已证实]:失败分支在:823早退后不会回滚游标,tick()继续走到:991-994把last_processed_msg_id = maxProcessedId;下次 tick 的id > last_processed_msg_id再也选不中那条消息。失败与成功在游标层面不可区分。[已证实]:skill 特权路径在正文之前先广播一条copyRespondingPlaceholder()(:697)。正文若失败,链上已有一条占位——用户看到的是"回了但没回全",比直接沉默更难诊断。[已证实]:BroadcastGroupChatFn返回Promise<void>(:124-129),实现体丢掉了sendGroupChatMessage本可返回的pinId(groupChatTransport.ts:300)。没有 pinId 就没有可回读的 ACK。为什么现有机制救不了它
privateChatBackfillService/groupChatBackfillService都是入站历史补拉(把别人的历史拉回本地),树内没有任何代码路径能把"本机一条发送失败、待投递的回复"重新广播出去。groupSendLedger(R4 单发去重)故意进程内不持久化(注释:turn 不会跨重启),只回答"本轮是否已发过"。metabot_chain_writes也接不住它:群聊发送被双重排除——路径/protocols/simplegroupchat(chainWriteLedger.ts:29-35)与 origininternal:group-chat(:41-49)都在排除集内。设计提案(若方向被接受,我出 PR)
核心是把"发出去"当成一条有终态的持久义务,而不是一次 fire-and-forget:
(metabot_id, group_id, idempotency_key, trigger_msg_id, state, attempts, last_error, pin_id?)。PENDING → SUBMITTED → CONFIRMED / ABANDONED;createPin返回的pinId只标SUBMITTED(只证明本机写入),只有外部回读命中才CONFIRMED。0000000000000000+ key=群号前 16 字符(metaWebCrypto.ts:30,注意该函数名为encryptGroupMessageECB但实际是 CBC)⇒ 同(content, groupId)密文逐字节确定 ⇒k = sha256(ciphertext_hex)可在发送前算好,且重复投递在链上公开可见。verifyPinSources()(groupTaskDaemon.ts:9151-9180):verified = found && !notFound,即至少一源命中且无任何源硬 404;主源getPinData、副源 metafile-indexer。单源 404 在索引滞后期不算"不存在"。agentGame/runtime.ts:380-420 retryPendingWrite()是树内唯一「写前登记意图 + 外部回显终止」的先例(退避[2s,5s,15s,30s]),agent_game_write_log的UNIQUE (group_id, action_seq, event_id)正是义务表的现成式样。一条必须写进实现的硬约束
chainWriteBudget.ts:26-31的报错文案已经把原则写清楚了:我们自己在私信侧刚吃过反面的教训:投递层重试没有 ACK 终止条件,导致同一段 1501 字节的内容在 40 分钟内被逐字重复上链 5 次、对端四次投诉。所以本设计里:
SUBMITTED后的冷却期必须长于实测索引延迟,否则"还没被确认"会被误判成"发送失败"而重发,精确重演那个事故。冷却期要用实测值定成显式常量 + 断言,不做魔法数字。想确认的一件事
这个方向上游是否接受?接受的话我按上面的边界出 PR(含「单源 404 不算不存在」与「冷却期内禁止重发」两组回归用例);如果不接受、或你们已有别的计划,我就不占用 review 资源。