Skip to content

fix(xpi): 跨主体终态提示带上未送达消息的身份 - #1450

Open
LoftWhale wants to merge 2 commits into
deepcoldy:masterfrom
LoftWhale:fix/xpi-dropped-message-identity
Open

LoftWhale wants to merge 2 commits into
deepcoldy:masterfrom
LoftWhale:fix/xpi-dropped-message-identity

Conversation

@LoftWhale

@LoftWhale LoftWhale commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

问题

跨主体终态提示(xpi.timeout.unclassified零标识:只说「没有做出选择,消息未被执行」,不说是哪一条消息。

后果不是不方便,是受损方判不出自己是不是受损方

实测复现(同一群,时刻取自 botmux history,换算为 UTC)

00:50:58  提议方 A(bot)发出一条
00:51:01  提议方 B(另一个 bot)发出一条
00:51:02  提示到达,@ 的是 A

一秒之内两个不同的提议方各发过消息,而这张提示既不说是哪一条消息,也不说提议方
⇒ 「这张说的是 A 那条还是 B 那条」在收件人的视图里逐字同形,A 无法判定自己那条到底有没有送达,也就无法精确补发。

对人类提议方这是困扰;对机器人提议方这是无法自动恢复——只能全部重发(重复投递)或全部不发(静默丢失),没有第三种。

改法

  • 新增纯函数 crossPrincipalDroppedMessageDigest(record):从记录里取出 turnId 与首条正文的摘录(折叠空白、超长截断为 60 字符 + )。记录不含消息时返回 undefined,调用方原样返回原文。
  • notifyCrossPrincipalTerminal两处 xpi.timeout.unclassified 调用点追加这一行。
  • 摘录取自提议方自己刚刚在同一个会话里发过的正文,回显它不泄露任何新信息。

新增的一行(中/英):

未送达的消息:turn om_xxx,开头「……」
Not delivered: turn om_xxx, starting "…"

刻意没做的(声明,不是遗漏)

  • 其余 8 处 notifyCrossPrincipalTerminal( 调用点未改grep -n 共 13 行=1 处定义+12 个调用点;接 helper 的 4 处=xpi.timeout.unclassified ×2、xpi.timeout.still_busy ×2。初版此处写「11」是把定义行算成了调用点,已订正)。 其中一处(独立会话已创建;因共用工作目录,将在当前任务结束后串行执行。)是成功提示——把这段话无差别塞进 helper 会让成功提示也带上「未送达」字样。所以这一行加在调用点上,不加在 helper 里。
  • 不含条数。 CrossPrincipalInterruption.messages 在全仓只有一个写入点(建记录那一行;grep -rn 'messages.push' src/ 的 6 处命中无一属于它),记录 id 又是 sha256(sessionId, message.turnId) ⇒ 每条消息各自成记录,条数结构上恒为 1。印一个恒为 1 的数会读起来像信息,所以去掉了。
  • 不动隔离守卫本身,也不改重试次数。 那是另一个决定,不该搭在一次措辞修复里。

测试

test/cross-principal-interruption-store.test.ts 新增一个 describe(dropped cross-principal message digest),5 例;该文件合计 9 例(基线 ba847cae5d19 上这个文件只有 cross-principal interruption durable identity 一个 describe、4 例)。五例逐条:

  • names the message by the turn the proposer already knows
  • collapses newlines so the excerpt stays on one line of the notice
  • truncates only past the limit, so short messages are quoted whole —— 正好等于截断长度(60 字符)时加省略号,超过 1 个字符才加
  • yields an empty excerpt for a whitespace-only body, so the caller can drop the quote(阴性对照,f0f7ce6e 补)
  • returns undefined when the record carries no message, so the notice is left unchanged(阴性对照,提示原文不变)

计数口径:grep -cE '^[[:space:]]+it\(' 按 describe 分组,5 + 4 = 9;同一口径在下面那三个未改动的文件上给 12 + 17 + 15 = 44,与 vitest 报的 44 相等 ⇒ 这把尺子在同一个对象上跟运行器对过,不是只对自己自洽。

实测读数

环境:macOS arm64 / bun 1.4.0 / vitest 4.1.11 / node v26.3.0。被测提交 f0f7ce6e2a91,=本 PR 当前 head(读数与出门的那份是同一个对象,不是本地另一棵树)。读数时刻 2026-09-20T13:28:14Z ~ 13:30:07Z(北京 21:28 ~ 21:30)。

$ bun run build
build_rc=0

$ bun x vitest run --project unit test/cross-principal-interruption-store.test.ts
 Test Files  1 passed (1)
      Tests  9 passed (9)

$ bun x vitest run --project unit \
    test/cross-principal-interruption-store.test.ts \
    test/cross-principal-choice.test.ts \
    test/cross-principal-interruption-gate.test.ts \
    test/xpi-shared-cwd-admission.test.ts
 Test Files  4 passed (4)
      Tests  53 passed (53)

CI(GitHub Actions,head f0f7ce6e2a91)9 项全绿:buildtesttest (1/3)test (2/3)test (3/3)bun-testbun-binarybun-binary-muslbun-binary-darwin

这一节首版的数字为什么不只是「旧」

首版这一节写的是 3 passed (3) / 52 passed (52),命令行里列的是不含 cross-principal-interruption-store.test.ts 的三个文件。今天量出来:那三个文件在基线和 head 上都是 12 + 17 + 15 = 44 例,且两处逐字未变。44 ≠ 52,而 44 + 8 = 52,8 正是 636b3638(本 PR 首个提交)那时 store 文件的例数 ⇒ 那个 52 是一次四文件的读数,被写在了一条三文件的命令下面。所以不是「数字过时」,是印出来的命令产生不出它旁边那个数;现在四个文件是同一条命令里的四个参数,53 = 44 + 9。

⚠️ test/daemon-ordinary-ingress-failure-notice.test.ts 在本机报
TypeError: undefined is not an object (evaluating 'z.string')
这是改动前的基线同一读数——git stash 后在未改动的树上重跑,报错逐字相同 ⇒ 与本 PR 无关,未处理。

相关:#1449(同一条投递路径上的另一半,日志侧)。

更新(f0f7ce6e,回应 review)

  • 两处 xpi.timeout.still_busy 也接上 withDroppedMessageIdentity
  • 摘录为空或全空白时改用 xpi.terminal.dropped_turn_only未送达的消息:turn {turnId} / Not delivered: turn {turnId}),不再印出空的「开头「」」。
  • staged() 测试辅助简化为单条消息;新增全空白摘录一臂。本地 bun test test/cross-principal-interruption-store.test.ts 9/9,test/dashboard-i18n*.test.ts 全过。
  • ⚠️ 首版写「整仓 tsc --noEmit 未在本地跑」,这一格现在补上了:package.jsonbuild 脚本里串着 tscbun run typecheck:scripts,上面的 build_rc=0 就包含这两步(不是 --noEmit,是会 emit 的那次全量类型检查);typecheck:test-mocks 不在 build 链里,未单独跑。CI 的 build 项同为绿。

