fix(edge): events 正确性批 2——闸门遗弃轮次永久卡死总线 / 截断短读丢最新事件 / 确定性 persist 失败仍烧退避梯 - #2239
Conversation
…仍烧退避梯 (#2154) 三条同包缺陷,都是 #2234(seq 顺序不变量)落地后由主机侧复审与移交清单挖出的。 一、闸门遗弃轮次 → 总线永久卡死(#2234 引入的新失败模式,主机侧复审发现) #2234 的顺序门要求「拿到 seq 的那个 publisher 必须消费掉自己的轮次」。 deliverInSeqOrder 的 defer 覆盖了函数内部的 panic/Goexit,但 seq 分配到进闸 之间还有一段**没有任何守护**的代码:persistWithRetry(走 EventLog.Append, 含 37.5MiB 级读写与截断)与失败时的 slog.Error。而 Publish 的调用方是 safego 守护的 goroutine(orchestrator dispatch、lifecycle 回调队列),所以那里 的 panic 会被**上层恢复、进程继续活着**——但 wireNext 永远停在该 seq 之前, 之后每个 publisher 都停在 `for b.wireNext < seq { Wait() }`,再没有人能 Broadcast。一次丢事件升级成**重启前全量静默丢事件**,比顺序门要修的乱序更严重。 红证据(两条,均实测): - `goroutine 7 [sync.Cond.Wait]` 永久停在 `deliverInSeqOrder(seq=2)`, `panic: test timed out after 15s`(seq 1 的轮次被恢复掉的 panic 遗弃); - 快速失败形态:`bus wedged after a recovered panic between seq assignment and the wire gate: wireNext never advanced, so every later publisher parks forever in sync.Cond.Wait`。 修法:Publish 在 seq 分配后立刻挂一个 defer,未被 deliverInSeqOrder 接管时调 abandonGateTurnLocked(seq);轮次已到就当场消费,没到就记进 wireAbandoned, 等闸门走到时由 advanceGateLocked 级联跳过(跳过时删除,map 由"尚未被走到的 遗弃量"限界)。**不在 panic 展开的 defer 里 Wait**——那会把恢复路径变成新的 死锁面。 自陈:我的第一版 advanceGateLocked 在"无遗弃"的常规路径上 `return` 而漏了 Broadcast,直接复现同类卡死,被既有 TestBusConcurrentPublishSubscriberIntegrity 挂住(2 分钟超时)抓到;已修并把原因写进注释。 二、truncateLocked 短读 → 丢掉最新事件(数据丢失级,#2234 移交,主机侧已核实) 截断路径 seek 到 -keepBytes(= maxSize*3/4,50MiB 默认即 37.5MiB)后**只做一次** l.f.Read(buf),随后 Truncate(0) 已经毁掉原文,却只回写 buf[start:n]。 read(2) 对普通文件返回少于请求的字节数是完全合法的(大读、信号中断、网络/ overlay 文件系统),os.File.Read 一次 syscall 对应一次调用 → 短读时**未读到的 那段正是保留窗口的尾部,也就是最高 seq、最新的事件**。 同函数第二个反模式:`readErr.Error() != "EOF"` 用字符串比对判 EOF,包装过的 EOF 会漏判。 修法:抽出可注入的 readRetentionWindow(io.Reader, int64) 循环读满,EOF/ UnexpectedEOF 视为"文件比窗口短"(返回已读字节,不算失败),其余错误照旧计入 truncateFailures;errors.Is 取代字符串比对。 红证据:注入每次只返回 1/3/7/64 字节的 short reader → `chunk=1: read 1 of 1520 bytes — a short read silently drops the newest, highest-seq tail of the retention window after Truncate(0) already destroyed the original`。 诚实标注:真文件端到端那条(TestTruncateLocked_KeepsNewestEventsEndToEnd) 修前修后**都绿**(本机 ext4 这个尺寸不会短读),它是回归钉子不是红证据; 红证据只来自注入的 short reader。 三、确定性 persist 失败仍烧退避梯(#2234 移交,主机侧复核后接受) persistWithRetry 对 json.Marshal 的永久性失败(payload 里有 chan/func)照样 重试 3 次并睡 2+4+8=14ms;而顺序门把"慢 persist"从单个 publisher 的延迟变成 **总线级延迟**。 修法:EventLog.Append 把 marshal 失败包成 errUnpersistableEvent, persistWithRetry errors.Is 命中即立即返回不睡;瞬时错误仍照旧重试。 红证据(临时撤回分类分支复现,已复原): `a deterministic persist failure burned 14.73489ms` `persistFn was called 4 times, want exactly 1` 另一方向也钉住:TestPersistWithRetry_TransientFailureStillRetries(修前修后均绿, 防止"分类"退化成"永不重试")。 门禁(全部跑在本 HEAD): - edge go build ./... / go vet ./... 干净;gofmt 净 - go test ./internal/events/ -count=1 → ok 1.5s - go test ./internal/events/ -race -count=3 → ok 16.0s,无 DATA RACE - golangci-lint run ./internal/events/... → 0 issues - verify-edge-lint-ratchet.py → PASS(9 findings,全在基线,未新增指纹) - gosec -fmt=json ./... | verify-gosec-gates.sh → CLEAN - verify-doc-ssot / verify-conventions rc=0;git diff --check 干净 证据等级:L0(单测 + -race + 静态门禁)。未做真实 :3210 + SSE/WS 客户端的 端到端乱序/截断观测,也未量化闸门 head-of-line 的 p99 尾延迟。 Co-authored-by: Cursor <cursor@vectorcontrol.tech>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…DWR 句柄重写 (#2154) CI 的 Native Windows Go (edge-server) 抓到(本 PR 首轮的失败就是它): ERROR event log truncate Truncate(0) failed path=...\truncate-replay.jsonl error="truncate ...: Access is denied." 报错来自**既有**测试 TestEventLogIndexSurvivesTruncation,不是我新增的测试—— 即缺陷先于本 PR 存在,只是被新断言显形。 根因:NewEventLog 以 os.O_APPEND|os.O_CREATE|os.O_RDWR 打开日志 (eventlog.go:102),而 Windows 对 append 模式句柄拒绝 SetEndOfFile,于是 l.f.Truncate(0) 每次都以 "Access is denied" 失败。Linux 接受同一调用,所以这个 缺陷在 Linux 开发面与 Linux CI 上完全不可见。 影响面(已核实 Edge 确实在 Windows 出货,不是只跑 CI):release.yml:109 构建 agenthub-edge-<ver>-windows-amd64.exe;:203 作为 Tauri sidecar (agenthub-edge-x86_64-pc-windows-msvc.exe)打进桌面安装包;:262 进 portable 包。 ⇒ 每台 Windows 桌面安装的事件日志**永不截断、无界增长**,现场只剩每次 Append 的 一条 error 日志与 edge_event_log_truncate_failures_total 计数。 修法:截断与重写走**独立的 O_RDWR 句柄**。Go 以 FILE_SHARE_READ|WRITE|DELETE 打开文件,第二句柄合法;重写与所有其它变更一样在 l.mu 内;append 句柄语义完全不 变,rebuildIndexLocked 末尾仍把它 seek 回 EOF 供 replay 读。代价=每次截断多一次 open/close(约每 25k 事件一次)。**没有**改 O_APPEND:它提供的是并发写入者只追加 的保证,为截断牺牲它不划算。 测试强化(跨平台,不加 skip): - 原断言只查 MaxSeq/ReadFrom,Windows 上「截断失败但索引还在」能蒙混过关;现在 同时断言 fi.Size() <= maxSize **且** log.truncateFailures == 0——"截断必须成功, 不是尝试过"。 - 该断言在 Windows CI 的红证据:log grew to 45692 bytes with maxSize 4096。 - 注释写明分工:对短读修复它是钉子(本机 ext4 这个尺寸不会短读),对 Windows 缺陷它是红证据;两者由不同测试证明,不混为一谈。 门禁(本 HEAD):edge go build ./... / go vet 干净;go test ./... → 34 包 ok; go test ./internal/events/ -race -count=1 → ok 6.7s;golangci-lint ./internal/events/... → 0 issues;verify-edge-lint-ratchet.py → PASS(9 全基线); git diff --check 干净。 Windows 侧结论只能由 CI 的 Native Windows Go (edge-server) 证明——本机无 Windows, 不冒充已验证。 Co-authored-by: Cursor <cursor@vectorcontrol.tech>
追加:首轮 CI 的
|
…2154)(#2237) ## 这条 lane 是什么 实施 lane E(**纯文档**)= #2154 Feynman 文档探索批的切片 C「对外文档诚实批」。 唯一非 `.md` 改动是第 6 条的 `scripts/verify/quality-debt-baseline.json`(任务书明示的唯一例外)。 **未改任何 `.go` / `.ts` / `.tsx` / `.yaml` 契约文件。** 分支已 `git fetch origin && git rebase origin/master`;上游新增的 `dc7df53d`(纯 Go:edge lifecycle safego 去重 + panic observer)与本分支 9 个文件**零重叠,rebase 无冲突**,因此没有触发「两边都保留 + 行数预算重新核算」的解冲突流程。 合并时 HEAD = `b017f0f4`,base = `94aef98a`(origin/master);lane push 时的 HEAD 为 `8f2ade25`(base `dc7df53d`)。7 个 commit,每个 commit 一次门禁。 **订正(主机侧合并前补记,原文此处写的是「故意不再 rebase」,与最终实况不符)**:push 后 `origin/master` 由 `dc7df53d` 连续前进到 `94aef98a`(#2234/#2235/#2236/#2239/#2240),本仓 `required_status_checks.strict: true` 使 PR 转 `BEHIND`,故本分支**已按纪律 rebase 到 `94aef98a`**,HEAD 由 `8f2ade25` 变为 `b017f0f4`。rebase 前后**写集逐字节零变化**:`git diff --stat 8f2ade2 b017f0f -- AGENTS.md CHANGELOG.md CONTRIBUTING.md README.md README_EN.md SECURITY.md docs/ scripts/verify/quality-debt-baseline.json` **输出为空**;两者全量 diff 只含 master 自身的 `app/pnpm-lock.yaml`+`app/pnpm-workspace.yaml`(#2240)与 `edge-server/internal/events/**`(#2234/#2239),与本 lane 9 个文件零重叠;7 个 commit 逐条 subject 一一对应。因此正文里所有本地实测结论原样成立。rebase 后按原文要求**重跑并复现**:`scripts/verify/verify-doc-ssot.py` → `doc SSOT ok`(verifier-map 66 script paths / 58 CI files、AGENTS.md 96 paths)、`wc -l AGENTS.md` → **284**(≤300 预算)、`git diff --check` 干净;CI run `33666303378` 在 `b017f0f4` 上**全绿**(22 successful / 17 skipped / 0 failing / 0 pending,含 go-hub、go-edge、windows-go、backend-required、frontend-required、ui-required、validate、CodeRabbit),`mergeStateStatus: CLEAN`。 --- ## 逐条三段式证据(文档原话 → 代码/CI 事实 → 改后原话) > 7 条逐字引证与改后原话全文见本 PR 正文(GitHub 侧永久保留),squash commit 只收判定结论以免历史膨胀。 | # | 条目 | 复核判定 | |---|---|---| | 1 | `CONTRIBUTING.md:36` 称 `make test` 跑前端 vitest | ✅ 成立(四处口径改指明 Makefile 目标名) | | 2 | `CHANGELOG.md:5-7`「暂无未发布变更」 | ✅ 成立(改为声明 SSOT 与生成方式) | | 3 | `SECURITY.md:23` 安全门禁工具名指错 | ✅ 成立,且原文另有一处更严重的不诚实 | | 4 | `README.md` / `README_EN.md` |⚠️ 部分成立(禁用词那条报告说法不准确,但假声明本身成立) | | 5 | `verifier-map.md` + `docs/architecture/README.md` 双向差集 | ✅ 成立(补 12 行 + 修 2 处不诚实 + 补 1 行索引) | | 6 | `quality-debt-baseline.json` 的 `issue` 归属 |⚠️ 报告部分成立:6 条里只敢改 2 条 | | 7 | `AGENTS.md` §12 加「CHANGELOG owner」 | ✅ 净减 2 行守住行数预算(284≤300) | ## 复核后判定「不成立 / 已过期」而跳过或改判的条目 | # | 报告说法 | 实测 | 处置 | |---|---|---|---| | 4b | 「路线图/roadmap 被门禁主动禁止却仍存在」 | `verify-doc-ssot.py` 只禁**根级文件 `ROADMAP.md`**(`:100`)、**路径 `docs/roadmap`**(`:109`)、**正则 `ROADMAP\.md`**(`:200`);没有任何规则禁「路线图」这个词,门禁本来就跑得绿 | 报告说法**不成立**,已在 PR 正文写明。但底下的**假声明成立**(`docs/` 里确实没有路线图),故仍按事实改,改法换成门禁理由本身陈述的真事实(roadmap 在 GitHub issues) | | 6 | web lint 债的真实归属是 1575 | `gh issue view 1581` 正文原话「**#1575 只负责 Desktop ESLint,不覆盖 Web**」 | 报告此点**不成立**,改判为 **#1581** | | 6 | i18n callsite 债的真实归属是 1612 | `#1612` 是 PR「docs(progress): MASTER 同步」,`files` 只有 `docs/progress/MASTER.md`;而仓内三处(checks.yml:2126 / verifier-map:27 / CHANGELOG:44)一致引用 #1612 | 报告此点**不成立**(且暴露更大问题:全仓的 #1612 引用可疑)。**不改**,登记 #2154 待裁决 | | 6 | 5/6 条都该改 | 只有 2 条能拿到「该 issue 明确以这笔债为标的」的正文证据 | **只改 2 条**,另 3 条按任务书要求不猜号 | | 1 | 四处口径自相矛盾 | `docs/developer-quickstart.md:122-123` 其实是**正确**的那一处 | quickstart **未改**,只改 CONTRIBUTING(错的那处)+ AGENTS(歧义的那处) | | 2 | 若不成立才补真实条目 | SSOT 判断**成立** | 按要求**没有**手写 Unreleased 列表 | --- ## 门禁表(原跑于 HEAD `8f2ade25` / base `dc7df53d`;rebase 到 `b017f0f4` / base `94aef98a` 后写集零变化,doc 门禁已重跑复现、CI 已全绿重证) | 门禁 | 命令 | 结果 | |---|---|---| | 文档 SSOT(主门禁) | `python3 scripts/verify/verify-doc-ssot.py` | ✅ `doc SSOT ok`;verifier-map **66** 脚本路径 / **58** CI 文件全部存在;AGENTS.md **96** 个反引号路径全部存在;`DOC-README-PARITY` PASS | | CI 结构合同 | `python3 scripts/verify/verify-ci-gates.py` | ✅ `ci gate policy ok` | | 质量债棘轮(动了 baseline) | `python3 scripts/verify/verify-quality-debt-ratchet.py` | ✅ **9 pass / 0 fail** | | 质量债棘轮负向自测 | `python3 scripts/verify/tests/verify-quality-debt-ratchet.Tests.py` | ✅ **15 tests OK** | | skill 白名单 | `python3 scripts/verify/verify-project-skills.py` | ✅ rc=0(`skills root absent (.agents removed) — whitelist gate trivially passes`) | | conventions 方法 SSOT | `python3 scripts/verify/verify-conventions.py` | ✅ `Passed: 1 \| Failed: 0` | | doc-ssot 负向自测 | `python3 scripts/verify/tests/verify-doc-entrypoints.Tests.py` | ✅ `Ran 1 test … OK`(证明主门禁没被我的改动弄钝) | | 空白/冲突标记 | `git diff --check origin/master..HEAD` | ✅ clean | | AGENTS.md 行数 | `wc -l AGENTS.md` | ✅ **284** ≤ 300 | | **GitHub Actions(本 PR 真实 run)** | run [33658111957](https://github.com/TokenDanceLab/AgentHub/actions/runs/33658111957) | ✅ **22 SUCCESS / 17 SKIPPED / 0 非绿**;7 个 required 聚合全绿:`validate` `go-edge` `go-hub` `windows-go` `windows-frontend` `backend-required` `frontend-required` | | 其他行数预算 | `wc -l` | ✅ CHANGELOG.md 80/90、CONTRIBUTING.md 58/90、verifier-map.md 87/120、docs/architecture/README.md 28/40 | **按纪律未跑**:`go test`、`go build`、vitest、coverage、全量 golangci-lint、docker、`make *`(4 核机 + 并行 lane)。 **golangci-lint 幽灵**:本 lane 未跑 golangci-lint,未遇到指向已删除 worktree 路径的缓存幽灵 issue。 --- ## 未验证项(诚实声明) 1. ~~**没有跑任何 CI**~~ → **已验证(本条从「未验证」升级为「已验证」,PR 开出后回写)**:GitHub Actions run [33658111957](https://github.com/TokenDanceLab/AgentHub/actions/runs/33658111957) 结果 **22 SUCCESS / 17 SKIPPED / 0 非绿**,7 个 required 聚合(`validate`/`go-edge`/`go-hub`/`windows-go`/`windows-frontend`/`backend-required`/`frontend-required`)全部 SUCCESS。`validate` 是承载 `verify-doc-ssot.py` + `verify-ci-gates.py` + `verify-quality-debt-ratchet.py` + `verify-conventions.py` + `verify-project-skills.py` 的 job,它 SUCCESS ⇒ 本 PR 全部 9 个文件的改动在 CI 上被同一套门禁判绿,不只是我本地判绿。`go-*` 侧也跑了(因为 `scripts/verify/**` 在 `changes` job 的 `go` 路径过滤里,baseline JSON 改动触发了 Go lane),`go-hub` 的 golangci-lint + 覆盖率门禁 SUCCESS ⇒ 未出现缓存幽灵。 **顺带活体印证第 3 条的改法**:`Vuln scan (pnpm audit prod+full)`、`Vuln scan (govulncheck)`、`Vuln scan (cargo audit)`、全部 `frontend-*`、`Visual QA *`、`Design CSS syntax` 在本 PR 均为 **SKIPPED** —— 正是我写进 `SECURITY.md` 的「三个 vuln-scan job 都经 `changes` job 路径过滤触发,不是每次 push 全量扫描」的实时证据(本 PR 不含 `app/**` 改动)。 2. **markdown 渲染只在本地按 CommonMark 规则推断**,没有在 GitHub 上肉眼看过渲染结果。两处需要 review 时确认:`docs/architecture/README.md` 新增行、`verifier-map.md` 宏观四行并入主表后是否真的渲染成表格。 3. **`gh issue view` 读到的是 issue/PR 的当前标题与正文**,不能证明「该 issue 在软门禁被引入的那一刻就是 owner」。desktop→#1575 / web→#1581 的判定依据是两个 issue 正文**逐字点名了对应的 baseline 条目与 step 名**,这是我能拿到的最强证据,但仍属文档考古而非当事人确认。 4. **tag `v0.6.1` 与 master 历史脱钩**这件事我只做了 `git merge-base --is-ancestor` / `git merge-base` 两个命令的验证,**没有**去查 release.yml 的历史 run 是否真的因此失败过,也没有验证 git-cliff 在无前序 tag 时的实际输出长度。它超出纯文档 lane 范围,只登记不动手。 5. **未改任何产品代码**,因此第 3/5 条里所有关于「门禁 fail-closed」的描述都是**读脚本源码 + workflow YAML 得出**,不是我实跑这些门禁观察到的红/绿。唯一实跑过的是 `verify-doc-ssot.py` / `verify-ci-gates.py` / `verify-quality-debt-ratchet.py` / `verify-conventions.py` / `verify-project-skills.py` 及两个负向自测。 --- ## 需要人工裁决 / 后续 issue(已同步登记 #2154) 1. quality-debt baseline 3 条 `issue` 归属待确认:`frontend-mobile: Lint (mobile rules)`、`validate: Verify i18n callsites ratchet`、`vuln-scan-rust: cargo clippy (advisory)`(现值均为可证伪的 1573)。 2. 全仓 `#1612` 引用可疑(checks.yml:2126 / verifier-map:27 / CHANGELOG:44 三处),需定位 i18n callsite ratchet 的真实接线 issue/PR。 3. tag `v0.6.1` 不在 master 祖先链上 ⇒ `release.yml:42` tag-guard 与 git-cliff `--latest` 的前序 tag 解析都受影响,下一次打 tag 前需裁决(重打 tag / 调整 cliff 调用 / 接受全量分组)。 4. `cliff.toml` 的 `^security` commit parser 是**死分支**(提交类型白名单不含 `security`):要么给白名单加 `security`,要么删掉这个 parser 并改用 label/其它机制披露安全修复。本 PR 只把 SECURITY.md 的承诺改成与现状一致,没动 cliff.toml(属产品配置,非纯文档 lane 范围)。 5. `scripts/verify/tests/merge-coverprofiles.Tests.py` 与 `scripts/verify/tests/verify-real-e2e-artifacts.Tests.py` **存在于磁盘但没有任何 workflow 调用**(`grep .github/workflows/` 零命中)⇒ 两个负向自测是死的。我在 verifier-map 里因此**没有**把它们写成「负向自测」(只登记了脚本本体),避免制造新的假绿声明;是否接线请裁决。 6. #1575 / #1581 均已 CLOSED,但对应的两条 `continue-on-error` 软门禁**仍在 checks.yml 里活着**(`verify-quality-debt-ratchet.py` 的 zombie 检查 PASS 即证明这点),且两条的 `review_by` 都是 `2026-10-01`。即「偿还 ESLint 债并移除软门禁」的 issue 关了、软门禁没移除。属治理不一致,非本 lane 范围。 ## 流程事故记录(不影响代码,但影响交付物可信度,故如实记) 开出本 PR 后、往 #2154 贴登记评论时,**另一条并行 lane(Lane A,#2154 评论 `5513247382`)在同一分钟覆写了 `/tmp/pr-body.md`** —— 两条 lane 用了同一个临时文件名。后果与处置: - **PR #2237 正文未受影响**:`gh pr create` 在覆写发生前已执行完毕。事后用 `gh pr view 2237 --json body` 回读实测 21035 字符,首句「## 这条 lane 是什么」、末句「Closes 无(本 PR 是 #2154 的切片 C…)」,且 `grep -c "toast.actionUnavailable"`(对方正文特征串)= 0 ⇒ 内容是我的、完整的。 - **#2154 的首版评论被污染**:拼评论时读到的是对方正文,等于把我的抬头 + Lane A 的正文贴了上去。已从 PR 正文回读重建、用 `gh api -X PATCH .../issues/comments/5513238457` **原地编辑**修正(不新贴第二条制造噪声),并复核修正后正文里对方 lane 的 5 个特征串(`§6.4`/`§6.5`/`desktop forward`/`regenerate 是否另开 lane`/`i18n 资源面 lane`)全部 0 命中、我的 4 个结构节各 1 次。 - **教训(供主机侧收进并行 lane 纪律)**:多 lane 并行时临时文件必须用 lane 唯一路径。本 lane 后续已改用 `/tmp/laneE-doc-honesty-2237/`。这与 `AGENTS.md`「一个 worktree 同时只放一个写 agent」是同一类风险,但发生在 worktree 之外的共享 `/tmp`,现有规则没覆盖到。 Closes 无(本 PR 是 #2154 的切片 C,#2154 由主机侧统一收口,不在此自动关单)。
…rward/regenerate 按 handler fail-closed、派发器 7 处静默 break 改为一次可感知反馈 (#2154) (#2238) ## 一句话 Desktop 右键菜单里 pin/unpin/recall 是**点了没反应**(平台层有 mutation 但没转发进 workbench deps),forward/regenerate 是**渲染了但根本没有 port**;派发器 7 处 `if (!handler) break;` 让这些点击零反馈消失。本 PR:能接的接上(3 个),接不上的按 handler 存在性 fail-closed 不渲染(2 个),并把静默 `break` 全部换成一次可感知反馈。 ## 1. 锚点核实结论(主机侧 4 条,逐条复核) | # | 主机侧结论 | 复核结果 | |---|---|---| | 1 | 门禁是 `hubMessageActions: Boolean(deps.sessionId)`,判据是"有没有 sessionId"而非"handler 存不存在" | ✅ 成立,且比描述更严重:`AgentHubWorkbenchHelpers.ts:157` 把 `props.activeConversationId` **直接当 sessionId** 传下去(注释:`#1383 REST message actions: activeConversationId doubles as the session id`)。Desktop 在 Hub IM 会话下 `activeConversationId` = hub session id ⇒ 门禁恒真 ⇒ pin/unpin/recall 照渲染。改前 516-517 行注释宣称"Desktop/demo shells get an honest, shorter menu (#1818)",与事实相反(Desktop 有 session id) | | 2 | mappers 有"5 处以上" `if (!handler) break;` | ✅ 成立,精确是 **7 处**同形态静默分支(改前行号):624 regenerate(变量名 `regenerateHandler`)/ 654 approval / 667 pin / 680 unpin / 693 forward / 706 recall / 719 react。全部零反馈、零日志 | | 3 | desktop 平台层 mutation 确实存在,路径应含 `/platform/` | ✅ 路径修正成立:`app/desktop/src/platform/useDesktopWorkbenchModel.ts` 的 `DesktopChatActions` 有 `sendMessage/recallMessage/editMessage/pinMessage/unpinMessage/markRead`(479-487 行接 `useHubRecallMessage/useHubPinMessage/useHubUnpinMessage`,hook 在 `app/desktop/src/api/sessionQueries.ts`)。**但只有这 3 个能用**:desktop api 层没有 forward hook(shared `hubClient.forwardMessage` 存在,desktop 未包)、desktop 全仓 grep 不到 regenerate、shared hubClient 也没有 addReaction | | 4 | App.tsx grep 不到 `onPinMessage\|onUnpinMessage\|onRecallMessage` | ✅ 成立,**具体缺 3 个转发**:`onPinMessage` / `onUnpinMessage` / `onRecallMessage`。改前 desktop App.tsx 只转发 `onEditMessage`(674-682)与 `onApprovalDecision`。`onForwardMessage` / `onRegenerate` / `onAddMessageReaction` 同样没有,但属"平台层没有对应 mutation",不是漏转发 | 前提全部成立 ⇒ 按"优先接真 mutation + 其余 fail-closed + 派发器不再静默"执行,没有另造修法。 ## 2. 改了什么 **`app/workbench/src/workbenchTranscriptChromeActionMappers.ts`** - 菜单选项 `hubMessageActions?: boolean` → `capabilities?: TranscriptMenuActionCapabilities`(`pin/unpin/recall/forward/regenerate` 五个独立布尔,缺省全 false = fail-closed)。pin 与 unpin **分开**:条目按 `block.pinned` 二选一,只接了一个方向的 shell 不再渲染死的那一半。forward 仍需 `conversations`(选择器是唯一真实转发路径,#1385),recall 仍限 `author.role === 'human'`,regenerate 仍限 agent 文本块。 - 新增 `UNAVAILABLE_ACTION_TOAST_KEY = 'toast.actionUnavailable'` + `announceUnavailableAction()`,替掉全部 7 处静默 `break`:恰好一次 toast、绝不播报成功文案、绝不产生 softHide/pulse/composer 等假副作用。键未落地时回落到 effect 自带的 `failureMessage`(已本地化),因此既不会静默也不会露出裸键。 **`app/workbench/src/workbenchTranscriptChromeHelpers.ts`** - `contextMenuGroups` 由 handler 存在性算 capabilities:`pin/unpin/recall = Boolean(sessionId) && deps.onXxx !== undefined`(planner 没有 sessionId 造不出 effect,#1818),`forward = deps.onForwardMessage !== undefined`,`regenerate = deps.onRegenerate !== undefined`。 - 修正 `sessionId` 的 doc 注释(原文断言"Absent on Desktop/demo shells",是假的)。 **`app/desktop/src/App.tsx`** - 转发 `onPinMessage/onUnpinMessage/onRecallMessage` 到 `workbench.chatActions.{pinMessage,unpinMessage,recallMessage}`,沿用既有 `onEditMessage` 的 `hub-message-` 前缀剥离约定(契约见 `AgentHubWorkbenchTypes.ts:167`:handler 收到的是 raw block id,由 parent 剥前缀);`chatActions` 缺失(demo/Hub 未就绪)时传 `undefined`。 - forward/regenerate/reaction **不接**(desktop 无对应 mutation,且 forward hook 要改 `app/desktop/src/api/sessionQueries.ts`,不在写集)⇒ 靠 fail-closed 让条目消失。 **测试**:`workbenchTranscriptChromeActionMappers.test.ts`、`workbenchTranscriptChromeHelpers.test.ts`、新增 `app/desktop/src/__tests__/App.messageActions.test.tsx`;另有 2 个写集外夹具修正(见 §6.2)。 ## 3. 不变量 → 测试映射(全部绿) | 不变量 | 测试 | 断言方式 | |---|---|---| | handler 缺失 ⇒ 菜单不出现 pin/unpin/recall/forward/regenerate | mappers `renders handler-backed menu entries only when the capability is declared (#2154)`;helpers `omits handler-backed menu entries when no handler is wired, even with a session id (#2154)` | 有 sessionId、无 handler ⇒ 逐条 `not.toContain`;另覆盖 pin/unpin 半开、recall 作者门、forward 无会话列表 | | handler 存在 ⇒ 点击真的派发到该 handler | helpers `renders each wired action and dispatches the click to its handler (#2154)`;mappers `dispatches a declared menu entry to its action string (#2154)`;desktop `forwards the Hub pin/unpin/recall ports with the block-id prefix stripped` | 菜单项 `onClick()` → spy handler 被调用(helpers 层断言 `onPinMessage('u1','sess-1')` 等 5 个 port;desktop 层断言 `chatActions.pinMessage('m1','sess-1')`,即前缀已剥) | | 任何"无 handler"分支必须产生一次可感知反馈,不允许静默 break | mappers `announces every unwired action exactly once instead of dropping it silently (#2154)`(7 个 effect 逐个)+ `announces Hub REST side effects when handlers are not wired` + `announces approval effects when no decision handler is wired` + `prefers the dedicated unwired-action copy…` + `falls back to the effect failure copy when the dispatcher gets no translate function` | `toHaveBeenCalledTimes(1)`、不是成功文案、且 softHide/pulse/dispatchComposer 均未被调用 | | desktop 接不上的 port 保持 undefined(菜单因此不渲染) | desktop `leaves the ports Desktop cannot back undefined so the menu hides them`、`withholds every message port when Hub chat actions are unavailable` | props 断言 `onForwardMessage/onRegenerate/onAddMessageReaction === undefined`;chatActions 缺失时 4 个全 undefined | ## 4. 红 → 绿证据 **红(实现改之前,tree = `bdbf810` + 红测试,提交为 `b7b2c785`)** ``` # pnpm --filter @agenthub/workbench exec vitest run \ # src/workbenchTranscriptChromeActionMappers.test.ts src/workbenchTranscriptChromeHelpers.test.ts ❯ src/workbenchTranscriptChromeHelpers.test.ts (27 tests | 1 failed) × omits handler-backed menu entries when no handler is wired, even with a session id (#2154) ❯ src/workbenchTranscriptChromeActionMappers.test.ts (32 tests | 6 failed) × announces Hub REST side effects when handlers are not wired (#2154) × announces approval effects when no decision handler is wired (#1821, #2154) × renders handler-backed menu entries only when the capability is declared (#2154) × dispatches a declared menu entry to its action string (#2154) × announces every unwired action exactly once instead of dropping it silently (#2154) × prefers the dedicated unwired-action copy once the locale bundle resolves it (#2154) Test Files 2 failed (2) Tests 7 failed | 52 passed (59) ``` 典型红断言:`expected [ 'context.copy', …(8) ] to not include 'context.regenerate'`(有 sessionId 无 handler 时条目照样渲染);`pin: expected "vi.fn()" to be called 1 times, but got 0 times`(派发器静默)。 ``` # pnpm --filter agenthub-desktop exec vitest run src/__tests__/App.messageActions.test.tsx × forwards the Hub pin/unpin/recall ports with the block-id prefix stripped AssertionError: onPinMessage must reach the workbench deps: expected undefined to be type of 'function' Test Files 1 failed (1) Tests 1 failed | 2 passed (3) ``` **实现落地后又抓出 2 处"旧断言就是那条假事实"的连带红**(均在写集外,见 §6.2): ``` useWorkbenchTranscriptChrome.test.ts × builds context menu groups shaped for agent and user blocks AssertionError: expected false to be true (只给 sessionId、不给 handler 就断言 regenerate 条目存在) __tests__/transcript.test.tsx × opens the design card context menu and multi-select toolbar… expected […] to have a length of 6 but got 5 (无 forward port 的 shell 仍断言"转发"条目存在) ``` **绿(合并时 HEAD `321992ca`,base `c87178b3`;下表原跑于 `094ba8ae` / `d1dc97fd`,第二次 rebase 后已在 `321992ca` 上全部重跑复现,见 §7)** ``` workbench: 4 files / 92 tests passed (mappers + helpers + useWorkbenchTranscriptChrome + __tests__/transcript) desktop : 1 file / 3 tests passed (App.messageActions) web : 1 file / 10 tests passed (src/App.test.tsx,回归面:web 也吃这套门禁) ``` ## 5. 门禁表 两轮:rebase 前(HEAD `1a78581`,base `bdbf810`)与 rebase 后(HEAD `094ba8ae`,base `d1dc97fd`,已 push)。rebase 只带入 hub-server Go 改动(`git diff --stat bdbf810..d1dc97f` 全是 `hub-server/**`),FE 树 byte-identical;下表全部为 **rebase 后 HEAD `094ba8ae`** 实跑结果。**注:合并前本分支又 rebase 了一次(→ `321992ca`,base `c87178b3`),下表所有本机可跑项均已在新 HEAD 重跑并逐项复现,见 §7。** | 门禁 | 命令 | 结果 | HEAD | |---|---|---|---| | workbench 单包单测 | `pnpm --filter @agenthub/workbench exec vitest run src/workbenchTranscriptChromeActionMappers.test.ts src/workbenchTranscriptChromeHelpers.test.ts src/useWorkbenchTranscriptChrome.test.ts src/__tests__/transcript.test.tsx` | 4 files / 92 passed | `094ba8ae` | | desktop 单包单测 | `pnpm --filter agenthub-desktop exec vitest run src/__tests__/App.messageActions.test.tsx` | 1 file / 3 passed | `094ba8ae` | | web 回归面单测 | `pnpm --filter agenthub-web exec vitest run src/App.test.tsx` | 1 file / 10 passed | `094ba8ae` | | 文档 SSOT | `python3 scripts/verify/verify-doc-ssot.py` | `doc SSOT ok` | `094ba8ae` | | 空白/冲突标记 | `git diff --check origin/master...HEAD` | clean | `094ba8ae` | | i18n 硬编码棘轮 | `python3 scripts/verify/verify-i18n-callsites.py` | PASS(74 files / 597 行 ≤ baseline 78/608) | `094ba8ae` | | 前端包边界 | `python3 scripts/verify/verify-frontend-package-boundary.py` | PASS | `094ba8ae` | | workbench 类型 | `pnpm --filter @agenthub/workbench exec tsc --noEmit` | 0 error | `094ba8ae` | | desktop 类型(app) | `pnpm --filter agenthub-desktop exec tsc --noEmit -p tsconfig.app.json` | 0 error | `094ba8ae` | | desktop 类型(含测试) | `pnpm --filter agenthub-desktop exec tsc --noEmit -p tsconfig.json` | 0 error | `094ba8ae` | | eslint(仅改动文件) | `pnpm exec eslint <8 个改动文件>` | 2 problems,**均 pre-existing**(见 §6.9) | `094ba8ae` | 按指令**未跑**:全量 vitest、coverage、`pnpm -r build`、全量 `tsc`(CI 是权威)。命令坑记录:desktop 包名是 `agenthub-desktop` 不是 `@agenthub/desktop`;`pnpm --filter X vitest run` 会 `ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT`,必须 `exec vitest run`。 ## 6. 证据等级 / 未验证项 / 可能错的地方 **证据等级** - **L1(jsdom 单测,真实断言)**:菜单条目按 handler 存在性渲染、点击派发到 spy handler、7 个无 handler 分支各产生恰好一次 toast 且无假副作用、desktop App 把 3 个 port 转发到 `chatActions` 且剥掉 `hub-message-` 前缀。 - **L2(静态)**:workbench/desktop(含测试)tsc 0 error、eslint 无新增问题、4 个 verify 脚本 PASS。 - **L3(真实端到端)=无**:没起 Tauri/真实 Hub,没有真人点过菜单,没有真实 REST 往返证据。 **未验证 / 可能错** 1. **缺 i18n 键(只登记未改)**:`toast.actionUnavailable`(zh 建议"该操作在当前端未接入",en "This action is not wired in this client")。资源面 `app/shared/src/chatview/i18n/resources.ts` 不在写集 ⇒ 未加。当前行为:键缺失时回落到该 effect 的 `failureMessage`(如"置顶失败,请重试")——**不静默、不假成功,但"请重试"语义不准**(该端永远不会成功)。键一落地自动切换到专用文案,无需再改代码。 2. **写集外改了 2 个测试文件(各 1 处,已独立成 commit,可直接 drop)**:`94972dec` `useWorkbenchTranscriptChrome.test.ts`(夹具补 `onRegenerate/onRecallMessage` 两行,断言一字未改)、`79ecae30` `__tests__/transcript.test.tsx`(菜单条目数 6→5 + "转发"改断言不存在)。理由:这两处旧断言正是本 PR 要消灭的假事实("有 sessionId 就有 handler 条目"/"有 conversations 就有转发条目"),不改则 CI 必红。若主机侧判定越界,请 drop 这两个 commit 并由写集内 lane 重做。 3. **`AgentHubWorkbenchTypes.ts:165-170` 的 doc 注释现在是假的**(仍写"Desktop/demo shells omit them and pin/unpin/recall/react stay hidden (#1818)")。该文件不在写集 ⇒ 未改,登记为后续 1 行注释修正。 4. **desktop forward 未接**:真接需要 `app/desktop/src/api/sessionQueries.ts` 新增 `useHubForwardMessage`(shared `hubClient.forwardMessage` 已有)+ `DesktopChatActions` 扩字段 + App.tsx 转发。api 层不在写集 ⇒ 未做,改为不渲染条目。 5. **desktop regenerate 未接(且我故意没接)**:web 的做法是 App.tsx 直接 `createHubClient(...).regenerateAgentTask(messageId)`,desktop 技术上可照抄,但**我没有验证 desktop 的 Hub 任务语义下 regenerateAgentTask 是否正确**(desktop 另有 DesktopHubTaskBridge/agent task 路径),所以选择 fail-closed 不渲染而不是接一个语义未证的 port。 6. **coverage 未跑**:新增生产分支(`announceUnavailableAction` 的 t 有/无两路、`capabilities ?? {}` 默认值、5 个 capability 计算)都有对应用例,但包级阈值是否被拉低只有 CI 能判。 7. **只跑了 6 个测试文件**,不是全量。其余 FE 测试里是否还有别处断言"有 conversations 就渲染转发",我用 label grep(`context.forward|context.pinMessage|context.unpin|context.recall|context.regenerate|转发|置顶|撤回|重新生成`)扫过 app/{workbench,web,desktop,shared} 与 e2e,认为没有第二处,但 grep 不是权威。 8. **可见 UX 变化(需产品确认,不是 bug)**:desktop 的"重新生成""转发"条目消失;web 在 `chatActions` 缺失(Hub 未就绪/demo)时"转发/置顶/撤回"也消失。这是 fail-closed 的直接后果——消失的正是原先点了没反应的条目。 9. **eslint 2 个 pre-existing 问题(非本 PR 引入,已用 origin/master blob 探针证明)**:`workbenchTranscriptChromeActionMappers.ts:5` `'AppError' is defined but never used`(error)、`desktop/src/App.tsx:260` `useMemo missing dependency: 'tIm'`(warning)。探针做法:`git show origin/master:<file> >` 临时同目录文件再 eslint,结果与改动后一致;临时文件已删,`git status` 干净。未顺手修(与本 lane 无关,且可能有棘轮基线归属)。 10. **多 lane 环境说明**:本机在 `.worktrees/fe-ctx-menu` 单写者作业;worktree 的 `app/node_modules` 是软链到主 checkout、各包 `node_modules` 是 `cp -a` 复制(内部 `@agenthub/*` 为相对符号链接,已核实指向 worktree 自己的 workbench/shared,跨包测试确实跑的是本分支代码)。未动其他 worktree,未合并任何分支。 ## 7. 剩余 blocker(需主机侧决策,非技术阻塞) 1. §6.2 两个写集外 test commit:接受 or drop 重做。 2. `toast.actionUnavailable` 键由谁落(i18n 资源面 lane)。 3. desktop forward / regenerate 是否另开 lane(§6.4、§6.5)。 4. §6.3 的 1 行注释修正归谁。 ## 7. 主机侧合并前订正与复跑(第二次 rebase) 正文写的是 HEAD `094ba8ae` / base `d1dc97fd`。实况:master 又前进了两个 commit(`530b4d99`→`94aef98a`→`c87178b3`,即 #2239 / #2240 / #2237),本仓 `required_status_checks.strict: true` 使 PR 转 `BEHIND` 而**阻塞合并**,故已 rebase 到 `c87178b3`,HEAD 现为 **`321992ca`**。 **写集零变化**:`git diff --stat 094ba8a 321992c -- app/desktop app/workbench` 输出为**空**;两者全量 diff 只含 master 自身的 `app/pnpm-lock.yaml`+`app/pnpm-workspace.yaml`(#2240)、`edge-server/internal/events/**`(#2234/#2239)、`docs/**`+`AGENTS.md`+`scripts/verify/quality-debt-baseline.json`(#2237),与本 lane 8 个文件零重叠;6 个 commit subject 逐条一一对应。 **在新 HEAD `321992ca` 上重跑的门禁(逐项复现正文数值)**: | 门禁 | 结果(`321992ca` 实跑) | 与正文声明 | |---|---|---| | workbench 4 个测试文件 | 4 files / **92 passed** | 一致 | | desktop `App.messageActions` | 1 file / **3 passed** | 一致 | | web 回归面 `src/App.test.tsx` | 1 file / **10 passed** | 一致 | | `verify-doc-ssot.py` | `doc SSOT ok`(66 script paths / 58 CI files、96 AGENTS paths) | 一致 | | `git diff --check origin/master...HEAD` | clean | 一致 | | `verify-i18n-callsites.py` | PASS:current **74 files / 597** ≤ baseline 78/608 | 一致 | | `verify-frontend-package-boundary.py` | PASS(376 shared + 421 workbench,0 违规) | 一致 | | workbench `tsc --noEmit` | **0 error** | 一致 | | desktop `tsc -p tsconfig.app.json` | **0 error** | 一致 | | desktop `tsc -p tsconfig.json`(含测试) | **0 error** | 一致 | | eslint(8 个改动文件) | **2 problems(1 error + 1 warning)** | 一致 | **并把正文§6.9 那句「2 problems 均 pre-existing」独立验证过**(不只采信):master 版 `workbenchTranscriptChromeActionMappers.ts` 第 5 行同样有 `import { AppError } from '@shared/errors';`,且 `AppError` 在 master 版与本 PR 版的出现次数**都是 1**(即只有 import 行、无使用点)→ 该 error 非本 PR 引入;`desktop/src/App.tsx` 的 `tIm` useMemo 缺依赖在 master 的 **259 行**即已存在 → 同样 pre-existing。 **源码级复核修复本体**:`hubMessageActions` 在生产代码中**已彻底消失**(全仓只剩 `workbenchTranscriptChromeHelpers.test.ts:1095` 一条注释在记录旧行为);`workbenchTranscriptChromeHelpers.ts:515-531` 改为逐 action 的 `capabilities{pin,unpin,recall,forward,regenerate}`,其中 pin/unpin/recall = `Boolean(deps.sessionId) && deps.onXxx !== undefined`,forward/regenerate 只看 handler 存在性;`if (!handler) break` 形态在 mappers 中**归零**,`announceUnavailableAction` 定义于 `:556` 并恰好有 **7 个调用点**(`:625/:660/:693/:709/:725/:741/:757`),与报告所述 7 处静默分支一一对应;`desktop/src/App.tsx:693/703/713` 确实把 `onPinMessage/onUnpinMessage/onRecallMessage` 接进 deps。 **CI run(HEAD `321992ca`)**:**全绿** —— 25 successful / 16 skipped / **0 failing / 0 pending**,其中此前唯一的红 `Vuln scan (pnpm audit prod+full)` 现为 **pass(33s)**:它当初失败的原因是 base 早于 #2240(`app/pnpm-lock.yaml` 仍锁 xmldom 0.8.13/0.9.10 + fast-uri 3.1.5),rebase 带入 #2240 的 override 后自动解除,**本 PR 未为过门禁改任何依赖或例外登记**。`mergeStateStatus: CLEAN`。
这是什么
三条同包(
edge-server/internal/events/)正确性缺陷,都是 #2234(seq 顺序不变量)落地后由主机侧复审与 lane 移交清单挖出的。关联:#2154门禁汇总
go build ./.../go vet ./.../ gofmtgo test ./internal/events/ -count=1go test ./internal/events/ -race -count=3golangci-lint run ./internal/events/...verify-edge-lint-ratchet.pygosec | verify-gosec-gates.shverify-doc-ssot/verify-conventions/git diff --check证据等级:L0(单测 +
-race+ 静态门禁)。未做真实:3210+ SSE/WS 客户端的端到端观测,也未量化闸门 head-of-line 的 p99 尾延迟。