Skip to content

fix(lark): 修复仓库选择卡吞卡、恢复与搜索 - #756

Open
47seek wants to merge 14 commits into
deepcoldy:masterfrom
47seek:fix/repo-picker-card-overflow-recovery
Open

fix(lark): 修复仓库选择卡吞卡、恢复与搜索#756
47seek wants to merge 14 commits into
deepcoldy:masterfrom
47seek:fix/repo-picker-card-overflow-recovery

Conversation

@47seek

@47seek 47seek commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

问题

真实环境扫描 132–134 个项目时,首次仓库选择卡会间歇性发送失败,表现为 socket hang up / write EPIPE;更严重的是同步扫描期间第一次 @机器人 约 1 分钟没有任何可见反馈,容易被误认为消息被吞。

现场剖析一次 134 项扫描:

  • 总耗时 76,170 ms;
  • 245 次 Git 子进程占 75,946 ms;
  • stat / readdir 仅约 0.2 秒;
  • 主要热点是 detached Flow worktree 逐个执行 git describe --tags --exact-match HEAD,仓库约有 117,370 个 tag,单次可耗 2–6 秒;
  • 同步扫描阻塞 daemon 事件循环,扫描后第一笔卡片请求可能复用已被服务端关闭的 keep-alive socket。

改动

首次 @ 不再等待扫描

  • 保持原同步 scanner 的扫描结果、排序、去重、worktree 和 tag 识别语义不变,仅放到隔离 child process 执行。
  • pending session 注册完成后立即回复“正在扫描仓库,您的消息已暂存”,随后释放 per-anchor serializer;扫描期间同话题的 follow-up、/repo/close 可正常处理。
  • child 完成后再发原仓库卡;如果 route 已被 /repo、关闭或其它入口消费,丢弃迟到结果并回收迟到卡,不会二次 fork。
  • 扫描结果为空时复用 pending commit 事务,将扫描期间的补充消息、附件、mentions、sender、chat context 和最新 turn id 一次性折入唯一首轮 fork。
  • child 异常时保留 pending input,并给出 /repo 文本恢复入口。

卡片超限、陈旧连接与失败恢复

  • 首次仓库卡使用稳定的 repo-picker:<sessionId> provider UUID。
  • 仅对 EPIPEECONNRESETECONNABORTEDETIMEDOUTsocket hang up 等瞬时传输错误自动重发一次,首发与重发共用 UUID,避免重复卡。
  • wire bytes 预算按最终 request data 计算并控制在 12,000 bytes 内。
  • 两次发送都失败才保留 /repo <name|path> 文本兜底;route 已消费或正在 commit 时不再发过时恢复文案。
  • 仓库卡保留搜索框,可从完整 lastRepoScan 按项目名、分支或路径搜索;搜索结果保持原始完整列表编号。
  • 搜索和单/多仓库模式切换继续使用“新卡发送成功后再回收旧卡”及 session/card-id CAS,失败时不吞活动卡。

行为说明

  • 扫描仍可能因大仓库 tag 数量耗时,但不会再阻塞 daemon,也不会让第一次 @ 静默。
  • 原卡片与仓库选择流程保留;不会靠“下一张卡回收上一张再重发”来掩盖失败。
  • 卡片暂不可用时,用户仍可立即回复 /repo <项目名|路径>,或 /repo 使用当前工作目录;首轮消息和扫描期间补充均不会丢。

验证

  • 聚焦回归:7 files、492 tests passed
    • child scanner 与同步结果严格相等
    • parent event loop 保持响应
    • 生产形态 anchor serializer 在扫描期间释放
    • follow-up 缓冲、bare /repo 接管、empty scan 单次 fork
    • route replacement、迟到结果/卡片撤回、card delivery 竞态、child failure 恢复
    • 卡片裁剪、搜索、原始编号、稳定 UUID 与连接重试
  • 全量单测:816 files passed,13,091 tests passed,36 skipped
  • pnpm exec tsc --noEmit
  • pnpm build
    • domain audit、TypeScript、dashboard bundle、dist audit 全部通过
    • runtime build id: 4ac0b8dd7335
  • 编译产物 child IPC smoke 通过。
  • git diff --check
  • 两轮独立只读复审无 blocking correctness 问题。

