fix(edge): EventLog orderedSeq 有序性 + Bus fanout wire order——并发 Publish 使文件行序与上线序都≠seq 序,重启重签 seq / replay 静默丢事件 / 客户端 seq<=lastSeq 永久丢弃 (#2154) - #2234
Merged
Conversation
|
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 |
…lay 静默丢事件 / gap 边界误报 (#2154) 本提交为红,修复见下一提交。 可达性(主机侧实测,非推测):EventLog.Append 是 Bus.persistFn 的唯一实现,而 Publish 先 atomic.AddInt64 领 seq、之后在不持任何锁的情况下调用 persistFn, 所以并发发布落盘的行序与 seq 序无关。512 次并发 publish 实测:6/6 个日志文件 乱序、每文件 46-56 处相邻逆序;同形状 60 轮扫描中 10 轮「末行不是最大 seq」、 20 轮「首行不是最小 seq」。生产侧 46 处 Publish 调用点分布在 HTTP handler、 run 生命周期 goroutine、MCP handler,并发是常态。 rebuildIndexLocked 按文件出现顺序 append 且全程不排序,于是三个消费点全错: 1. bus.go 拿 orderedSeq[len-1] 当「当前 seq」续签 → 重启后重签盘上已有的 seq (红证据:末行 seq=6、盘上 max=13,重启后连发 7/8/9/10 全是重复 seq)。 2. ReadFrom 拿 orderedSeq[0] 当最旧 seq → 对日志仍然覆盖的 cursor 误报 gap, 逼订阅端丢弃自身状态全量 resync(红证据:首行 seq=8、真实 min=3, cursor=3..7 全部误报 hasGap=true)。 3. ReadFrom 二分定位后 seek 到该 seq 的偏移再读到 EOF → 位于更早行的更大 seq 被静默丢弃且无 gap 通知,订阅端无声分叉(红证据:cursor=12 只回 [13], 应为 [12 13];并发文件上单个 cursor 最多丢 104 个事件)。 顺带引入 EventLog.MaxSeq() 访问器,替掉 bus.go 对 orderedSeq 的裸字段读 (跨结构体且未持 l.mu);b.seq 的种子写改 atomic.StoreInt64,与 Publish 里的 atomic.AddInt64 保持一致。本提交中 MaxSeq 仍返回末元素、orderedSeq 仍未排序, 故上面三条全部为红。 红证据(cd edge-server && go test ./internal/events/ -count=1):仅新增 5 个 测试失败,既有测试全绿。 - TestConcurrentPublishWritesSeqUnorderedLogFile(可达性证明 + 三条不变量) - TestRestartAfterSeqUnorderedLogDoesNotReissueSeq - TestReadFromIsCompleteOnSeqUnorderedLog - TestReadFromGapBoundaryUsesTrueMinSeqOnSeqUnorderedLog - TestOrderedSeqInvariantHoldsAcrossMutationPaths TestSubscribeReplayIsCompleteOnSeqUnorderedLog 修前修后均绿(sort.Search 在 该 fixture 上恰好命中 seq=12),作为订阅端可见契约的钉子保留,不计入红证据。 Co-authored-by: Cursor <cursor@vectorcontrol.tech>
…最小偏移,并把假注释订正为显式契约 (#2154) 绿。修上一提交钉住的三条红。 1. rebuildIndexLocked 在扫描完成后对收集到的 seq 排序,再赋给 orderedSeq。 Append 路径本来就用 appendSorted 维护有序,只有重启/truncate 这条重建路径 漏了;补上后「orderedSeq 升序」在全部三条变更路径(open 重建、Append、 truncateLocked 后重建)上成立,MaxSeq / orderedSeq[0] / sort.Search 三个 消费点随之恢复正确。用 sort.Slice 而非 slices.Sort:重建本身要做全文件扫描 + 逐行 json.Unmarshal(默认 maxSize 下 37.5 MiB 量级),排序开销是噪声, 且不引入新 import、与本文件既有风格一致。 2. 光排序还不够。ReadFrom 原来是「二分定位第一个 seq >= cursor → seek 到它的 字节偏移 → 读到 EOF → 按 seq 过滤」,这套只在「文件行序 == seq 序」时成立。 文件既然是 append 序,更大的 seq 完全可能落在更早的行上,seek 过去就再也 读不到它 —— 这是静默丢事件,不是报错。改为抽出 replayStartOffsetLocked(startSeqIdx):取 orderedSeq 后缀上 index 偏移的最小 值作为起点。后面的 seq >= cursor 过滤会把多读进来的早期行剔掉,所以结果是 精确的而不只是安全的。代价 O(k) 次 map 查找(k = 本次要回放的条数,也就是 紧接着要 json.Unmarshal 的那 k 行),相对 I/O 可忽略;文件恰好有序时最小值 就是 orderedSeq[startSeqIdx] 本身,起点与改前逐字节一致。index 缺键会读成 偏移 0,把读放大到全文件 —— 过读安全,欠读不安全。 抽成独立函数同时把 ReadFrom 的圈复杂度从 22 降回 20(gocyclo 阈值)。 3. 注释订正(任务要求「别留假声明」): - orderedSeq 原注释「sorted seqs parallel to index」是错的:index 是 map, 两个结构不存在 positional parallel 关系。改为「index 的去重键集,升序」, 并补一段显式不变量(升序 + 恰为 index 键集 + len 相等 + 谁依赖它)。 - index 原注释尾巴「(exclusive of the seq→offset map)」是无意义残留,删。 - EventLog 类型注释补 ORDERING CONTRACT:文件是 append 序而非 seq 序, 附上实测数字与产生原因,免得后人再把「文件有序」当前提优化回去。 - ReadFrom 末尾那句「the file is append-order which should already be seq-ordered」是本次 bug 的错误前提本身,改成说明为什么这个 sort 不是 可有可无的兜底。 - rebuildIndexLocked 注释补上「为什么必须排序」以及不排序时三个消费点各 自怎么坏。 门禁(HEAD=本提交,4 核机、只跑本包): - go build ./... ok;go vet ./internal/events/ ok - go test ./internal/events/ -count=1 ok(含 -race 单包一次,3.5s,无告警) - golangci-lint run ./internal/events/... → 0 issues - scripts/verify/verify-edge-lint-ratchet.py → PASS(9 findings 全在 baseline, 本 PR 未新增指纹) - scripts/verify/verify-doc-ssot.py → ok;git diff --check → clean - gosec CI 形态(-fmt=json ./... | verify-gosec-gates.sh)→ clean 0 issues Co-authored-by: Cursor <cursor@vectorcontrol.tech>
…会把乱序事件当重复永久丢弃 (#2154) 本提交为红,修复见下一提交。 上游只修了磁盘侧(orderedSeq 排序),内存侧的产生器还在:Publish 在 bus.go:157 用锁外 atomic.AddInt64 领 seq,接着 :178 调 persistFn 做文件写(失败时还有 2+4+8ms 退避),直到 :188 才 b.mu.Lock() 做 fanout。领号与推送之间整段可抢占, 于是 wire order ≠ seq order。 下游危害有客户端实证,不是推断:app/shared/src/eventClient.ts:202 是 `if (envelope.seq <= lastSeq) return;` —— 乱序到达的 envelope 被当成 replay 重复**永久丢弃且不触发 system.gap**,客户端静默丢一次状态变更;这直接违背 api/events.md:67 声明的「`seq` 同 stream 单调」。同仓 hub 侧的正确写法在 hub-server/internal/ws/fanout.go:92-101:seq stamp 在 sendMu 临界区内,其注释 明写「queue order — and therefore wire order — always equals seq order」。 红证据(bus.go 停在上一提交,cd edge-server && go test ./internal/events/ -count=1): - TestFanoutWireOrderMatchesSeqOrderWithEventLog:750 条中 64 处 inversion (pos 10 收到 seq 2,此前已收到 12) - TestFanoutWireOrderMatchesSeqOrderWithoutEventLog:750 条中 43 处 inversion —— 说明窗口不只是磁盘 I/O,genID 的 crypto/rand + RFC3339 时间戳本身就够调度器翻序 - TestSubscribeZeroCursorReplayIsSeqOrdered:Subscribe(cursor=0) 回放 750 条中 30 处 inversion。b.history 按 fanout 序追加,而 Subscribe 只在「配了 event log 且 cursor>0」时才走 mergeReplayWithLog 排序 —— 首连全量回放(cursor=0)和 无 event log 的 bus 因此拿到未排序回放 - TestPublishAndSubscribeWithCursorDoNotDeadlock:锁序 canary,红态即绿(30s watchdog) 夹具本身踩过一个坑并已在注释里写明:第一版先 publish 完再 drain,256 槽的 subscriber channel 直接灌满,1000 条只收到 256 条 —— 那测的是丢包不是乱序。 改为并发 drain,并按 GOMAXPROCS-1 选 publisher 数(给 drainer 留一个 P), 断言拆成「严格递增」无条件 + 「无空洞」仅在零丢包时生效,避免负载高时假红。 同时提交 bus_publish_order_bench_test.go:把本次取舍用到的每个数字做成可复现 基准(三方案吐吐对照表、persist 退避阶梯、truncation 随 maxSize 的线性成本 ~16.7ms/MiB)。benchmark 不在 CI 跑,零成本。 Co-authored-by: Cursor <cursor@vectorcontrol.tech>
…决「把 persist 塞进 b.mu」方案(truncation ~835ms 会冻总条线);replayFromHistory 无条件排序 (#2154) 绿。修上一提交钉住的三条红。 做法:seq 仍在 persist 之前分配(persist-before-broadcast 要求落盘记录带 seq), 但 fanout 前加一道门 —— b.wireNext 表示「轮到谁上线」,b.wireCond 通知推进。 publisher 持久化完成后在 deliverInSeqOrder 里等到自己那一轮才 append history + 推 channel,然后 wireNext=seq+1 并 Broadcast。persist 失败的事件同样过门消费掉 自己的 seq(不投递但推进),否则会把后面所有人永久卡死;留下的空洞就是客户端 的丢失信号,与 hub fanout.go「dropped frames consume a seq too」一致。 门的推进放在 defer 里:panic 或 runtime.Goexit 也必须交出轮次,因为一个已分配 却永不消费的 seq 会把整条 wire 焊死。 wireNext==0 作为「未设基线」哨兵,基线由**第一个被分配的 seq**在 b.mu 内设定 (不能由第一个到达门的 seq 设定 —— 它不一定最小,会把更小的 seq 放到它后面 上线,反而制造 inversion)。这样重启后 seq 被 MaxSeq 播种、或测试里直接写 bus.seq(bus_recover_test.go:54)都不会把门卡住。 === 被否决的方案 A:seq stamp + persistFn + fanout 全塞进一个 b.mu 临界区 === 先实测了 A。happy path 它甚至更快(NoLog/w4 553→492、w8 709→452 ns/op,因为 争用的 atomic.AddInt64 共享 cache line 消失进了本来就要拿的 mutex)。否决理由是 它塞进锁里的东西: 1. EventLog truncation 成本随 maxSize 线性,实测 ~16.7ms/MiB: 1MiB→17.2ms / 2→34.4 / 4→65.4 / 8→130.5 / 16→267.1。 外推到 50MiB 默认值 ≈ **835ms 一次**,频率约每 maxSize/4=12.5MiB 写入一次 (~500B/条 → 约每 25k 事件一次)。A 方案下这 835ms 持有 b.mu:Subscribe / Unsubscribe / AddObserver / HistoryLen(Prometheus 抓取)全线冻结,256 槽 subscriber channel 溢出 → system.gap 风暴 → 所有客户端全量 resync。 (更正:我先前一度量到「1.8ms」,那是 maxSize=1 的退化情形 —— keepBytes=0 会把日志清空、只测了空文件重建。真实数字是上面的线性表。) 2. persistWithRetry 退避阶梯(2+4+8ms)实测 14.5ms/条。4 个并发全失败 publish 在锁外是 14.6ms 总耗时(并行),塞进 b.mu 后是 58.0ms(串行 14.5ms each)。 且退避对**确定性错误也照睡**(persist.go:41-55 不区分错误类型),所以调用方 传个含 chan/func 的 payload 让 json.Marshal 必然失败,就能每次冻总线 14.5ms。 死锁风险两个方案都查过:Publish 走 b.mu→EventLog.mu,Subscribe 在 ReadFrom 里 拿 EventLog.mu 并**在取 b.mu 之前释放**(bus.go:325 那段 F15 注释顺带保证了这 一点),不构成 AB-BA。TestPublishAndSubscribeWithCursorDoNotDeadlock 做 canary。 === 取舍数字(K = 本方案;ns/op,越低越好;ARM64 4C,GOMAXPROCS=4,中位数/3 轮)=== baseline(racy) A(否决) K(本提交) NoLog/w1 1372 1388 1397 +2% NoLog/w4 553 492 2437 4.4x 慢 NoLog/w8 709 452 3512 5.0x 慢 WithLog/w1 7880 8036 8145 +3% WithLog/w4 8962 9156 10417 +16% WithLog/w8 9230 9256 11087 +20% K 的代价是门上的 head-of-line blocking(争用时每条事件一次 cond park/unpark)。 NoLog/w4、w8 的 4.4x/5.0x 就是这个;但绝对值仍是 ~410k / ~285k events/s,比 edge 实际负载高几个数量级。生产形态(配了 event log,~8µs 的 append 本来就主导且本来 就串行)只贵 16-20%;单 publisher(单 run 活跃,最常见)+2~3% 基本无感。 退化路径回到 baseline:4 个并发全失败 publish 重新并行,~15.7ms 总耗时。 本质结论:persist-before-broadcast + wire 单调 两个契约同时成立时,「慢 persist 阻塞其后事件的投递」是无法消除的 —— 要么乱序上线(客户端静默丢),要么延迟上线。 本提交选延迟。唯一能同时拿掉延迟的办法是像 hub 那样把 seq stamp 放到投递临界区 内,但那要求 persist 不带 seq 或改到 broadcast 之后,属于产品契约变更。 未采纳的可选后续(写在 bench 文件注释里):没配 persister 时 seq stamp 与 fanout 之间本来就没有任何东西,可以跳过门、直接在投递临界区里 stamp(即 hub 写法), 把 NoLog 的数字拿回来。代价是给总线最热的函数加第二条排序路径,而收益落在一个 已经远超真实负载的量级里 —— 留给决策。 另修 replayFromHistory:无条件 sort.Slice。b.history 现在按门序(=seq 序)追加, 所以通常是 O(n) 的已排序快路径;但 Subscribe 只在「有 event log 且 cursor>0」时 才经 mergeReplayWithLog 排序,cursor=0 的首连全量回放和无 event log 的 bus 之前 拿到的是 history 原始顺序。排序后契约不再依赖 history 是怎么被填进去的。 门禁(HEAD=本提交):go build ./... / go vet ./internal/events/ / go test ./internal/events/ -count=1 / 同包 -race 一次(6.3s,无告警)/ golangci-lint run ./internal/events/...(0 issues)/ verify-doc-ssot.py / git diff --check / gosec CI 形态(clean 0)全绿;全量棘轮见 PR。 Co-authored-by: Cursor <cursor@vectorcontrol.tech>
DeliciousBuding
force-pushed
the
fix/edge-eventlog-seq-order
branch
from
September 2, 2026 16:51
8474400 to
967210e
Compare
DeliciousBuding
added a commit
that referenced
this pull request
Sep 2, 2026
…退避梯 / Windows O_APPEND 句柄无法截断 (#2154)(#2239) * 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 尾延迟。 Co-authored-by: Cursor <cursor@vectorcontrol.tech> * fix(edge): 事件日志在 Windows 上永远无法截断——O_APPEND 句柄被拒 SetEndOfFile,改用独立 O_RDWR 句柄重写 (#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> --------- Co-authored-by: DeliciousBuding <DeliciousBuding@users.noreply.github.com> Co-authored-by: Cursor <cursor@vectorcontrol.tech>
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`。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
摘要
一个根因,两侧损害。
Bus.Publish用锁外atomic.AddInt64领 seq,随后在不持任何锁的情况下做持久化,最后才b.mu.Lock()做 fanout / 重建索引。于是「seq 顺序」与「实际发生顺序」在两个地方分叉:rebuildIndexLocked按文件行序 append 且不排序,而文件行序 = append 序 ≠ seq 序ReadFrom二分后 seek 到该 seq 偏移再读到 EOF → 更早行上的更大 seq 被静默丢弃(实测单 cursor 最多丢 104 个);③orderedSeq[0]不是最小 seq → 对日志仍覆盖的 cursor 误报 gap,逼客户端全量 resyncsort;replay 起点改取后缀最小偏移(replayStartOffsetLocked);新增MaxSeq()app/shared/src/eventClient.ts:202是if (envelope.seq <= lastSeq) return;—— 乱序到达的 envelope 被当成 replay 重复永久丢弃且不触发system.gap,违背api/events.md:67「seq同 stream 单调」;另Subscribe(0)首连全量回放本来就返回未排序的 history 序wireNext/wireCond顺序门;replayFromHistory无条件排序两侧修复互补,缺一不可:顺序门让 fanout 有序,但 persist 仍在
b.mu之外(这是刻意的,见下),所以落盘文件依然是乱序的(修复后实测仍 6/6 个文件乱序、245–300 处 inversion),磁盘侧的排序与最小偏移修复因此是载荷性的,不是 defense-in-depth。被否决的方案(含实测数字)
把 seq stamp +
persistFn+ fanout 全塞进一个b.mu临界区(hubfanout.go的写法直接照搬)在 happy path 上甚至更快,但会把两笔大开销关进总线锁:b.mu会冻结Subscribe/Unsubscribe/AddObserver/HistoryLen(Prometheus 抓取),并让 256 槽 subscriber channel 溢出成 gap 风暴。persistWithRetry退避阶梯实测 14.5 ms/条,且对确定性错误也照睡(persist.go:41-55不区分错误类型)——调用方传个含chan/func的 payload 让json.Marshal必然失败,就能每次冻总线 14.5ms。4 个并发全失败 publish:锁外 14.6ms 总耗时(并行)vs 锁内 58.0ms(串行)。所以改用顺序门:persist 留在
b.mu外(慢 persist 的影响范围与今天完全一致——publisher 们在EventLog.mu上串行),只把「上线顺序」按 seq 排队。代价是门上的 head-of-line blocking,数字见下方「取舍」。关联 issue
属于 #2154(Euclid 并发探索批)lane B / P1。不 close #2154——它是批次伞 issue。
可达性结论与依据
结论:两侧均可达,均已实测复现,不是 latent。 证据等级 A。
产生器只有一个,且是常态路径:
Bus.Publish用atomic.AddInt64(&b.seq, 1)领号(原bus.go:151),随后在不持任何锁的情况下调persistFn(原bus.go:172-180),而WithEventLogPath把persistFn绑成EventLog.Append(persist.go:87-89),直到原bus.go:182才b.mu.Lock()。领号与「落盘 / 上线」之间没有跨两者的锁:Append自己的l.mu只保证单行写入原子,不保证顺序。生产侧 46 处Publish调用点分布在 HTTP handler、run 生命周期 goroutine、MCP handler(api/handlers_*.go、lifecycle/process_executor_publish.go、mcp/tools_handlers.go),并发是常态。实测数字(Kunpeng ARM64 4C8G,go1.26.5,GOMAXPROCS=4)
ReadFrom完整性Subscribe(0)返回的 1000 条中 inversion 数已排除的候选路径
truncateLocked保留窗口内相对顺序不变;随后的rebuildIndexLocked把已有乱序固化进orderedSeqjson.Unmarshal失败被跳过;重试产生的重复行被if _, exists := index[env.Seq]去重bus.go:136-137httpserver/server.go:228只构造一个 Bus,无此部署形态证据;修复不依赖产生器身份git log -- eventlog.go仅 4 次改动,写入形状一直是「一行一 JSON、按 Append 顺序」红 → 绿证据
红测试不 mock 任何内部字段。磁盘侧确定性用例的乱序文件只用导出方法
EventLog.Append(即persistFn实际调用的那个)按[8 3 12 5 11 4 13 6]写出——首行 8 不是最小(min=3)、末行 6 不是最大(max=13),正是并发 Publish 被实测产生的形状。内存侧断言对象是订阅端 channel 实际收到的 envelope 序列,文件侧断言对象是从磁盘读回的行序(测试自己按行json.Unmarshal)。磁盘侧红(commit
b54ad64)内存侧红(commit
4001838a,bus.go 停在磁盘侧修复)「不配 event log 也乱序」这条很重要:说明窗口不只是磁盘 I/O,
genID的 crypto/rand 读 + RFC3339 时间戳本身就够调度器翻序。绿(HEAD
8474400b)go test ./internal/events/ -count=1→ok 1.539s;-count=3全绿;同包-race一次全绿(5.64s,无 DATA RACE)。新增 11 个测试(磁盘侧 7 + 内存侧 4)全 PASS,既有测试全 PASS。如实说明两点,不计入红证据:
TestSubscribeReplayIsCompleteOnSeqUnorderedLog修前修后均绿——sort.Search在该 fixture 上恰好命中 seq=12,起点偏移正好够用。保留为订阅端可见契约的钉子。TestPublishAndSubscribeWithCursorDoNotDeadlock是锁序 canary,红态即绿(它防的是修复引入 AB-BA,不是复现已有 bug)。夹具自身踩过的坑(已写进注释):内存侧第一版先 publish 完再 drain,256 槽 subscriber channel 灌满,1000 条只收到 256 条——测的是丢包不是乱序。改为并发 drain,publisher 数按
GOMAXPROCS-1选(给 drainer 留一个 P),断言拆成「严格递增」无条件 +「无空洞」仅零丢包时生效,避免高负载假红。磁盘侧第一版把 truncation 成本量成 1.8ms,那是maxSize=1的退化情形(keepBytes=0会清空日志、只测了空文件重建),真实数字是上面的线性表——该错误数字从未进入本 PR 或 #2154 的任何已发布文本,仅存在于一个被丢弃的本地原型注释里。取舍:顺序门的成本(实测)
ns/op = 每条事件的墙钟时间,越低越好。
-benchtime 20000x -count=3取中位数。b.mu(否决)atomic.AddInt64共享 cache line 消失进了本来就要拿的 mutex。但这不是决策依据,决策依据是它关进锁里的 truncation(~835ms)与退避阶梯(14.5ms)。8µs 的 append 本来就主导且本来就串行)只贵 16–20%;**单 publisher(单 run 活跃,最常见)+23% 基本无感**。本质结论:
persist-before-broadcast+wire 单调两个契约同时成立时,「慢 persist 阻塞其后事件的投递」无法消除——要么乱序上线(客户端静默丢状态),要么延迟上线。本 PR 选延迟。唯一能同时拿掉延迟的办法是像 hub 那样把 seq stamp 放进投递临界区内,但那要求 persist 不带 seq 或挪到 broadcast 之后,属于产品契约变更,不在本 lane 决定。全部数字由
bus_publish_order_bench_test.go复现(benchmark 不在 CI 跑,零成本):改了什么
edge-server/internal/events/共 5 files, +1183/−35:eventlog.go(+94 行变更)、bus.go(+167 行变更)、新增eventlog_seqorder_test.go、bus_wireorder_test.go、bus_publish_order_bench_test.go。未动 hub、未动前端、未动contracts/与openapi(eventClient.ts与api/events.md只作为证据引用,一行未改)。磁盘侧(
eventlog.go)rebuildIndexLocked补排序:扫描后sort.Slice(ordered, ...)再赋给l.orderedSeq。Append路径本来就用appendSorted维护有序,只有重建这条漏了;补上后「orderedSeq升序」在全部三条变更路径(open 重建、Append、truncateLocked后重建)成立。用sort.Slice而非slices.Sort:重建本身要做全文件扫描 + 逐行json.Unmarshal(默认 maxSize 下 37.5 MiB 量级),排序是噪声,且不引入新 import。replayStartOffsetLocked(startSeqIdx):ReadFrom的 seek 起点改为取orderedSeq后缀上index偏移的最小值。后面的seq >= cursor过滤会剔掉多读进来的早期行,所以结果精确而不只是安全。代价 O(k) 次 map 查找(k = 紧接着要json.Unmarshal的那 k 行,µs/条 vs ns/次查找);文件恰好有序时最小值就是orderedSeq[startSeqIdx]本身,起点与改前逐字节一致(这是零回归的原因)。index缺键读成偏移 0 → 放大到全文件读:过读安全,欠读不安全。抽成独立函数同时把ReadFrom圈复杂度从 22 降回 20(gocyclo阈值,内联版被 lint 拦下)。EventLog.MaxSeq():替掉bus.go对orderedSeq的跨结构体裸字段读(未持l.mu)。orderedSeq原注释sorted seqs parallel to index是错的:index是map,slice 与 map 不存在 positional parallel 关系。改为「index的去重键集,升序」+ 一段显式不变量(升序、恰为index键集、len相等、谁依赖它),由TestOrderedSeqInvariantHoldsAcrossMutationPaths双向钉住。index原注释尾巴(exclusive of the seq→offset map)是无意义残留,删。EventLog类型注释补 ORDERING CONTRACT(文件是 append 序而非 seq 序 + 产生原因 + 实测数字),免得后人再把「文件有序」当前提优化回去。ReadFrom末尾原注释the file is append-order which should already be seq-ordered正是本 bug 的错误前提本身,改成说明这个 sort 为什么不是可有可无的兜底。内存侧(
bus.go)wireNext/wireCond顺序门:seq 仍在 persist 之前分配(persist-before-broadcast 要求落盘记录带 seq),但 fanout 前等门。deliverInSeqOrder等到自己那轮才 append history + 推 channel,然后wireNext=seq+1并Broadcast。fanout.go「dropped frames consume a seq too」一致。defer里:panic 或runtime.Goexit也必须交出轮次——一个已分配却永不消费的 seq 会把整条 wire 焊死。wireNext==0作「未设基线」哨兵,基线由第一个被分配的 seq 在b.mu内设定(不能由第一个到达门的 seq 设定——它不一定最小,会把更小 seq 排到它后面上线,反而制造 inversion)。这样重启后 seq 被MaxSeq播种、或测试直接写bus.seq(bus_recover_test.go:54)都不会卡门。b.seq从锁外 atomic 改为b.mu保护的普通字段;NewBus的播种改普通赋值(b在NewBus返回前不可被其他 goroutine 观测)。replayFromHistory无条件sort.Slice:b.history现在按门序(= seq 序)追加,所以通常走 pdqsort 的 O(n) 已排序快路径;但Subscribe只在「有 event log 且 cursor>0」时才经mergeReplayWithLog排序,cursor=0的首连全量回放与无 event log 的 bus 之前拿到的是 history 原始顺序。Publish走b.mu → EventLog.mu;Subscribe在ReadFrom里拿EventLog.mu并在取b.mu之前释放(原bus.go:325那段 F15 注释顺带保证了这点)。包内无任何位置在持b.mu时调Publish。TestPublishAndSubscribeWithCursorDoNotDeadlock做 30s watchdog canary。门禁
全部在 HEAD =
8474400b上重跑(4 核机、3 lane 并行,严格只跑本包)。cd edge-server && go build ./...go vet ./internal/events/go test ./internal/events/ -count=1ok 1.539s)go test ./internal/events/ -count=3ok 4.606s)go test ./internal/events/ -count=1 -raceok 5.640s,无 DATA RACE)golangci-lint run ./internal/events/...python3 scripts/verify/verify-edge-lint-ratchet.pypython3 scripts/verify/verify-doc-ssot.pygit diff --check+git diff --check origin/master...HEAD~/go/bin/gosec -fmt=json ./... 2>/dev/null | bash ../scripts/verify/verify-gosec-gates.shbash scripts/verify/verify-commit-messages.sh origin/master HEAD未跑(lane 纪律禁止):全量
go test ./...、全量golangci-lint、coverage、vitest、docker;未触碰:3210与bin/edge。golangci-lint缓存幽灵本轮未遇到,无需cache clean。证据等级
Publish产生行序 ≠ seq 序的日志文件TestRestartAfterSeqUnorderedLogDoesNotReissueSeq红→绿;探针 10/60 轮末行≠max(479 vs 512)ReadFrom静默丢事件TestReadFromIsCompleteOnSeqUnorderedLog红→绿;探针 3/4 轮丢事件,单 cursor 最多 104 个TestReadFromGapBoundaryUsesTrueMinSeqOnSeqUnorderedLog红→绿;探针 20/60 轮首行≠minTestFanoutWireOrderMatchesSeqOrder*红→绿;1000 条中 87(有 log)/ 92(无 log)处 inversionSubscribe(0)回放未排序TestSubscribeZeroCursorReplayIsSeqOrdered红→绿;1000 条中 62 处 inversionapp/shared/src/eventClient.ts:202+api/events.md:67。未跑前端,未做端到端观测BenchmarkPersistRetryLadderBenchmarkPublishConcurrent*,3 轮中位数orderedSeq与index不「parallel」(原注释为假声明)index是map[int64]int64;不变量测试双向钉住键集相等:3210观测TestPublishAndSubscribeWithCursorDoNotDeadlock;非穷举证明未验证项 / 遗留
:3210)+ SSE/WebSocket 客户端做重连/乱序观测,也没跑前端 vitest 验证eventClient.ts的实际丢弃行为——lane 纪律禁止。客户端危害是读代码 + 契约文本得出的(等级 A 的代码事实,但非运行时观测)。./internal/events/。httpserver/api/hub中消费 replay 与 fanout 的测试未在本机运行。风险偏低但这次不再是零:顺序门改变了Publish的时序语义(新增 head-of-line blocking),任何断言「Publish 立即返回」或依赖投递交错顺序的下游测试都可能受影响。以 CI 结果为准。EventLog.mu串行的形式存在同样的冻结)。未测 GC 暂停或调度抖动导致的门等待分布。10/60、20/60来自已删探针;committed 测试断言「对任何产生的文件/任何投递序列,不变量成立」并t.Logfinversion 数。代价:若将来Publish被改成串行、乱序不再发生,TestConcurrentPublishWritesSeqUnorderedLogFile会退化为空跑(仍绿),只有日志里的0/N能提示。truncateLocked短读数据丢失(pre-existing,未修):l.f.Read(buf)可能返回n < keepBytes,随后只写回buf[start:n],会丢掉保留窗口尾部(最高 seq 段)。是丢数据而非乱序,与本 PR 不变量正交;未构造测试、未修。建议单开 issue。persistWithRetry对确定性错误也退避(pre-existing,未修,persist.go在写集外):json.Marshal失败是永久性的,重试 3 次 + 睡 14ms 毫无意义,还让「调用方传错 payload」变成每次 14.5ms 的门阻塞。建议:区分 transient/permanent 错误,permanent 直接失败。这是我在取舍分析中发现的最高性价比后续项。rebuildIndexLocked中index[seq]last-wins、orderedfirst-wins。修好后正常路径不再产生重复 seq;persistWithRetry的重复行相邻,两种 offset 经过滤后结果相同。极端情形(同 seq 相距很远两处)未测。truncateLocked的Truncate(0)在 Windows 上会失败(bus_truncate_test.go注释已说明),本 PR 未改变该平台行为。sync.Cond无平台差异。以 CI 的 Native Windows Go 为准。bus_publish_order_bench_test.go注释。验证
8474400b),未运行项已在「未验证项 / 遗留」逐条说明原因internal/events下 2 个源文件 + 3 个测试文件;leak_guard pre-commit hook 通过)api/、AGENTS.md或三份主文档 —— 不适用且刻意未动:wire 契约(seq单调)本来就写在api/events.md:67,本 PR 是让实现去符合既有文档,不是改文档;未改contracts/与openapi;EventLog.MaxSeq()是internal/包内新增方法,无外部 API 面变化