Skip to content

fix(edge): events 正确性批 2——闸门遗弃轮次永久卡死总线 / 截断短读丢最新事件 / 确定性 persist 失败仍烧退避梯 - #2239

Merged
DeliciousBuding merged 2 commits into
masterfrom
fix/edge-events-correctness-batch2
Sep 2, 2026
Merged

fix(edge): events 正确性批 2——闸门遗弃轮次永久卡死总线 / 截断短读丢最新事件 / 确定性 persist 失败仍烧退避梯#2239
DeliciousBuding merged 2 commits into
masterfrom
fix/edge-events-correctness-batch2

Conversation

@DeliciousBuding

Copy link
Copy Markdown
Collaborator

这是什么

三条同包(edge-server/internal/events/)正确性缺陷,都是 #2234(seq 顺序不变量)落地后由主机侧复审与 lane 移交清单挖出的。关联:#2154

fix(edge): events 正确性批 2——闸门遗弃轮次会永久卡死总线 / 截断短读丢掉最新事件 / 确定性 persist 失败仍烧退避梯 (#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 尾延迟。

门禁汇总

门禁 结果
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 | verify-gosec-gates.sh CLEAN
verify-doc-ssot / verify-conventions / git diff --check rc=0 / 干净

证据等级:L0(单测 + -race + 静态门禁)。未做真实 :3210 + SSE/WS 客户端的端到端观测,也未量化闸门 head-of-line 的 p99 尾延迟。

…仍烧退避梯 (#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>
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 83bd1efb-01b5-4407-9159-fe4f93657a7b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…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>
@DeliciousBuding

Copy link
Copy Markdown
Collaborator Author

追加:首轮 CI 的 Native Windows Go (edge-server) 红已定位并修复(commit 40bb5708),而且它揭出一个先于本 PR 的已发布平台缺陷

失败点不是本 PR 的新逻辑,而是新断言显形了一个既有缺陷:

ERROR event log truncate Truncate(0) failed path=...\truncate-replay.jsonl
      error="truncate ...: Access is denied."
--- FAIL: TestTruncateLocked_KeepsNewestEventsEndToEnd
    log grew to 45692 bytes with maxSize 4096
  • 报错同时出现在既有测试 TestEventLogIndexSurvivesTruncation 的日志里 → 缺陷先于本 PR。
  • 根因:NewEventLogO_APPEND|O_CREATE|O_RDWR 打开日志(eventlog.go:102),Windows 对 append 模式句柄拒绝 SetEndOfFile,于是 l.f.Truncate(0) 每次都失败;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)。没有O_APPEND——它给的是并发写入者只追加的保证,为截断牺牲它不划算。代价是每次截断多一次 open/close(约每 25k 事件一次)。

测试也强化了(跨平台,不加 skip):原来只查 MaxSeq/ReadFrom,Windows 上「截断失败但索引还在」能蒙混过关;现在同时断言 fi.Size() <= maxSize log.truncateFailures == 0,即"截断必须成功,不是尝试过"。

诚实边界:Windows 侧结论只能由 CI 的 Native Windows Go (edge-server) 证明,本机无 Windows,不冒充已验证。Linux 侧本 HEAD 重跑:go test ./... 34 包 ok、-race ok 6.7s、golangci-lint ./internal/events/... 0 issues、ratchet PASS(9 全基线)、git diff --check 干净。

@DeliciousBuding
DeliciousBuding merged commit 530b4d9 into master Sep 2, 2026
39 checks passed
@DeliciousBuding
DeliciousBuding deleted the fix/edge-events-correctness-batch2 branch September 2, 2026 18:04
DeliciousBuding added a commit that referenced this pull request Sep 2, 2026
…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 由主机侧统一收口,不在此自动关单)。
DeliciousBuding added a commit that referenced this pull request Sep 2, 2026
…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`。
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.

1 participant