现场验证

部署 3191bd3a 后,在新话题第一次 @机器人

  1. 应立即看到扫描中提示;
  2. daemon 在扫描期间仍能接收 follow-up 或 /repo
  3. 扫描结束后显示选择卡,或空结果时自动开始;
  4. 不再需要第二次 @ 才唤醒首轮消息。

扫描卡交接补充(e2caa193)

  • 第一次 @ 后立即发送独立、不可操作的“正在扫描仓库”卡片。
  • 扫描完成时先发送完整仓库选择卡,成功后撤回扫描卡;正常情况下话题里只保留最终选择卡。
  • 未直接 PATCH 原卡:现有仓库选择卡包含 Lark v1 form,PATCH 会出现空卡;因此保留原搜索、手工路径和多 worktree 表单逻辑。
  • 扫描卡使用独立稳定 UUID,瞬时传输失败原 UUID 重试;route 被 /repo、/close 或其它入口消费时会回收迟到卡。
  • 修复旧扫描卡发送失败清空新选择卡授权 ID 的竞态。

验证:聚焦回归 8 files / 513 tests passed;TypeScript、build、diff check 通过。全量 819 files 中 815 passed、3 skipped,1 个无关 worker 状态集成测试在并行负载下超时;该文件随后隔离重跑 3/3 passed。runtime build id: 170f86b9ddaf。

@47seek
47seek requested a review from deepcoldy as a code owner August 6, 2026 03:59
@47seek 47seek changed the title fix(lark): 修复仓库选择卡超限与失败恢复 fix(lark): 修复仓库选择卡超限、恢复与搜索 Aug 6, 2026
@47seek 47seek changed the title fix(lark): 修复仓库选择卡超限、恢复与搜索 fix(lark): 修复仓库选择卡吞卡、恢复与搜索 Aug 6, 2026
@47seek
47seek force-pushed the fix/repo-picker-card-overflow-recovery branch from 8f0fbdb to 3191bd3 Compare August 6, 2026 14:39
@47seek

47seek commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

已追加首次 @ 扫描无反馈的修复并部署验证环境:

  • latest head: 3191bd3a(已 rebase 最新 master)
  • 同步 scanner 语义不变,改为 child process 执行;pending 注册后立即发扫描中提示并释放 anchor serializer
  • follow-up、bare /repo、route replacement、迟到卡、empty scan 单次 fork、child failure 都有路由回归
  • 聚焦:492/492 passed
  • 全量:13,091 passed / 36 skipped
  • TypeScript、build、compiled child IPC smoke、diff-check 均通过
  • runtime build id: 4ac0b8dd7335
  • 4 个 bot daemon 已重启到该构建并保持 online;未合并,等待 CI 与 review。

47seek added 8 commits August 6, 2026 23:17
将 fix/repo-picker-card-overflow-recovery 与最新 upstream/master 合并,解决 5 处冲突
(command-handler / types / daemon / card-handler / 测试),并对齐本分支「异步仓库扫描」
与 master 新增「durable queued-activation-tail」两套并发缓冲模型:

- daemon:仓库选择卡走异步子进程扫描(scanMultipleProjectsAsync + repoScanInFlight),
  扫描期间同锚点消息缓冲进 pendingFollowUps 并静默 hold;扫描结束后落 repoCardMessageId
  时补 persistPendingRepoCardMessageId 保持重启可恢复。
- handleThreadReplyAdmitted:pendingRepo 预 fork 缓冲按开局形态分流——bare /repo 占位
  提升为 durable 开局、raw-root 开局按 deepcoldy#597 暂存 durable 队列尾;普通 prompt 选仓卡的
  后续消息仍折进 pendingFollowUps(选仓提交路径消费的正是该 buffer,避免提交时丢消息)。
- card-handler:worktree_toggle_mode 保留跨 await 卡片竞态守卫(isActiveRepoCard /
  isCurrentRepoCardReplacement),并在切换运行态卡身份前补 persistPendingRepoCardMessageId。
- 仓库搜索结果沿用全量原始 1-based 编号(displayIndexByPath),卡面编号与 /repo N 指向一致。