🤖 Generated with Claude Code

终态提示(`xpi.timeout.unclassified`)当前**零标识**:只说「没有做出选择,消息未被执行」,
不说是哪一条消息。后果不是不方便,是**受损方判不出自己是不是受损方**——
在忙会话期间发过多条消息的提议方,无法知道被丢的是哪一条,也就无法精确补发;
机器人提议方更是只能全部重发或全部不发。

实测复现(同一群、`botmux history` 时刻换算为 UTC):
- `00:50:58` 提议方 A 发出一条
- `00:51:01` 提议方 B(另一个 bot)发出一条
- `00:51:02` 提示到达,@ 的是 A

一秒之内两个不同的提议方各发过消息,而这张提示既不说消息、也不说提议方
⇒ 「这张说的是 A 那条还是 B 那条」在收件人视图里**逐字同形**。

改法:
- 新增纯函数 `crossPrincipalDroppedMessageDigest(record)`,从记录里取出
  `turnId` 与首条正文的摘录(折叠空白、超长截断)。记录不含消息时返回
  `undefined`,调用方原样返回原文,不改变任何既有措辞。
- `notifyCrossPrincipalTerminal` 的**两处** `xpi.timeout.unclassified` 调用点
  追加这一行。⚠️ 其余 11 处终态提示调用点**未改**——其中一处
  (「独立会话已创建;因共用工作目录,将在当前任务结束后串行执行。」)是
  **成功**提示,把这段话无差别塞进 helper 会让成功提示也带上「未送达」字样。
- 摘录取自提议方自己刚刚在同一个会话里发过的正文,回显它不泄露任何新信息。

未做(明确声明,不是遗漏):
- 不含条数。`CrossPrincipalInterruption.messages` 在全仓**只有一个写入点**
  (`cross-principal-interruption-store.ts` 建记录那一行,`grep -rn 'messages.push' src/`
  的 6 处命中无一属于它),记录 id 又是 `sha256(sessionId, message.turnId)`
  ⇒ 每条消息各自成记录,条数**结构上恒为 1**。印一个恒为 1 的数会读起来像信息。
- 不动隔离守卫本身,也不改重试次数——那是另一个决定,不该搭在一次措辞修复里。

测试:`test/cross-principal-interruption-store.test.ts` 新增 4 例,含两组阴性对照
(恰好等于截断长度时**不**加省略号;记录无消息时返回 `undefined`)。

实测读数:
- `bun run build` 通过。
- `bun x vitest run --project unit test/cross-principal-interruption-store.test.ts`
  → 8 passed。
- 同批相关文件 `cross-principal-choice` / `cross-principal-interruption-gate` /
  `xpi-shared-cwd-admission` 全绿(52 passed)。
