Skip to content

test(desktop): allow CRLF in voice recording gate assertion - #1440

Merged
MagicLizi merged 1 commit into
makecindy:mainfrom
xxxxxccc:fix/voice-input-recording-gate-crlf
Aug 3, 2026
Merged

test(desktop): allow CRLF in voice recording gate assertion#1440
MagicLizi merged 1 commit into
makecindy:mainfrom
xxxxxccc:fix/voice-input-recording-gate-crlf

Conversation

@xxxxxccc

@xxxxxccc xxxxxccc commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

这次改了什么

摘要

修复 Windows CI 上 VoiceInputSection.recordingGate.test.ts 的 source-contract 正则只接受 LF、拒绝 CRLF 的问题。Windows checkout 读取 renderer 源文件时使用 CRLF,导致该测试在 main 的 Windows 2/2 分片失败。

变更类型

  • feat 新功能
  • fix 缺陷修复
  • refactor / perf 重构或性能优化
  • docs / test / chore 文档、测试或工程维护
  • 其他:

范围

UI 变化

  • 引用的设计规范:不涉及:仅修改 renderer source-contract 测试断言,不改变 UI 或交互。

怎么验证的

自动验证

git diff --check
结果:通过。

手工验证

不涉及。GitHub Actions Windows unit-test 分片将验证 CRLF checkout 场景。

未执行的验证

未运行本地 unit test,按任务约束执行;依赖 CI 的 Windows 分片验证。

风险

风险分类

  • 跨平台差异
  • 无已知风险
  • SQLite / migration
  • system prompt
  • 协议兼容
  • 权限 / 安全 / 用户数据
  • 存量插件兼容(批准状态 / 指纹 / manifest 校验 / 安装布局 / 包格式)
  • 原生层 / fingerprint / OTA
  • 其他:

影响与回滚

  • 影响范围:仅影响测试断言的换行符匹配;不改变运行时代码。
  • 回滚 / 降级方式:回滚本 PR 即可恢复原断言。