验证:tsc --noEmit 通过;pnpm build 通过;受影响的定向单测全绿(daemon-repo-scan-routing
22、daemon-rename-route 56、group-join-shared-routing 18、card-handler-repo-select 75、
daemon-codex-app-workflow-wiring 3);三方合并对 master 零冲突。全量单测除依赖 bwrap /
tmux 的环境型集成用例(在 master 上同样失败)外全部通过。
异步扫描把 140 仓库扫描挪进 fork 子进程后,runScan 仅在子进程
message/error/close 时结算 Promise。若子进程加载后卡在同步文件系统调用
(挂死的 NFS 网络盘、超大目录树——正是本子系统要隔离的慢扫描场景),
则永不发消息、永不退出 → Promise 永不结算。又因扫描走全局串行队列
(scanQueue),一个卡死子进程会永久堵住此后所有会话的仓库扫描,且无看门狗自愈。

修复:
- runScan 加超时(默认 60s,可用 BOTMUX_REPO_SCAN_TIMEOUT_MS 覆盖):超时先 SIGTERM,
  2s 宽限后仍在则 SIGKILL。kill 必然触发 close 事件,从而结算 Promise 并让串行队列
  继续往下推;超时会走进已有的 catch 分支(撤进度卡 + 发 /repo 文本恢复)。
- 补回归测试:用一个「加载后挂住、IPC 永不回复」的子进程 + 极短超时,断言扫描以
  timeout 报错拒绝,且**后续扫描仍成功**(队列未被毒化)。
- childEntryPoint 增加 BOTMUX_REPO_SCANNER_CHILD 覆盖 seam(仅测试/排障用),
  以便无需真卡死即可覆盖超时路径。

验证:tsc --noEmit 通过;pnpm build 通过;project-scanner-async 4 测试全绿
(含新超时+队列排空回归,485ms 内完成,无子进程泄漏);daemon-repo-scan-routing /
project-scanner / daemon-rename-route 定向套件全绿。
复审在异步扫描子系统上发现三个 blocking,逐个修复:

1. 默认超时会误杀生产扫描:参考部署扫 134 仓库需 ~76s,原 60s 会在扫完前
   就杀掉,核心场景永久只剩文本兜底。默认抬到 180s(~2.4x 余量),
   BOTMUX_REPO_SCAN_TIMEOUT_MS 仍可覆盖。

2. watchdog 依赖 close 事件才结算,不保证解锁队列:不可中断 I/O(挂死 NFS)下
   即便 SIGKILL 也可能长期 pending、close 永不到达,全局串行 scanQueue 仍被
   永久堵死。改为父进程 OWN 结算——settle() 只结算一次并摘除全部 child listener,
   超时时排好 SIGKILL 兜底后**立即** settle(reject),不再等 close;后到(或永不
   到达)的 close 不会二次结算或泄漏。补回归测试:子进程 trap SIGTERM、close 不
   在宽限内到达,扫描仍在 ~超时点 reject 且队列继续排空。

3. 失败恢复会把同步扫描搬回 daemon 事件循环,重造原始全局卡死(最隐蔽):
   兜底文案引导用户 `/repo <项目名>` / 直接 `/repo`,而 resolveRepoSelection 的
   bare-name 递归扫描与 bare `/repo` 建卡此前都走同步 scanMultipleProjects()、
   直接跑在 daemon 事件循环上——无隔离无 watchdog,恢复指引反而把用户引回本 PR
   要消灭的那个卡死。改为 resolveRepoSelection 异步化 + 两处递归扫描(bare-name
   解析、bare /repo 建卡)都改走隔离的 scanMultipleProjectsAsync。补回归测试:
   嵌套 name 解析命中的同时断言同步 scanner 从未被调用(证明走了异步隔离路径)。

验证:tsc --noEmit 通过;pnpm build 通过;project-scanner-async 5 / repo-selection 10 /
command-handler 242 / daemon-repo-scan-routing 22 等定向套件全绿(470 项);全量单测
除依赖 bwrap/tmux 的环境型集成用例(在 master 上同样失败)外全部通过。
上一版 settle() 在结算 Promise 时 clearTimeout(killTimer),把超时分支刚排好的
SIGKILL 升级定时器一起取消了。对忽略 SIGTERM 的卡死子进程(正是要防的那类),
SIGTERM 无效、SIGKILL 又永不触发 → 子进程永久泄漏,长跑 daemon 在慢盘上会
不断堆积僵尸扫描进程。

修复:
- settle() 只清 timeoutTimer,绝不清 killTimer——结算只负责解锁串行队列,进程回收
  由 SIGKILL 升级定时器独立跑完;仅在子进程自身 'close' 到达时才清 killTimer。
- 回归测试补齐「进程最终退出」断言:stubborn child 写出自己的 PID,测试在 grace
  过后 poll 该 PID,断言已 dead(SIGKILL 生效、无孤儿泄漏),而不再只断言 Promise
  快 reject + 队列排空。已验证测试有牙:重新放回「settle 清 killTimer」的 bug 时,
  该测试会在进程存活断言处失败。

验证:tsc --noEmit 通过;project-scanner-async 5 测试全绿(含新回收断言,无孤儿残留)。
上一版只把递归 basename 扫描换成了异步隔离,但 resolveRepoSelection 的直接候选
fast-path 仍在 daemon 事件循环上同步跑 statSync + describeProjectDir——后者会 shell
出多次 git(含 `git describe --tags`,在超大 tag 仓库上 2-6s/次的热点)。挂死 mount
上单次不可中断的 statSync 就能在 watchdog 子进程起来前锁死整个事件循环,隔离不完整。

修复:
- 把完整的 `/repo <name|path>` 解析(候选 stat + describeProjectDir 的 git 调用 +
  递归 basename 扫描)整体下沉到隔离子进程:新增子进程 resolve 请求类型,
  project-scanner-async 暴露 resolveRepoSelectionAsync,command-handler 的
  resolveRepoSelection 只做薄委托。runChild 复用同一套超时/SIGTERM→SIGKILL/父进程
  结算机制,resolve 与 scan 共享全局串行队列与看门狗。
- 无论命中哪条候选路径或回退,都不再有同步 fs/git 落在 daemon loop 上。
- 回归测试:新增「直接候选 stat + git describe 也不在父进程跑」用例(spy 断言
  同步 scanMultipleProjects 与 describeProjectDir 均未被父进程调用),与既有
  「嵌套 name 递归扫描走异步隔离」用例配套。command-handler / daemon-rename-route
  的 /repo 测试 mock 相应对齐(resolveRepoSelectionAsync 走 mock 或原实现)。

验证:tsc --noEmit 通过;pnpm build 通过;command-handler 242 / repo-selection 11 /
project-scanner-async 5 / daemon-rename-route 56 / 相关 daemon 套件全绿(468 项);
全量单测仅剩依赖 bwrap/tmux 的环境型集成用例失败(纯 master 上同样失败)。
@deepcoldy

Copy link
Copy Markdown
Owner

#797 的合并协调说明

#797(给同步扫描加进程内预算护栏 maxScanDirs/maxScanMs + onBudgetExceeded 提示卡,定位为纵深防御/缓解)已双审通过,将先行合并作为快速止血。

本 PR(#756)是同一问题的根治向修法(把扫描 fork 进子进程 + watchdog 超时),层次更深、体量更大,继续按自身节奏收敛即可。

需要注意的一处冲突#797 合入后,本 PR 会在 src/core/command-handler.ts/repo 处理块与 i18n 键上产生文本冲突——#797 把该块改成了「同步 scanMultipleProjects(..., { onBudgetExceeded })」,本 PR 是「await scanMultipleProjectsAsync(...)」。

建议的解法:保留本 PR 的异步子进程扫描,同时把 #797onBudgetExceeded 部分结果提示语义接过来——因为子进程内部仍调用同一个同步 scanProjects#797 的预算护栏在子进程里依然生效,两层叠加(预算让单次扫描有界 + watchdog 兜底内核挂死)是最稳的组合。i18n 只需保留双方各自新增的键(scan_budget_no_repos/scan_budget_partial 与本 PR 的 list_limited/refresh_failed/repo_card_unavailable 等)即可,互不冲突。

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