- `test/daemon-ordinary-ingress-failure-notice.test.ts` 在本机报
  `TypeError: undefined is not an object (evaluating 'z.string')`,**改动前的基线
  同一读数**(`git stash` 后重跑逐字相同)⇒ 与本次改动无关,未处理。

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

Copy link
Copy Markdown
Owner

感谢补充消息身份,方向和切分(纯函数 + 在调用点包裹、不进 helper)都很干净。本地基于最新 master 复验编译与测试均通过,新增用例变异测试有效。有一处同族遗漏建议在本 PR 内一并处理,另附几个小建议。

建议修改:xpi.timeout.still_busy 的两处失败终态仍是零身份

与本 PR 修改的两处 xpi.timeout.unclassified 同形(同样是「消息未执行,可重新发送」、同样在会话内无消息锚点的 @ 提示),但没有带 digest:

  • src/daemon.ts:19023:bot 提议方等待 owner 的第二轮到期(waitDecisionRound > 0)。这条路径对 bot 可达——bot 选择「留给当前任务」后若 owner 任务一直忙、对 wait 提示保持沉默,最终就到这里;PR 描述里「bot 只能全部重发或全部不发」的问题在这条路径上原样存在,建议必改。
  • src/daemon.ts:19061:人类提议方等待卡超时,用的是同一个 i18n key,只补上面一处会读起来像遗漏。

两处各包一层本 PR 已有的 withDroppedMessageIdentity(...) 即可,复用 xpi.terminal.dropped,不需要新增文案或测试(纯函数已覆盖)。

其余终态提示语义不同,可另开 follow-up、不阻塞本 PR:共享目录队列满的 backpressure 提示(「建议已确认,但队列已满,请稍后重新发起」)、owner 明确拒绝的提示。

小建议

  1. 测试 helper staged(texts[]) 的多元素循环实际不可达:record id 按 (sessionId, turnId) 哈希,第二次调用会新建记录而非向同一记录追加消息,且 4 个用例都只传单元素,建议简化以免后来人误读 messages 会聚合。
  2. 纯空白 / 仅附件的正文会渲染成 开头「」(普通文本消息入口基本不可达,纯观感问题);excerpt 为空时只输出 turn 行即可。
  3. 「其余 11 处调用点未改」计数差一:notifyCrossPrincipalTerminal 的调用点共 12 处(grep 命中的另一行是函数定义本身),本 PR 改 2 处,其余为 10 处。不影响代码,但「刻意只改两处」的论证依赖这个数字,PR 描述与 commit message 建议顺手修正。

以上为自动评审的初步意见,最终以维护者审阅为准。

…s; turn-only fallback for empty excerpt

- wrap both xpi.timeout.still_busy call sites in withDroppedMessageIdentity
- use xpi.terminal.dropped_turn_only when the digest excerpt is empty/whitespace
- simplify staged() test helper; add whitespace-only arm

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LoftWhale

Copy link
Copy Markdown
Contributor Author

Thanks — all addressed in f0f7ce6:

  • still_busy (must-fix): both xpi.timeout.still_busy sites (waitDecisionRound > 0 bot path and the human wait-card timeout) now go through withDroppedMessageIdentity(...), reusing xpi.terminal.dropped.
  • Nit 1: staged() simplified to a single message; no more implied aggregation.
  • Nit 2: empty / whitespace-only excerpt now renders the turn line only via a new xpi.terminal.dropped_turn_only key (zh/en), with a whitespace-only test arm. bun test test/cross-principal-interruption-store.test.ts 9/9; test/dashboard-i18n*.test.ts pass.
  • Nit 3: count corrected in the description — 12 call sites (13 grep lines incl. the definition); with this commit 4 are wrapped, 8 remain. I left the earlier commit message as-is to avoid a force-push; happy to fix it in the squash message.

Backpressure / explicit-reject notices left for a follow-up as suggested.

Note: I did not run a full-repo tsc --noEmit locally this round (memory-constrained box); relying on CI for the typecheck.

🤖 Generated with Claude Code

@deepcoldy

Copy link
Copy Markdown
Owner

新 commit 已复审:上一轮意见均已落实,谢谢快速响应。

  • 两处 xpi.timeout.still_busy(bot 第二轮到期 + 人类等待卡超时)均已接上 withDroppedMessageIdentity
  • 空白/仅附件正文走新增的 xpi.terminal.dropped_turn_only,zh/en 两字典齐全,不再出现空的「开头「」」;
  • 测试 helper 的死循环已移除,并补了纯空白正文用例(本地变异验证有效);
  • 调用点计数也已在描述中订正(1 定义 + 12 调用,已接 4,余 8)。

本地基于最新 master rebase 零冲突,类型检查与 XPI 相关测试全部通过。

一个不阻塞的小建议:PR 描述「测试 / 实测读数」一节还是首版的数字(新增 4 例 / 8 passed / 52 passed),当前实际为新 describe 5 例(文件内 9 例)、四个 XPI 文件合计 53 passed,合并前顺手刷新即可。

以上为自动评审的初步意见,最终以维护者审阅为准。

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