提交前检查

  • 已 review 完整 diff
  • 每个 commit 都带 DCO 签名(git commit -s
  • UI 改动已在「UI 变化」注明引用的设计规范章节(不涉及 UI)
  • 未提交凭证、令牌或授权文件
  • 已补充必要文档
  • 已确认测试结果或说明未执行原因

Signed-off-by: xxxcc <xxxcc123@126.com>
Copilot AI review requested due to automatic review settings August 3, 2026 04:55
@xxxxxccc
xxxxxccc requested a review from a team as a code owner August 3, 2026 04:55
@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown

Greptile Summary

本 PR 仅修复桌面端语音输入 source-contract 测试对 Windows 换行符的兼容性。

  • startFnKeyCapture 空依赖数组断言中的换行匹配由仅 LF 扩展为 LF 或 CRLF。
  • 同步调整错误依赖数组的否定断言。
  • 不修改产品逻辑、UI 或运行时行为。

Confidence Score: 5/5

本 PR 看起来可以安全合并,修改范围仅限测试中的 CRLF 兼容处理。

修改一致地放宽了两条换行符断言,使 Windows CRLF 源文件能够通过测试,同时不改变断言所验证的回调依赖契约。

Important Files Changed

Filename Overview
apps/desktop/src/renderer/voice-input/tests/VoiceInputSection.recordingGate.test.ts 两条 source-contract 正则均使用 \r?\n 接受 LF 和 CRLF,同时保留原有依赖数组约束,未发现功能性问题。

Reviews (1): Last reviewed commit: "test(desktop): allow CRLF in voice recor..." | Re-trigger Greptile

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

该 PR 修复了 Desktop Renderer 侧的 source-contract 单测在 Windows(CRLF checkout)环境下的误报:原断言正则只接受 LF,导致 Windows CI 读取 VoiceInputSection.tsx 源码时换行符不匹配而失败。

Changes:

  • startFnKeyCapture 相关的两条正则断言从 \n\n 调整为同时接受 LF/CRLF(使用 \r?\n 组合)。
  • 保持测试意图不变:仍然断言 callback 依赖数组必须为空,避免录制中途因 identity 变化导致 effect 重跑。

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@MagicLizi MagicLizi added awaiting-discussion 等待维护者讨论(review-pr) touches:product-ui 改动碰到产品 / UI 面(review-pr 自动维护,仅展示) labels Aug 3, 2026
@MagicLizi

Copy link
Copy Markdown
Contributor

⏸️ 维护者确认门

本 PR 命中 product 维护者确认(修改语音录制门控测试文件)。

等待任一维护者在本 PR 上 Approve 后自动放行。如需修改请 Request Changes,作者改完后重新 Approve 即可。

讨论 issue:#1444

@MagicLizi MagicLizi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以推进

@MagicLizi
MagicLizi merged commit 2ce6bcd into makecindy:main Aug 3, 2026
8 checks passed
zqchris added a commit that referenced this pull request Aug 3, 2026
Codex P2:pi edit 的 diff 只按 edits[] 解析,真实事件会拿到空 diff 与 +0 -0。
核对 pi v0.83.0 的 core/tools/edit.ts 后确认它有**两种**入参形态:声明 schema
是 { path, edits: [{oldText,newText}] },同时 LegacyEditToolInput 仍接受顶层
{ path, oldText, newText } 并由 normalizeEditInput 归一化。只认任一种都会让
另一种退化成空 diff,因此改为两种都认。

- maker-shared 新增共享归一化器 piEditReplacements:先取 edits[],再把顶层
  oldText/newText(两侧都是字符串才认)作为最后一段追加,顺序与 pi 的
  normalizeEditInput 一致;单段内只有一侧是字符串时另一侧按空串,纯增/纯删
  不会被丢掉。
- AgentActionRow(diff lightbox)、diffStats(行内 +N -N)、payloadSummary
  (mobile payload)三处消费端改为共用它,消除各自解析导致的漂移。
- 测试补齐真实形态:两种入参各自的 diff/统计/渲染断言,含顶层形态必须给出
  非空 diff(断死不出现 "diffs":[])与真实 +1 -2。

Copilot:补 FILE_PATH_TOOLS 注释说明它不是「所有 kind='file' 描述符」的集合
—— pi 的 ls 归一化成 kind='file' 并渲染文件 chip,但刻意不入列(目标是目录,
开 lightbox 无意义),点击仍走命令类就地展开;新增工具按「点击后该看到什么」
判断是否入列。

同时合入最新 upstream/main(带上 #1440 的 VoiceInputSection CRLF 断言修复),
本 PR 的 Windows CI 红是该基线问题所致(issue #1448),非本 PR 引入。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
yan-xdt pushed a commit to yan-xdt/cindy that referenced this pull request Aug 3, 2026
* fix(pi): 工具调用展示按 harness 归一化,不再只显示裸工具名

PI 内置工具名全小写(bash/read/edit/write/grep/find/ls)、文件参数字段为
path,而展示层的 describeToolUse 只认 Claude Code 大写名与 Codex 名,PI
的调用一律落进 generic 分支 —— 行级动词兜底成「调用」,参数位只剩裸工具
名,命令原文、文件名、搜索模式全部看不见。

把 PI 内置工具接进同一条展示产线:

- describeToolUse 补小写分支(bash→command 走 intent 解析,read/ls→file
  read,edit/write→file edit/create,grep/find→search);不用无脑
  toLowerCase 统一,因为 CC 的 Bash 带模型写的 description 而 PI 无此
  字段,展示策略不同。
- 行级动词映射补小写条目,「调用」变回「运行 / 读取 / 编辑 / 搜索」。
- diff 统计与 diff lightbox 认 PI 的 edits[].oldText/newText 与
  write.content,PI 编辑行恢复 +N -N 与 diff 预览。
- 展开详情、文件 chip 与 Read lightbox、最近一轮改动路径、mobile payload
  diff 同步认小写名与 path 字段。

灵动岛(agent-island/toolDetail)与 mobile 消息投影本就由 describeToolUse
驱动,随本次修复一并受益。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — pi edit 两种入参形态归一化,澄清 FILE_PATH_TOOLS 边界

Codex P2:pi edit 的 diff 只按 edits[] 解析,真实事件会拿到空 diff 与 +0 -0。
核对 pi v0.83.0 的 core/tools/edit.ts 后确认它有**两种**入参形态:声明 schema
是 { path, edits: [{oldText,newText}] },同时 LegacyEditToolInput 仍接受顶层
{ path, oldText, newText } 并由 normalizeEditInput 归一化。只认任一种都会让
另一种退化成空 diff,因此改为两种都认。

- maker-shared 新增共享归一化器 piEditReplacements:先取 edits[],再把顶层
  oldText/newText(两侧都是字符串才认)作为最后一段追加,顺序与 pi 的
  normalizeEditInput 一致;单段内只有一侧是字符串时另一侧按空串,纯增/纯删
  不会被丢掉。
- AgentActionRow(diff lightbox)、diffStats(行内 +N -N)、payloadSummary
  (mobile payload)三处消费端改为共用它,消除各自解析导致的漂移。
- 测试补齐真实形态:两种入参各自的 diff/统计/渲染断言,含顶层形态必须给出
  非空 diff(断死不出现 "diffs":[])与真实 +1 -2。

Copilot:补 FILE_PATH_TOOLS 注释说明它不是「所有 kind='file' 描述符」的集合
—— pi 的 ls 归一化成 kind='file' 并渲染文件 chip,但刻意不入列(目标是目录,
开 lightbox 无意义),点击仍走命令类就地展开;新增工具按「点击后该看到什么」
判断是否入列。

同时合入最新 upstream/main(带上 makecindy#1440 的 VoiceInputSection CRLF 断言修复),
本 PR 的 Windows CI 红是该基线问题所致(issue makecindy#1448),非本 PR 引入。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

---------

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
Co-authored-by: Chris <4436110+zqchris@users.noreply.github.com>
zqchris added a commit to zqchris/cindy that referenced this pull request Aug 3, 2026
- 多 receiver 聚合(subagent-live-cards.ts):一次 V1 `spawnAgent` 返回多个
  receiverThreadIds 时,此前为各子线程建独立计数器却用同一 taskId 发更新 →
  后到的快照把先到的覆盖成更小值(token/toolUses 回退),任一 sibling 先收口
  还会把整张卡误报完成。改为聚合状态挂在 taskId 上、按 thread 存分量:
  * token 是各线程的**累计快照**,按线程覆盖后求和(不能相加历史值,否则重复计);
  * toolUses 用卡级 item id 去重(item id 跨线程唯一);
  * 状态取聚合:任一线程 running → running;全终态后 failed > stopped > completed,
    失败不被 sibling 的 completed/stopped 掩盖。
- 乱序通知重放(tracker):子线程 thread/started 已建立 lineage、但父线程 spawn item
  尚未登记时,通知此前被直接丢弃 → 首个工具调用 / 初始 token / 甚至终态永久缺失。
  改为对未登记子线程缓冲(只缓冲会消费的 method,线程数与每线程条数双封顶),
  在 noteSpawnItem 登记后重放并回传一帧聚合快照。
  index.ts 把重放帧安排在 translateItemNotification **之后**发:translator 对 V2
  spawn 会推一帧 status=running,先发会被它把已重放出的终态盖回去。
- 测试 +6 例:多 receiver 用量累加与 token 覆盖语义、sibling 先收口不提前完成、
  failed 跨到达顺序仍胜出、早到通知重放(含早到终态)、无可重放时返回 null、
  不缓冲不消费的 method。

CI:Windows 红项是 `VoiceInputSection.recordingGate.test.ts` 的语音用例,断言对组件
源码做行尾敏感的正则匹配(`\n {2}\}, \[\]\);\n\n`),Windows CRLF checkout 必红 ——
与本 PR 无关,上游 makecindy#1440(2ce6bcd)已修。本次合并最新 upstream/main 带入该修复。

Signed-off-by: Chris <zqchris@msn.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
zqchris added a commit that referenced this pull request Aug 3, 2026
review P1(安全,Codex + Greptile 各报一次):原实现把同目录资源的**预签名地址**回填进
页面,而渲染态保留 JavaScript —— 内联脚本能读 `img.src` 再以 no-cors 外发,第三方即可在
有效期内下载该被控端文件;`<img src=".env">` 这类引用会把同目录任意文件变成可外传的
bearer URL。提示注入过的 agent 产物足以触发。两层修:

1. 资源不再以签名地址进页面:app 侧下载后转成 `data:` URI 再回填
   (downloadRemoteMediaAsDataUri),页面里**不出现任何 bearer 凭证**;单资源 2 MiB 上限,
   超限保留原引用。MIME 按扩展名白名单给准,表外类型不改写(给错会让样式表/脚本被
   浏览器拒收、静默失效)。音视频刻意不在表内 —— 整份内联会撑爆内存。
2. 新增 htmlPreviewCsp:注入 `default-src 'none'` + 只放行 `data:` 资源 +
   `connect-src 'none'` 的 CSP,把页面网络出口交给渲染引擎强制封锁,"读到了也送不出去"
   不再靠我们约定。CSP 挂在渲染载体(HtmlFileReader)上,任何进 WebView 的 HTML 都带策略。
   代价已在 PR 写明:公网 https 图片/字体在预览里不再加载 —— 放行 img-src https: 等于留
   `new Image().src='…?d=…'` 这条经典外传通道,会让整条封锁形同虚设。

review P2(SSH):资源取件的 `xdt-file://` 缺 sessionId/remoteHostId/workdir,被控端
parseSshMediaOrigin 会把远端 absPath 当本机路径交给 realpath —— SSH 会话下所有同目录资源
取件失败,被控桌面恰有同名路径时还会读到错误来源。remoteFileMediaUrl 与
fetchRemoteAbsFileToUrl 补可选 SSH 上下文(三项必须齐备,与被控端完整性校验同口径),
并**进取件缓存键**:同一路径在不同远端主机上是不同文件。

CI 红为既有基线问题,不在本 PR 修:Windows unit tests 失败用例是
src/renderer/voice-input/__tests__/VoiceInputSection.recordingGate.test.ts,已由 issue

Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
#1448 记录「在 main 上稳定失败(疑与 CRLF 检出有关)」,另有修复 PR #1440 + 维护者确认
#1444。本 PR 只改 apps/mobile,不可能影响桌面 renderer 的该用例;本地全绿。
yan-xdt added a commit to yan-xdt/cindy that referenced this pull request Aug 3, 2026
撤销 a23ef3f(为解锁 CI 误判的 recordingGate 兼容修复)后,该文件回到旧 LF 版,
但上游已由 makecindy#1440 合入 CRLF 修复(2ce6bcd)。同步到上游版本,PR 内该文件零 diff。

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: yan <yan233@xd.com>
GaoWeiLiuXD pushed a commit to GaoWeiLiuXD/cindy that referenced this pull request Aug 3, 2026
* feat(codex): 子代理卡补齐实时状态,与 Claude 子代理卡形态统一

Codex 子代理此前在 UI 上只有一张即时收口的「已启动」卡:没有运行中状态、
没有 tokens / 工具调用数 / 耗时,而 Claude 子代理卡靠 SDK task_progress
全程可见。差距的根因不在 UI —— 两者本就共用 AgentTaskCard —— 而在 Codex
侧缺数据源:子线程的 item / tokenUsage / turn 通知其实已经到达 Cindy 进程,
被 AppServerHost 的路由丢掉了。

- host: routeNotification 按 lineage 认出已知子线程,投到新增的
  descendantNotification 通道。刻意不复用主线程 dispatch —— 主线程 handler
  带 turn 级簿记(stale 判定、currentTurnId、status 推送、usageTracker 记账),
  子线程事件灌进去会把子代理的 exec 渲染成主会话自己的工具调用并污染主 turn
  用量。thread/started 仍只走专用的 descendantThreadStarted,不双通道下发;
  血缘未知的线程保持原 TTL 缓冲语义(解 subscribe 竞争)。
- subagent-live-cards: 新增纯聚合模块(Map 查 + 计数,零 IO,热路径友好),
  把子线程通知汇成 tokens / 工具调用数 / 耗时 + 状态。跟踪条目有界(默认 64,
  优先淘汰已收口条目);工具类 item 按 id 去重,只发 completed 的 item 也计入。
- translator: spawn 卡改为发 agent_task_update(running),tool_result 仍就地
  收口不留悬空工具调用;抽出 readCodexSubagentSpawnRegistration 供 index 复用。
- V1/V2 双轨通用:V2(Sol/Terra)的 subAgentActivity 按 agentThreadId 登记,
  V1(老模型与自定义接入模型)的 collabAgentToolCall spawn 按 receiverThreadIds
  登记,同一条聚合链路服务两轨。
- 卡片文案:有 live update 时不再显示「Subagent X 已启动」——title 与运行状态
  已表达同样信息,留着会让 codex 卡比 Claude 卡多一行冗余文案。判据下沉
  maker-shared 的 buildAgentTaskCardModel,桌面与手机端共用;历史回放(无 live
  update)仍保留该回执,否则只剩一条裸 agentPath。

不改 system prompt、不改注入配置、不新增 UI 组件。

Signed-off-by: Chris <zqchris@msn.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — 多 receiver 共享聚合 + 乱序通知重放

- 多 receiver 聚合(subagent-live-cards.ts):一次 V1 `spawnAgent` 返回多个
  receiverThreadIds 时,此前为各子线程建独立计数器却用同一 taskId 发更新 →
  后到的快照把先到的覆盖成更小值(token/toolUses 回退),任一 sibling 先收口
  还会把整张卡误报完成。改为聚合状态挂在 taskId 上、按 thread 存分量:
  * token 是各线程的**累计快照**,按线程覆盖后求和(不能相加历史值,否则重复计);
  * toolUses 用卡级 item id 去重(item id 跨线程唯一);
  * 状态取聚合:任一线程 running → running;全终态后 failed > stopped > completed,
    失败不被 sibling 的 completed/stopped 掩盖。
- 乱序通知重放(tracker):子线程 thread/started 已建立 lineage、但父线程 spawn item
  尚未登记时,通知此前被直接丢弃 → 首个工具调用 / 初始 token / 甚至终态永久缺失。
  改为对未登记子线程缓冲(只缓冲会消费的 method,线程数与每线程条数双封顶),
  在 noteSpawnItem 登记后重放并回传一帧聚合快照。
  index.ts 把重放帧安排在 translateItemNotification **之后**发:translator 对 V2
  spawn 会推一帧 status=running,先发会被它把已重放出的终态盖回去。
- 测试 +6 例:多 receiver 用量累加与 token 覆盖语义、sibling 先收口不提前完成、
  failed 跨到达顺序仍胜出、早到通知重放(含早到终态)、无可重放时返回 null、
  不缓冲不消费的 method。

CI:Windows 红项是 `VoiceInputSection.recordingGate.test.ts` 的语音用例,断言对组件
源码做行尾敏感的正则匹配(`\n {2}\}, \[\]\);\n\n`),Windows CRLF checkout 必红 ——
与本 PR 无关,上游 makecindy#1440(2ce6bcd)已修。本次合并最新 upstream/main 带入该修复。

Signed-off-by: Chris <zqchris@msn.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — 嵌套子代理按血缘归属,订阅前缓冲的子线程通知按序补投

1. 嵌套子代理归属(greptile P1):孙线程的 spawn item 只出现在**子线程自己**的事件流里,
   主线程的 itemStarted 钩子永远看不到 → noteSpawnItem 不可能登记孙线程,其通知全部落进
   pending 且再无登记路径可重放,工具调用/token/终态一概不计入卡片。
   改为利用 host 已有的 `descendantThreadStarted`(对**每一代**都触发并给出 parentThreadId):
   新增 `noteDescendantThread(child, parent)`,父线程已归属某张卡时把子线程并入同一张卡并
   重放其早到缓冲;父线程与子代理无关时无副作用。任意深度自然成立(子→孙→曾孙)。

2. 订阅前缓冲重放(codex P2):`thread/started` 与该子线程的 item/usage/turn 都早于 root 的
   subscribeThread 到达时,它们缓存在**child id** 下;root 侧 drain 只排空 root id 的队列,
   `replayBufferedDescendantThreadStarts` 又只重建血缘 → 早期工具数与 token 永久丢失,
   漏掉 turn/completed 还会让卡片一直停在 running。
   `routeDescendantThreadStarted` 在血缘建立后调用新增的
   `drainBufferedDescendantNotifications`,按到达顺序把该 child 的非 start 通知补投进
   descendant 通道(先删再投,避免后续血缘重建重复投递)。
   同时把 `replayBufferedDescendantThreadStarts` 改为**先快照候选再处理** —— 补投会从
   this.buffered 删除条目,边迭代边删同一个 Map 容易漏项。

测试 +4 例:
- 聚合器:嵌套(子→孙→曾孙)工具数累加与 token 按线程求和、全部代际终态后才收口且 failed
  胜出;孙线程通知先到后经血缘重放补回(含早到终态,卡片不再停在 running);父线程与子代理
  无关时零副作用。
- host:订阅前到达的 child 三条业务通知(item/usage/turn)与 grandchild 通知按原顺序补投、
  thread/started 不进该通道、主线程 handler 零调用、同一批不被重复补投。

注:本轮首次全量单测遇到 vitest worker SIGSEGV(被 reporter 归类为 TEST_ASSERTION_FAILED),
重跑 EXIT=0 全绿且无 SIGSEGV —— 属工具链偶发崩溃,与本改动无关。

Signed-off-by: Chris <zqchris@msn.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — spawn 自身失败时不再被聚合快照盖回 running

`readCodexSubagentSpawnRegistration` 新增 `failed` 标记(V1 `collabAgentToolCall.status
=== 'failed'`;V2 的 subAgentActivity 无 status,恒 undefined)。聚合器据此:

- spawn 本身收口为失败时**不补发**聚合快照 —— translator 已推过 failed 帧,而那时子线程
  还标着 running,补一帧就会把真实失败态盖回运行中(review);
- 同时把该 spawn 的线程直接标成 failed,后续迟到的子线程通知也不会把卡片翻回 running;
- 成功收口仍照旧重发聚合快照(否则会被 translator 合成的 completed 盖掉,上一轮已修)。

测试 +2 例:失败收口不发帧且迟到通知仍为 failed;成功收口仍重新声明真实聚合(running)。

CI 红项(Windows unit tests (1/2))经核对为**基线抖动,非本分支引入**,证据:
- 失败用例是 `packages/lizi-mcps/src/__tests__/contactsTools.test.ts`「系统回写: 锚点每批
  立即回填…」,报错是 `Test timed out in 5000ms`(超时,非断言);
- 本分支对 `packages/lizi-mcps` **零改动**(`git diff upstream/main...HEAD -- packages/lizi-mcps`
  为空),该测试文件最后一次改动是 7-24 的首次开源提交;
- 本地全量 `pnpm test:unit` EXIT=0 / 0 FAIL,该套件本地通过;
- 同一时段 upstream/main 自身的 client-ci 也是 failure,但红在**另一个**用例
  (`device-link/__tests__/dispatchWeakNetwork.test.ts`,来自刚合并的 makecindy#1418)—— 说明当时
  Windows runner 整体不稳。

规模自审(漂移门禁 [2] WARN +70%):以 merge-base 计,本 PR 共 11 文件 / +1391 −32,其中
**743 行是测试**(53%)。源码增长全部落在同一机制上:聚合器新文件(388)、host 血缘路由与
缓冲重放(+122)、index 接线(+73)、translator 登记判据(+81),无新增无关功能面。本轮
unresolved 归零、不再加功能面,属收尾阶段。

Signed-off-by: Chris <zqchris@msn.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — 缓存未归属血缘并递归补绑,失败 spawn 上终态闩

两条 P1 都是乱序通知下的归属/终态问题,根因同一处:归属与重放逻辑分散在两个 note*
入口各写一遍,谁也没覆盖"父线程还没归属"这个中间态。这次抽出单一递归补绑路径统一。

**1. 未归属血缘不再丢弃(subagent-live-cards.ts:336)**

子线程的 thread/started 可能早于根线程的 spawn item 到达,而它在归属前就可能已派出孙
线程 —— 那条「孙 → 子」血缘此刻无从判断归属,原实现直接 return null 丢掉。后果:之后
noteSpawnItem 只绑直接子线程,孙线程已缓冲的工具/token/终态再无重放机会,卡片漏计,还
可能在孙线程仍在跑时提前显示完成。而这条边是孙线程**唯一**的入卡途径 —— 孙线程的 spawn
item 只出现在子线程自己的事件流里,主线程的 itemStarted 钩子永远看不到。

改为缓冲 `父 → 子` 边(pendingLineage,父线程数 × 每父子线程数双封顶),父线程一归属就
递归补绑整条链并重放各代的缓冲通知。递归带 visited 防环——血缘理论上是树,但通知来自
外部进程,不能假定。

顺带修正一个发帧条件:新线程并入会改变聚合状态(已显示完成的卡因孙线程仍在跑必须回到
running),所以不能只在"有内容被重放"时发帧,还要看聚合状态是否因此改变。

**2. 失败 spawn 的终态闩(subagent-live-cards.ts:247)**

上一轮我只把「当下已知的线程」标成 failed,这挡不住:applyNotification 的 turn/started
会无条件写回 running、turn/completed 会写 completed,迟到的 turn 生命周期通知照样能把卡
片从失败翻回运行中/已完成,覆盖 translator 的失败帧。这是我自己引入的洞,bot 指出得对。

改为卡上的 spawnFailed 闩,aggregateStatus 里优先于一切子线程状态返回 failed;失败时若卡
尚未建立也建卡上闩,这样失败先到、子线程后到的乱序同样锁得住,新并入的线程直接算 failed。
闩上之后仍继续吸收 token / 工具计数 —— 派发失败但子线程已烧掉的量该算进去,只是状态恒为
failed。

**验证**

- 新增 7 例回归测试(乱序血缘、多代链递归补绑、环、失败先到/后到、闩上仍计量、
  已完成卡因迟到后代回到 running、缓冲有界)。
- 做了变异验证:把终态闩与血缘缓冲两行分别停掉,新增用例中 5 例立刻失败、其余 27 例照旧
  通过 —— 确认这些测试真的守着这两个机制,不是写了个恒真断言。
- packages/maker-core codex 全套 507 passed;pnpm test:unit EXIT=0 / 0 FAIL;
  maker-shared 与 desktop typecheck 通过,maker-core 无 typecheck script。

顺带合入 upstream/main:其中 eae5a49 修掉了此前把本 PR 三个 check 卡红的 device-link
outbox TTL 断言(makecindy#1477makecindy#1418 同日合入撞出的语义冲突,在 main 上稳定失败,已关 makecindy#1501)。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — 血缘重扫、去重登记留到淘汰、失败 spawn 补发已重放用量

三条都是子代理卡在**乱序/迟到通知**下丢数据,按根因分两处修。

**1. 逆序孙线程的血缘不再烂在缓冲区(host.ts)**

root 已订阅、而孙线程的 thread/started 先于父线程到达时,孙的血缘无从判断 → 连同它的业务
通知一起缓存在孙自己的 id 下。父线程随后建立血缘时,原实现只排空父线程那一条队列(而且按
契约跳过 thread/started),不再扫待解析的后代血缘 —— 孙线程的 tool / token / 终态通知会一直
留到过期,卡片漏计并可能持续显示运行中或提前完成。

改为在 routeDescendantThreadStarted 末尾复用 root 订阅时那套迭代重建。两者会互相调用,加
replayingDescendantLineage 重入闸:嵌套调用直接返回,由最外层那次的 for(;;) 跑完 —— 否则深
血缘下退化成 O(深度²) 的重复扫描。

**2. 工具 item 去重登记留到卡片淘汰(subagent-live-cards.ts)**

app-server 允许 turn/completed 先发、后台收尾的 item/completed 随后才到(codex/index.ts 的
终态墓碑注释写明了这个顺序)。原来 snapshot() 一收口就清 countedItemIds,那条迟到的
completed 会被当成新工具再加一次 → 卡片最终工具数虚高。

改为跟卡同生命周期(dropCard 时随卡释放),只按条数封顶(4096,按插入序淘汰最老的)防长跑
子代理无界增长 —— 被淘汰的 id 只在"几千个工具调用之前那条的 completed 现在才到"时才会重复
计数,现实中不发生。

**3. 失败 spawn 补发已重放的用量(subagent-live-cards.ts)**

V1 spawn 的 started phase 缺失/晚到时,子线程的工具与 token 先进缓冲、由 attachThread 重放
出来;若此后再无通知,原来无条件 return null 就意味着这些用量永远不显示 —— 而 translator 的
failed 帧本身不带 usage。上一轮加的 spawnFailed 闩让 snapshot() 恒为 failed,现在补发这一帧
不会重现"把失败盖回运行中"的老问题。没有可重放内容时仍然不发帧。

**验证**

- 新增 5 例回归测试:host 侧「已订阅 + 孙先到」与三代逆序链各一例,tracker 侧收口后迟到
  completed 不重复计数、失败 spawn 有/无重放内容两条路径。
- 变异验证:把三处修复分别停掉(血缘重扫、终态清 id、失败分支 return null),对应 4 例立刻
  失败、其余 43 例照旧通过 —— 确认这些测试真的守着机制,不是恒真断言。
- codex 全套 512 passed;pnpm test:unit EXIT=0 / 0 FAIL;maker-shared 与 desktop typecheck
  通过,maker-core 无 typecheck script(该包存量类型错误在 claude-code 测试文件里,与本 PR
  无关;已单独确认 agents/pi 与 agents/codex 下无新增错误)。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — 对真 codex 二进制核对协议契约,补计 sleep 工具 item

review 指出的根因成立:这条热路径重新路由后代线程通知,而此前**全部**证据都是我手工构造的
事件 —— 手构造的问题是"我没想到的形状我也构造不出来",漏计类缺陷天然测不到。

用真 codex 0.145.0 二进制做了实测核对(方法与完整结论写进 PR 说明),直接查出一个真缺陷:

**TOOL_ITEM_TYPES 漏了 `sleep`。** `codex app-server generate-json-schema` 导出的 ThreadItem
共 18 个变体,`sleep` 的 schema 描述是 "Display item emitted by the interruptible `clock.sleep`
tool" —— 它是工具调用,漏掉会少计一次。已补,并加了一例把 9 个工具类型逐一计数、9 个非工具
类型逐一断言不计数的回归测试(此前只覆盖 commandExecution 一种)。

同时核对通过、说明既有假设站得住的部分(细节见 PR):
- `Thread.parentThreadId` 的 schema 描述:"only be set if this thread is a subagent" ——
  正是 host 侧血缘路由的判据本身;root 线程实测 parentThreadId=null。
- 四个枚举与代码分支逐一吻合:TurnStatus(completed/interrupted/failed/inProgress)、
  SubAgentActivityKind(started/interacted/interrupted)、CollabAgentTool(spawnAgent 等 5 个)、
  CollabAgentToolCallStatus(inProgress/completed/failed)。
- `collabAgentToolCall.receiverThreadIds` 的描述确认"一次 spawn 对应新建 agent"的多 receiver
  语义 —— 即卡片按 taskId 聚合、多线程分量累计的前提。
- 真实 turn 的通知顺序实测:turn/started → item/* → **thread/tokenUsage/updated** →
  turn/completed。token 先于终态到达,这正是聚合快照不会带着过期 token 收口的前提。
- V1/V2 仲裁实测:0.145 下 `[features].collab` 已废弃(提示改用 `multi_agent`);自定义
  provider 暴露的是 `multi_agent_v1`(子工具 spawn_agent / send_input / wait_agent /
  resume_agent / close_agent),与 V2 只在特定 OpenAI 模型上启用的判断一致。

**未能实测的部分,如实记录**:没能真正 spawn 出一个子线程来端到端验证血缘形状与累计 usage
聚合 —— 需要让假 provider 按 responses API 的 SSE 分片格式伪造一次 spawn_agent 工具调用,
codex 不接受我构造的 output 形状,再往下是协议逆向工程,超出本 PR 应付的成本。PR 说明里写明
了这一面未验证及可执行的验证步骤。

验证:codex 全套 525 passed;pnpm test:unit EXIT=0 / 0 FAIL;maker-shared typecheck 通过;
desktop typecheck 除基线 makecindy#1543 的 7 条外无本分支错误。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — item/updated 也登记 spawn 映射;真 spawn 实测推进但未打通

**1. item/updated 补上 spawn 映射登记(真缺陷,已修)**

V1 的 spawn 是长跑 item(started → updated* → completed)。原来只在 started 与 completed 两帧
调 noteSubagentSpawnItem —— started 那帧若没到我们手里(turn 缓冲、stale turn 丢弃、上游省略),
映射要一直等到 completed 才建立。期间子线程的 item / token / turn 终态全被缓冲,卡片在整个运行
期(可能好几分钟)没有实时数据,最后才一次性补上 —— 那恰好是本 PR 要解决的问题本身(review)。

改为三个生命周期阶段顺序一致:先登记 → 翻译 → 后发重放帧(重放帧必须后发,否则会被 translator
刚推的 running 帧盖回去)。

回归测试用 index.test.ts 既有的 fake host 驱动**真实 handler**(不是结构断言):只发 updated、
刻意跳过 started,断言子线程随后的 item/token 当场路由成卡片更新(toolUses=1、totalTokens=4242、
status=running),而不是被缓冲。变异验证:去掉这次登记,该例立刻失败(usage 为 undefined)。

**2. 真实子线程 spawn 实测:推进明显,但仍未打通(如实记录)**

上一轮卡在"codex 不接受我构造的 assistant output"。这轮定位到根因并解决了那一层:responses API
必须走 `response.output_item.added` / `.function_call_arguments.delta` / `.done` /
`response.output_item.done` 分片,**只把 output 塞进 `response.completed` 会被静默忽略**。改对之后
codex 确实解析并路由了工具调用 —— 证据是 router 开始报明确错误而不是无反应。

新的卡点是 codex 对 **namespace 工具**的分派名:`multi_agent_v1` 是 `type: "namespace"`,内含
`spawn_agent` 等 5 个子工具。逐个试过 `spawn_agent` / `multi_agent_v1.spawn_agent` /
`multi_agent_v1__spawn_agent` / `_` / `-` / `:` 六种写法,全部被 router 判为
`unsupported call` —— 分派约定不在导出的 JSON Schema 里,继续试属于对闭源二进制做协议逆向。

因此「真实 spawn 出子线程」这一面**仍未验证**,PR 说明里写明了缺口与两条可执行的补验路径。
本线程留给放行人裁决是否以它为合并前置,我不自行 resolve。

验证:codex 全套 529 passed;pnpm test:unit EXIT=0 / 0 FAIL;maker-shared typecheck 通过;
desktop typecheck **0 error**(上游已修 makecindy#1543,基线红消失)。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — 聚合器也消费 item/updated(与主线程 spawn 路径同因)

上一轮修的是**主线程 spawn item** 的 updated 登记;这条是同一个因在**子线程工具 item** 上的镜像:
聚合器的 applyNotification 只认 item/started 与 item/completed,CONSUMED_METHODS 也不缓冲
updated。于是长跑工具若首个可见阶段是 updated,在 completed 到达前不计入实时工具数;会话在
completed 前中断则**永久漏计**。

两处一起补:applyNotification 的 item 分支加 'item/updated',CONSUMED_METHODS 加同一项(登记前
到达时要能进缓冲、登记后重放)。去重仍靠 countedItemIds 的 item id,同一 item 的
started / updated / completed 只会计一次 —— 这也是加 updated 不会引入重复计数的原因。

既然上一轮已经接受"started 可能缺失"这个前提并据此修了主线程路径,同一前提在子线程侧也成立,
不该只修一半。

回归测试 2 例:首帧为 updated 时当场计数且同 item 后续 updated/completed 不重复计数;登记前的
updated 进缓冲、登记后被重放出来。

验证:codex 全套 531 passed;pnpm test:unit EXIT=0 / 0 FAIL;maker-shared typecheck 通过;
desktop typecheck 0 error。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — 断连时收口在跑的卡;卡片起点回溯到最早证据

**1. 断连时把仍在跑的卡收成终态(P1)**

tracker 只靠后代 `turn/completed` 写终态,而 transport error / 强制 retire / thread cleanup
failure 之后那些通知**永远不会再到**。原来两条收口路径只调 `subagentLiveCards.clear()` 丢内部
状态,不发任何帧 —— 桌面与手机的 task-update map 于是一直留着最后一帧 `running`:进程早就死了、
或者用户已经重连,卡还在原地转圈(review)。

新增 `drainRunningForShutdown()`:为每张非终态卡把 running 线程改成 `stopped` 再产出快照,由
`closeSessionHandle` 与 `terminateHandleAfterThreadCleanupFailure` 两处在 `clear()` **之前**发出。

两个细节是刻意的:① 报 `stopped` 而不是 `failed` —— 会话没了不代表子代理失败,它是被中断的;
② 改线程状态而不是在 snapshot 里特判,这样 aggregateStatus 自然收敛,也保住 `spawnFailed` 闩的
优先级(派发本身失败过的卡仍报 failed)。线程集合为空的卡(spawn 刚认出、子线程还没 started)
补一个占位线程,否则 aggregateStatus 对空集合返回 running,那张卡还是会转圈。

**2. 卡片起点回溯到最早证据(P1)**

`startedAt` 原来取建卡时刻。但 spawn 的 started/updated 阶段可能缺失或晚到,等 completed 才建卡
时子线程其实已经跑了一段,而那段时间的通知都躺在缓冲里(没有时间戳)—— 于是已消耗的时长整段漏掉,
长跑子代理甚至显示接近 0ms。

新增 `firstSeenAt`:第一条缓冲通知或第一条未归属血缘边就记下时刻,建卡时取本次 spawn 子线程里
最早的那个;没有任何早于建卡的证据时才退回 now()。

验证:新增 4 例(仍在跑的卡被收成 stopped 且已收口的不重复发、drain 幂等、线程未登记的卡也能收口、
起点回溯 60s、无早期证据时回退 now)。变异验证:把 startedAt 改回 now()、把 drain 的非终态判断短路,
对应 3 例立刻失败。codex 全套 535 passed;pnpm test:unit EXIT=0 / 0 FAIL;
maker-shared typecheck 通过;desktop typecheck 0 error。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — 子代理帧不参与主 turn 存活判定

`eventQueue.push` 上装了探针:每条事件都会刷新 `upstreamIdleLastEventAt` + `armUpstreamIdle()`,
并喂给 `observeReconnectStallEvent`。而 `agent_task_update` 就在 `isReconnectRecoveryEvent` 的
白名单里(第 4631 行)。于是子线程一有进展,就会重置主线程的静默计时并清掉 reconnect deadline ——
主 turn 其实已经哑火,却因为子代理还在跑而**永远检测不出来**(review)。子线程有进展 ≠ 主 turn 恢复。

刻意**不**从 `isReconnectRecoveryEvent` 白名单里删 `agent_task_update`:那对 Claude 主线程的 Task
更新是正确的语义,删了会误伤。修在源头 —— `emitSubagentCardUpdate` 期间置 `emittingDescendantUpdate`,
探针据此跳过存活判定(push 是同步的,set 与 clear 之间不会被别的事件穿插)。

回归测试用既有的 reconnect-stall harness:spawn 登记完成后发 reconnect 提示,此后**主线程一条事件
都不发**,只让子代理每 30s 产出一次工具调用,断言 ① 卡片确实在更新(证明子代理帧真的流过来了,
不是测试没生效)② deadline 照旧到点收口为 `codex_reconnect_stalled`。

写这条测试时先踩了一次自己的坑:第一版把 spawn 的 `itemStarted` 放在 reconnect 提示**之后**,
而那是合法的主线程进展、本身就会清 deadline —— 于是测的是"主线程事件清了 deadline",不是"子代理
帧不该清"。已调整顺序并在注释里写明,免得后来人照抄错版本。

档位说明:这改的是 upstream-idle / reconnect 存活状态机,按 owner 22:08 的分档属 **D 档**,
所以跑了全量,不是只跑单文件。

验证:pnpm test:unit EXIT=0 / 0 FAIL;codex/index.ts 自身无类型错误(该包既有 test 文件的存量
类型错误与本改动无关)。变异验证:把探针的 `!emittingDescendantUpdate` 判断短路,该用例立刻失败。

Signed-off-by: Chris <zqchris@users.noreply.github.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* fix: address review — transport 断连也收口在跑的子代理卡

app-server 崩溃 / stdio 断开走的是 `scope: 'transport'` 那条错误路径:订阅当场作废,
后代通知永远不会再到,而 tracker 的终态只由后代 turn/completed 写入。原来这条分支只
广播 error、清主线程缓存,子代理卡停在最后一帧 running —— 进程早死了,界面还在转圈。

复用 close() / cleanup-failure 已有的 drainRunningForShutdown() + clear(),不新增机制。
回归测试模拟一次普通断连:验证在跑的卡收到 stopped 终态且保住已聚合的 token/工具数,
重复断连与迟到的后代通知都不再投递新帧,close() 的同一套 drain 保持幂等。

Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

---------

Signed-off-by: Chris <zqchris@msn.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
Signed-off-by: Chris <zqchris@users.noreply.github.com>
Co-authored-by: Chris <4436110+zqchris@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-discussion 等待维护者讨论(review-pr) touches:product-ui 改动碰到产品 / UI 面(review-pr 自动维护,仅展示)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants