diff --git a/.studio/research/2026-09-12-frontend-perf-recheck.md b/.studio/research/2026-09-12-frontend-perf-recheck.md new file mode 100644 index 00000000..49a326b5 --- /dev/null +++ b/.studio/research/2026-09-12-frontend-perf-recheck.md @@ -0,0 +1,52 @@ +# 复核:前端频道性能治理成果是否仍有空洞(#512) + +日期:2026-09-12 · 复核范围:apps/web/src 频道相关渲染面与取数面(#322/#325/#326/#328/#403/#486 治理组合) + +## 结论 + +**有空洞,1 条实质发现**(取数纪律未覆盖行动中心 store 的 SSE 触发重拉),另确认 2 处「虚拟化/水合覆盖不到」的视图属已接受边界而非缺陷。memo 边界(#322 纪律)在治理后新增的组件上无违规。除发现 1 外不建议新开实现票——发现 1 是否立项由 map 维护方裁决。 + +## 发现 + +### F1(实质):notificationStore.load() 无 TTL/single-flight/seq 守卫,且被 SSE 事件逐条直触全量重拉 + +- `stores/notificationStore.ts:127-137` — `load()` 裸发 `GET /action-center`,无任何纪律:无 TTL 锚点、无 single-flight(并发事件 → 在途重叠多份)、无 seq 守卫(整体替换语义下晚到的旧响应可覆盖新状态,下一次事件前 UI 呈旧值)。 +- `components/NotificationBell.tsx:103-116` — 每条 `workunit.status_changed`(全频道、每次 WU 状态流转)与每条 atHuman `channel.message_sent` 都 `void load()`,无防抖无合并。对照:同一事件风暴面在建议端点重拉上已做 trailing 防抖(`pages/ChannelDetailPage.tsx:46-49` #489,`SUGGESTIONS_RELOAD_DEBOUNCE_MS`),行动中心这条路径漏掉了同等处理。 +- 频道相关性:该 store 的 `stateItems` 是频道页顶栏 NEED_INPUT chip 的唯一数据源(`pages/ChannelDetailPage.tsx:184-189` waitingWus 投影),属于票面所问「频道相关 store」。 +- 影响面:多 agent 活跃期 WU 状态流转连发时,每个流转 = 一次行动中心全量派生查询;并发在途 + 旧响应晚到可致短暂陈旧。严重度中低(流转是秒级人文节奏,非 chunk 级),但这是 #403 纪律组合里唯一漏管的频道相关取数面。 +- 修复方向(供裁决,不在本票实施):`load()` 接 `stores/fetchDiscipline.ts` 底座(全局单份簿记,照 rosterStore 模式),或在 NotificationBell 触发侧加 #489 同款 trailing 防抖 + single-flight。 + +## 边界确认(覆盖不到但判定为已接受边界,非缺陷) + +### B1:右栏「频道动态」未虚拟化 + +- `components/channel/ChannelActivityRail.tsx:269-292` — REQ 卡按 `reqs.map` 全量渲染,无窗口化;卡下动态行每卡截断 3 条(:285),「其他动态」经 `deriveActivityRows` 同类相邻折叠(:263)。 +- 判定依据:整栏已 memo 化且只吃稳定投影(:219 + `hooks/useActivityMessageItems.ts:12-20` 引用稳定契约 + 卡内静态部分 `ReqCardStatic` memo :141),无关增量不触重渲;渲染成本 O(频道 REQ 数),频道 REQ 量级为数十,线性可承受。**边界条件**:单频道 REQ 数涨到数百级时挂载成本线性增长,届时需再评估。 + +### B2:展开线程的回复在单个虚拟行内全量渲染 + +- 线程是虚拟化的 item 单位(`utils/streamVirtual.ts:89-103` anchor+全部 replies 映射同一虚拟行),行内不再窗口化:`pages/ChannelDetailPage.tsx:857-877` 展开线程的 replies 全量进 DOM。 +- 缓解已在:≥3 条连续过程消息默认折叠成组(`utils/streamView.ts:125-149`),长线程的主体(过程消息)不进 DOM。 +- **边界条件**:数百条里程碑级回复(人类/卡片/等待回复)的极端线程仍全量渲染,概率低,不立项。 + +## 核查无发现的面(证据) + +### 虚拟化/水合覆盖(#325/#326) + +- 主流+线程统一走 `deriveStreamView` items → virtualizer 窗口渲染(`pages/ChannelDetailPage.tsx:653-700,997-1004`);线程视图在覆盖内(见 B2 的行内边界)。 +- 降级/水合按首个可见消息锚定(`utils/messagePruning.ts:58-77`,keepRecent=100/降级距 50/水合距 30 迟滞);水合防抖 + in-flight 重排 + 频道切换清计时器(`hooks/useChannelEvents.ts:195-224`)。 +- 降级区是前缀性质 → 线程 reply 降级蕴含 anchor 降级 → 整线骨架行兜底(`pages/ChannelDetailPage.tsx:838-841`),不存在「半水合线程」渲染洞。 +- 搜索/跳转场景:产品无频道内消息搜索功能(grep 全仓仅 Knowledge/Library 搜索);跳转(`?highlight=`)覆盖完整——翻页定位循环上限 10 页 + toast 终局反馈(`pages/ChannelDetailPage.tsx:553-569,608-634`)、目标掉出窗口走 `virtualizer.scrollToIndex`(:761-765)、先解钉防振荡(:756)、骨架目标同样高亮(:827-832)。 + +### memo 边界外重渲染路径(#322 纪律) + +- `ChannelMessageItem` memo + 稳定 props 契约保持完好:`handleAction` 经镜像 ref 稳身份(`hooks/useChannelCardActions.ts:27-28`)、`findMessage` 同法(`pages/ChannelDetailPage.tsx:359-363`)、`renderMessageItem` 的 deps 在消息到达时不变(:801-821)。 +- 治理后新增组件核查:`ChannelActivityRail` memo(#416,:219)+ 投影稳定;`ChannelWorkBar` 自持有 live 订阅,step 事件只重渲自身边界(`components/channel/ChannelWorkBar.tsx:64-65` + `hooks/useChannelLiveExecutions.ts`);`ChannelInput` 派生全 memo 化、store 订阅按 key 切片(`components/channel/ChannelInput.tsx:60-71,102-142`),页面随消息到达重渲时这些组件的增量 diff 均为廉价路径,无 memo 破口。 +- 页面级重渲(messages setState → ChannelDetailPage 重渲)是架构内禀,被治对象(消息项)已被 memo + 稳定引用保护,未发现新组件把不稳定引用/内联闭包传进 memo 化消息项。 + +### 取数纪律(#403)在其余 store 的遵守情况 + +- 已接 `fetchDiscipline` 底座(TTL/single-flight/seq/inflight 生命周期):`stores/channelDataStore.ts:78,100,122`(词表/PMO/成员三切片按 channelId 粒度)、`stores/requirementChainStore.ts:77`(per-reqId)、`stores/pmoDataStore.ts:72,93`、`stores/rosterStore.ts:96,119`。 +- 纯本地无取数、无需纪律:`stores/unreadStore.ts`(SSE 增量计数)、`stores/rosterActivityStore.ts`(chunk 下沉)。 +- `stores/workunitStore.ts` 属 WU 列表页面(非频道面),`loadMoreWorkUnits` 有 loading 在途守卫(:124-126),不在本票范围。 +- SSE 有序合并(#328):refetch 合并 `mergePage` prepend 历史页不丢(`hooks/useChannelEvents.ts:22-31`);乐观回显(#486)pending 即插/原位替换/失败回滚(:144-166),游标取最老非 pending 防翻空页(:171-173)。 diff --git a/.studio/research/2026-09-12-walk1-msg-intake.md b/.studio/research/2026-09-12-walk1-msg-intake.md new file mode 100644 index 00000000..b0ffcdc0 --- /dev/null +++ b/.studio/research/2026-09-12-walk1-msg-intake.md @@ -0,0 +1,77 @@ +# 走查① 消息进:`POST /:id/messages` 全路径性能与流程嫌疑 + +- 工单:#506(Part of #504) +- 日期:2026-09-12 +- 方法:读代码为主 + 本地 bench(脚本 `bench-msg-intake.mts`,随本报告同分支;运行方式 `/root/projects/studio/node_modules/.bin/tsx bench-msg-intake.mts`,真实 `~/.studio` 只读、写测全在 tmp) +- 机器口径:本机 Linux,studio 仓 `git ls-files` 2099 条,真实数据 = 3 频道(最大 492 行/272KB)、workunits index 49 快照、sessions.json 592KB/966 条 + +## 结论先行 + +1. **嫌疑点⑧成立但量级小**:`git ls-files` 缓存 miss 确实发生在发消息请求路径上(`validateFileRefs` 在 `routeMessage` 内、201 响应前被 await,`message-routing.ts:142`),实测 miss 比命中慢约 **4.4ms**(cold 5.34ms vs warm p50 0.96ms),其中子进程本身 p50 **3.42ms**。当前规模(单候选仓、2099 文件)不构成瓶颈;风险在缓存 60s TTL 过期后的首发、多候选仓串行 miss 叠加、大仓场景。 +2. **请求路径无同步重 IO**:FileStore 全部读口走 mtime 校验读穿缓存(`file-store.ts:271`/`:324`/`:404`),唯一子进程就是⑧的 `git ls-files`(`file-ref-vocabulary.ts:130-141`)。热路径各环均为毫秒级个位数。 +3. **最重的单点是写侧锁**:`appendMessage` p50 **4.52ms**(mkdir flock + owner.json + append + rm,`file-store.ts:739-744`);`commitSnapshot` 建单 p50 **7.75ms**(flock + appendEvent + **全量 index 重写 + fsync**,`file-store-workunit.ts:254-259`、`:215-225`)。@mention 路径把多次写串行 await,估算整条 30-60ms。 +4. **附件上传有事件循环同步阻塞但可接受**:8MB JSON body 的 `JSON.parse` p50 **5.97ms** 同步阻塞(express json,`channel.routes.ts:223`),base64 解码 5MB p50 **0.90ms**(`attachments.ts:70`)。 +5. **流程多余环节(小)**:路由层与 routeMessage 重复读 channel;`linkWorkUnit` 已知 channelId 却走全频道扫描的 `getMessageById`;每条消息双发 eventBus 两个频道 + fire-and-forget 动态 import。 +6. **鉴权每请求成本随 sessions.json 无界增长**:`requireAuth` 每请求 readJson sessions.json(592KB/966 条,其中 947 条已过期未清理),缓存命中也要 structuredClone 全量,p50 **1.91ms**(`middleware/auth.ts:50-58`、`file-store.ts:283`)。 + +## 分环节发现(带行号与实测数字) + +### 1. 鉴权(`middleware/auth.ts`) + +- `requireAuth()` → `verifyToken`(JWT 纯计算)→ `findSessionWithUser`(`auth.ts:50-58`):每请求 `readJson(sessions.json)` + `readJson(users.json)` 两次存储读。 +- 走 FileStore 读穿缓存(mtime 校验),但命中路径仍 `structuredClone` 整个数组(`file-store.ts:283`):sessions.json 实测 warm p50 **1.91ms**/请求,users.json 0.03ms。cold(mtime 变化后首读)7.23ms。 +- 数据观察:966 条 session 中 947 条已过期,文件只涨不清——每请求克隆成本随之线性增长。 +- `STUDIO_AUTH=none`(本机缺省)直接短路(`auth.ts:180-183`),上述成本仅生产开启认证时存在。 + +### 2. 路由入口(`channel.routes.ts:176-218`) + +- body 形状校验纯内存;`fileStore.getChannel`(`:193`)走缓存,warm <1ms。 +- 多余环节:`getChannel` 在路由层读一次,`routeMessage` 内 mention 路径(`message-routing.ts:239`)与决策 12 路径(`:426`)又各读一次。缓存兜底下每次 <1ms,属冗余非瓶颈。 + +### 3. routeMessage 各分支(`message-routing.ts`) + +- **文件引用校验(嫌疑点⑧入口)**:`:140-158`,`validateFileRefs` await 在响应关键路径上。详见下节。 +- **Priority 1 线程回复**:`getMessageById(replyToId)`(`:190`)= 扫全部频道 messages.jsonl(`file-store.ts:1244-1261`,Promise.all 并发读 + mergeActiveRows)。实测 cold **6.10ms** / warm p50 **1.78ms**(3 频道)。频道数与消息量增长会线性放大。随后 `appendMessage`(p50 4.52ms)+ 可能的 `resumeWaitingWorkUnit`。 +- **Priority 2 @mention**:串行 await 链 —— `validateFileRefs`(可省时无)→ `listProfiles`+`getChannel`(`:238-239`,两次独立 await 可并行)→ `resolveReqIdForDispatch`(`:285`,最差情况自动新建 REQ = 序号锁 + writeJson)→ `resolveWorkspaceForWU`(`:299`,REQ→PMO 多次缓存读)→ `createHumanMessage`(`:315`,appendMessage ≈4.5ms)→ `wuService.create`(`:322`,commitSnapshot ≈7.8ms)→ `linkWorkUnit`(`:372`,又一次全频道 `getMessageById` + `appendMessage` ≈6-11ms)→ 可能 1-3 条 `postWuSystemMessage`(各一次 appendMessage)。全串行,估 30-60ms/条。 +- **Priority 3 合并窗口**:`findMergeTargetWorkUnit`(`:65-83`)→ `queryMessages(human, limit=20)` 实测 warm p50 **1.35ms**(`resolveActiveMessages` 全量归并后过滤,`file-store.ts:828-851`)+ `getById` 读 index(缓存)。 +- **Priority 4 纯存储**:仅一次 `appendMessage` ≈4.5ms,整条路径最干净。 + +### 4. 嫌疑点⑧:file-ref-vocabulary 词表(`file-ref-vocabulary.ts`) + +- 机制:`vocabCache` 进程内存 Map,TTL 60s(`:122`、`:146-148`);miss → `execFile('git', ['ls-files'])`(`:130-141`)在请求路径同步 await(`:151`、`:284`);失败(非 git 仓)空词表且**失败同样入缓存**防反复 spawn(`:152-158`)。 +- 实测(#研发频道,候选集 = `/root/projects/studio`,2099 个被跟踪文件): + - `git ls-files` 子进程:p50 **3.42ms**,p95 7.23ms,max 8.69ms(n=30) + - `validateFileRefs` cold(清缓存 → 请求路径 spawn):**5.34ms**;warm(TTL 内):p50 **0.96ms** + - `computeCandidateRepos` cold 13.18ms(首批 FileStore 读未暖)/ warm p50 0.97ms +- 判定:**属实但当前量级小**。缓解因素:composer 打开 @ 选择器时 `GET /:id/file-vocabulary`(`channel.routes.ts:162-173`)与发送共用同一缓存,正常 UX 下发送时缓存多已被暖;单仓 miss 仅 +4.4ms。放大因素:TTL 仅 60s(静默 1 分钟后首发必 miss);多候选仓时 `validateFileRefs` 按 ref 串行 `getRepoFiles`(`:278-289`)、`getChannelFileVocabulary` 按仓串行(`:258-260`),N 仓全 miss = N×3.4ms 叠加;大仓(十万级文件)ls-files 输出变大、parse 变重。 +- 附带观察:`files.includes(ref.path)`(`:285`)是 O(词表) 线性扫,每 ref 一次;多 ref 同仓重复扫描,词表大时可改 Set(当前 2099 条下无感)。 + +### 5. 落库(`channel-message.service.ts` + `file-store.ts`) + +- `createHumanMessage`:`appendMessage`(`:105`)= per-channel mkdir flock + owner.json 写 + append + rm(`file-store.ts:739-744`、`file-store-base.ts:195-253`),实测 p50 **4.52ms** / p95 10.13ms(n=200)。每 500 次 append 触发一次压实评估(`file-store.ts:750-765`),阈值内均摊可忽略。 +- SSE 发布:`eventBus.publish('channel.message_sent')` + `publishSSE` 同事件再发 `'events'` 频道(`channel-message.service.ts:108-109`)——双平面订阅者不同(agent-loop/evolution vs SSE 广播 `sse.routes.ts:60-77`),属设计内双发,但每消息构造两只信封;eventBus 全内存,成本微秒级。 +- 每消息 fire-and-forget 动态 `import('../knowledge/preference-observer.js')`(`:112-114`、`:148-150`):ESM 模块缓存后仅微任务开销,非瓶颈。 +- WU 建单 `commitSnapshot`:flock 内 appendEvent + `upsertSnapshotLocked` **全量 index.json 重写 + fsync**(`file-store-workunit.ts:215-225`、`file-store-base.ts:118-134`),实测 index=49 快照时 p50 **7.75ms** / p95 12.63ms。index 增长则线性变差。 + +### 6. SSE 发布面(`events/sse.routes.ts`) + +- 内存订阅 → replay buffer → 按 topic 广播,慢客户端背压断开(`:79-99`)。无 IO、无子进程,非嫌疑。 + +### 7. 附件上传(`attachments.ts` + `channel.routes.ts:223-240`) + +- 上传:JSON base64 体(不引 multipart)。8MB body 的 express `JSON.parse` 实测 p50 **5.97ms** 同步占事件循环;`Buffer.from(base64)` 5MB 解码 p50 **0.90ms**(`attachments.ts:70`);落盘 `fsp.writeFile` 异步。单图上限 5MB 有 base64 长度预拒(`:67`)。结论:有同步 CPU 段,量级个位数 ms,单用户场景可接受。 +- 取图:`fs.existsSync` 同步 stat(`:98`,微秒级)+ `createReadStream` 流式响应 + immutable 缓存头,无问题。 + +## 候选优化手段清单(只列,不评判优先级) + +1. ⑧:`validateFileRefs` 的多仓 `getRepoFiles` 串行改并行(`file-ref-vocabulary.ts:278-289`);`getChannelFileVocabulary` 同理(`:258-260`)。 +2. ⑧:词表结果从 `string[]` 改 `Set`(或配套建 Set 缓存),消 `files.includes` 线性扫(`:285`)。 +3. ⑧:发送路径预热——`POST /:id/messages` 带 files 时若 cache miss,可accept 旧值 + 后台刷新(stale-while-revalidate),或把 TTL 与 composer 拉取联动延长。 +4. `linkWorkUnit` 增 channelId 直读路径,免全频道扫描(`channel-message.service.ts:223-242` → `file-store.ts:1244-1261`);调用方本就持有 channelId。 +5. @mention 链可并行的环节并行:`listProfiles` ∥ `getChannel`(`message-routing.ts:238-239`);`reportDroppedRefs` 与后续系统播报目前顺序 await,可收拢。 +6. sessions 清理:过期 session 定期 prune 或 readJson 命中路径避免全量 structuredClone(`middleware/auth.ts:50-58`、`file-store.ts:283`)。 +7. `getMessageById` 支持 channelId 限定查询(reply 路径 `message-routing.ts:190` 已知 channelId)。 +8. 附件上传:8MB JSON.parse 同步段可改流式/multipart 或 worker 解码(`channel.routes.ts:223`、`attachments.ts:70`)。 +9. `commitSnapshot` 全量 index 重写 + fsync 随 WU 数线性增长,大 index 时可考虑增量落盘(`file-store-workunit.ts:215-225`)。 +10. 路由层与 routeMessage 重复 `getChannel` 可传参消一次(`channel.routes.ts:193` vs `message-routing.ts:239`/`:426`)。 +11. 每消息双发 eventBus 两频道(`channel-message.service.ts:108-109`)可评估收敛订阅面;动态 import preference-observer 可上提为模块级。 diff --git a/.studio/research/2026-09-12-walk2-route-dispatch.md b/.studio/research/2026-09-12-walk2-route-dispatch.md new file mode 100644 index 00000000..8cd23715 --- /dev/null +++ b/.studio/research/2026-09-12-walk2-route-dispatch.md @@ -0,0 +1,97 @@ +# 走查②路由派单:routeMessage 链路的性能与流程嫌疑(#507) + +- 日期:2026-09-12 ・ 票:#507(Part of #504)・ 分支:research/walk2-route-dispatch +- 方法:读码 + 本地 bench(`apps/api/bench/route-dispatch-merge-window.ts`,只读复制真实 `~/.studio/data` → tmp 放大 1x/10x/50x/…/1000x;Node v22.22.0,median of 20-50 rounds) +- 前置状态:#494(派单原子性,方案 c anchorMessageId)、#495(合并窗口)均已 CLOSED(2026-09-11)并落地,本走查针对落地后现状。 + +## 结论先行 + +1. **嫌疑点③成立但当前无害,是增长型风险**。`findMergeTargetWorkUnit`(`apps/api/src/modules/channels/message-routing.ts:70`)经 `queryMessages` 全量物化本频道热文件(`packages/studio-shared/src/file-store.ts:828-851` → `:824-826` → `readJsonl :324-362`)。`readJsonl` 有 mtime 读穿缓存,但**稳态每条消息必 miss**:上一条消息(含本频道任何 agent/系统消息)的 `appendMessage` 都精确失效缓存(`file-store.ts:313-316`)。实测 miss 路径成本随行数线性:492 行 3.4ms → 4,920 行 34ms → 24,600 行 222ms(median)。 +2. **合并窗口查询可以避免全热读**,且有现成原语:尾部倒扫(`jsonl-tail.ts`,`getChannelVersion`/`getMessagesSince` 已用,file-store.ts:778/803)。实测「倒扫至首条(去重)human+workUnitId 即停」在全部规模档持平 **0.3-0.4ms**,24,000 行时对比现状 276ms ≈ **约 700 倍**;人类消息稀疏的最坏档退化为全文件逐行 parse(131ms vs 222ms,省掉 clone/归并/排序),可用窗口时间早停再降。 +3. **更大的隐藏嫌疑在 P1 replyTo 与 P2 linkWorkUnit**:`getMessageById` 扫**全部频道**热文件(`file-store.ts:1244-1261`)。每条线程回复(`message-routing.ts:190`)和每次派单回填(`channel-message.service.ts:224`)各付一次 O(Σ所有频道热文件)——实测暖命中也随总量线性(24K 行时 108ms),而 routeMessage 本来就持有 channelId,父消息/派发消息必在本频道,全频道扇出是浪费。 +4. **生产现状:P3 合并窗口路径当前休眠**。3 个频道均无 `defaultProfileId`(`~/.studio/data/channels/*/config.json`),最大频道(sys,492 行)**0 条人类消息**(纯 agent 流量),最后一条人类消息 2026-08-17。即嫌疑点③的实际触发频率目前为零;成本分析是配置启用后的前瞻。 +5. **流程优先级本身无需简化**:4 级各有不可替代语义,无冗余读取(P2 return 后才走 P3,`getChannel` 不重复)。可简化的是各环节的**实现方式**(见候选清单),不是链的顺序。 +6. **各环节成本(真实 1x 数据,median)**:P1 replyTo ≈ 7-10ms;P2 mention 派单 ≈ 20ms+(其中建 WU `commitSnapshot` 9.4ms、两次 append 7.4ms、linkWorkUnit 内全频道查 1.7ms);P3 合并 ≈ 6-9ms(查询仅占 0.5-3.4ms);P4 纯存储 = 一次 append 3.7ms。当前规模全部健康,瓶颈全在「随热文件线性增长」的读口上。 + +## 分环节发现(成本与必要性) + +### P1:replyTo 线程回复(message-routing.ts:189-233) + +- 读取:`resolvedFs.getMessageById(replyToId)`(`:190`)→ `file-store.ts:1244-1261`:`readdirCached` 列频道 + **逐频道** `readJsonl` 热文件(Promise.all 并行,取首个命中)。每个文件的命中路径仍要 structuredClone 全量行(`file-store.ts:336`, `cloneCached :168-170`)。 +- 实测:真实 1x(3 频道/总 522 行)1.7ms;人类频道放大 1000x(本频道 24K 行)后 afterWrite 109ms / warmHit 108ms——**暖命中也不省**,因为克隆税照付。 +- 必要性:线程继承 workUnitId 必要;**全频道扇出不必要**——replyToId 来自本频道 UI,父消息必在本频道(`:1248-1253` 的跨频道查找对路由层是纯浪费)。 +- 附带:`resumeWaitingWorkUnit`(`:210`)= WU 点读 0.9ms(`getIndex({id})` filter 下推,file-store.ts:404-429)+ 锁内 metadata 合并写(`waiting-input.ts:83-86`/:131-142),量级数 ms,不随频道消息量增长。 + +### P2:@mention 派单(message-routing.ts:236-423) + +- 读取:`listProfiles({status:'active'})`(`:238`)cold 5.2ms / warm 0.67ms(8 profiles);`getChannel`(`:239`)warm 0.06ms;REQ 绑定 `resolveReqIdForDispatch`(`:285`)与归属解析 `resolveWorkspaceForWU`(`:299`,`ownership-resolver.ts:60-114`:getRequirement + projectService.get + getChannel,均为缓存 JSON 点读,各步独立容错)。 +- 写入:`createHumanMessage`(`:315`)append 3.7ms → `wuService.create`(`:322`)= `commitSnapshot`(`file-store-workunit.ts:254-259`,锁内 appendEvent + 全量索引重写)9.4ms + `publishCreated` 同步触发 loop observe(`workunit-crud.ts:285-295` → `agent-loop.ts:267-282`)→ `linkWorkUnit`(`:372`)= **又一次 getMessageById 全频道扫**(`channel-message.service.ts:224`)+ 第二次 append 3.7ms。 +- 必要性:建 WU 与事件发布必要;**linkWorkUnit 的查找不必要**——派发消息记录刚刚由 `:315` 返回(id、channelId、全字段在手),`channel-message.service.ts:223-228` 却重新全频道查一遍再 append。 +- #494 原子性现状:方案 c 已落地——先落派发消息、WU metadata 显式携带 `anchorMessageId`(`:312-336`,决策12 路径同构 `:466-486`),认领播报锚点竞态已消,无遗留动作。 + +### P3:决策12 默认角色 + #495 合并窗口(message-routing.ts:426-500) + +- 合并目标查找 `findMergeTargetWorkUnit`(`:65-83`):`queryMessages(channelId, {authorType:'human', limit:20})`(`:70`)→ **全热文件物化**(readJsonl miss = readFile + 逐行 parse + structuredClone 全量;命中 = stat + structuredClone 全量)→ `mergeActiveRows` 归并 + filter + sort(`file-store.ts:824-851`),最后才 slice(-20)。 +- **稳态必 miss 的因果**:频道内任何消息落库(人类闲聊、agent 回复、系统播报)都经 `appendMessage`(`file-store.ts:739-744`)→ `appendJsonl` → `invalidateFileKey`(`:313-316`)。默认角色频道里每条无 @ 消息到达时,上一条消息的写入刚把缓存失效 → miss 全读。实测对照:afterWrite(miss) vs warmHit,24K 行时 276ms vs 122ms(人类频道 1000x 档)。 +- 实测线性增长(median): + + | 规模(sys 频道模板) | B 合并查询(写后) | C 对照(暖) | + |---|---|---| + | 1x = 492 行 / 266KB | 3.4ms | 1.4ms | + | 10x = 4,920 行 / 2.6MB | 34ms | 16.7ms | + | 50x = 24,600 行 / 13.1MB | 222ms | 88ms | + +- 命中后的第二步 WU 点读(`:74`)0.9ms,不随消息量增长,无问题。 +- 必要性:合并窗口本身必要(#495 AC:连发闲聊不建 WU 风暴);**全热读实现不必要**——语义只要「最近一条带 workUnitId 的人类消息」+ 窗口判定(`:71-73`),尾部倒扫 O(窗口内行数) 即可回答。 + +### P4:纯文本存储(message-routing.ts:502-505) + +- 一次 `appendMessage` ≈ 3.7ms(含 per-channel flock + 每 100 次 append 的压实评估,file-store.ts:750-758),O(1) 成本,无嫌疑。 + +## 嫌疑点③专项:合并窗口查询能否避免全热读——实测对照 + +候选 = 尾部倒扫(复用 `iterateJsonlLinesBackward`,jsonl-tail.ts;file-store.ts:778/803 已有两个消费先例)。两种停法: + +- **D(对齐现语义)**:倒扫收集 20 条(去重)人类消息即停 → 与现 `limit:20` 口径逐字等价。 +- **D2(语义收敛)**:倒扫遇第一条(去重)`human+workUnitId` 即停——`:71-72` 本来就只用最后一条。 + +实测(人类频道 706… 模板,24 行含 3 human / 1 human+wu、距尾 7 行,放大保持分布;median): + +| 规模 | B 现状(写后) | D | D2 | +|---|---|---|---| +| 1x = 24 行 | 0.46ms | 0.31ms | 0.27ms | +| 50x = 1,200 行 | 9.5ms | 0.74ms | 0.37ms | +| 200x = 4,800 行 | 35ms | 1.1ms | 0.39ms | +| 1000x = 24,000 行 / 12.3MB | 276ms | 0.86ms | 0.43ms | + +D/D2 全档位持平亚毫秒(扫 20/37 行),24K 行时 ≈ 300-700 倍差距。 + +**最坏档 caveat(实测)**:频道无(或稀疏)human+workUnitId 消息时,倒扫凑不满即扫到文件头——sys 频道(0 人类消息)50x 档 D=131ms vs B=222ms。仍省掉全量 clone+归并+排序,但逐行 parse 逃不掉。补救:扫到 `createdAt` 超出合并窗口(默认 5 分钟,`:55-58`)的人类消息即可判 null 早停——注意 `#317` 起更新副本(linkWorkUnit/meta 更新)append 在尾而 `createdAt` 不变(`channel-message.service.ts:165-170`),严格 createdAt 序被打破,早停需过量扫描缓冲或接受有界语义偏差。 + +**语义偏差说明**:现行为「按 createdAt 排序后最后 20 条人类消息中取最后一条带 workUnitId 的」;倒扫首例 = 「append 序最后一条带 workUnitId 的」。两者在更新副本存在时有界偏差(旧消息的更新副本位置靠前但 createdAt 旧);D 方案保留 20 条上限后与现语义仅差此类副本的排序,方向安全(副本 updatedAt 更近、createdAt 相同,窗口判定不受影响)。 + +## 流程优先级可否简化 + +链顺序(replyTo → @mention → 决策12 → 纯存储,`:4-9` 头注 / `:189/:236/:426/:502`)四级语义正交,无可删级: + +- replyTo 优先于 @mention:回复里的 @ 不触发派单,是语义决策(线程内对话不新建单),非性能问题,不动。 +- P2 return 后才走 P3,`getChannel` 每条消息只调一次(`:239` 与 `:426` 互斥),无重复读。 +- `detectMention`(`:38-42`)单次正则,O(内容长度),可忽略。 +- 可简化的在实现层不在链层:P1 的全频道父消息查找、P2 的 linkWorkUnit 重查、P3 的全热读,三处都是「用 O(全量) 的通用读口回答 O(增量) 的问题」。 + +## 候选优化手段清单(只列,不评判优先级) + +1. `findMergeTargetWorkUnit` 改尾部倒扫:复用 `iterateJsonlLinesBackward`,首条(去重)human+workUnitId 即停 + 窗口判定(D2 实测全档位 ~0.4ms);保留 20 条人类上限对齐现语义(D 方案)。先例:file-store.ts:778/803。 +2. 倒扫加窗口时间早停:`createdAt` 超窗的人类消息出现即判 null,稀疏频道最坏档从全文件 parse 降到 O(尾部);需处理更新副本乱序(过扫缓冲或有界偏差声明)。 +3. P1 父消息查找限定本频道:`routeMessage` 已持 channelId,改走本频道查找(新增 `getMessageByIdInChannel` 或 `queryMessages(channelId)` 内 find),把 O(Σ全部频道热文件) 降到 O(本频道);跨频道 reply 场景需先确认不存在(UI 不产出)。 +4. `linkWorkUnit` 免重查:调用方已持完整消息记录时直接 append 更新版,跳过 `getMessageById`(channel-message.service.ts:223-228);P2 每次派单省一次全频道扫。 +5. `queryMessages` 加 tail 快径:`limit` 且无 `since`/`workUnitId` 过滤时走倒扫切片,惠及全部调用方(路由、分页、wu-messenger 等),不只本链路。 +6. 合并目标指针化:决策12 建单/合并时把 lastDispatch 消息/WU 指针落频道 config 或独立小文件,查询 O(1);代价是多写入点一致性维护。 +7. WU 索引推导合并目标:`getIndex` 按 channelId+在途状态+defaultProfileId 取最新——锚点语义是消息时间(滑动窗口,`:63` 注释明确),WU.updatedAt 被簿记推进(#493/#499 已踩过同类坑),语义不等价,仅在改窗口语义为「WU 最后活动」时可用。 +8. append 侧维护 per-channel 轻量索引(如 human 消息尾部游标):写入时更新,读取 O(1);属存储层变更,blast radius 最大,仅在其他手段不足时考虑。 + +## 附:bench 产物 + +- 脚本:`apps/api/bench/route-dispatch-merge-window.ts`(本分支;只读 `~/.studio/data` → tmp 合成,不碰生产数据)。 +- 原始结果:`/tmp/route-dispatch-bench-mO5z6g/route-dispatch-bench-results.json`(sys 频道 1x/10x/50x)、`/tmp/route-dispatch-bench-cKhIOZ/route-dispatch-bench-results.json`(人类频道 1x/50x/200x/1000x,含 D/D2)。 +- 生产数据快照事实:3 频道均无 defaultProfileId;sys 频道 492 行 0 人类消息;WU 索引 49 条;最后人类消息 2026-08-17。 diff --git a/.studio/research/2026-09-12-walk3-claim-latency.md b/.studio/research/2026-09-12-walk3-claim-latency.md new file mode 100644 index 00000000..82ddfbe8 --- /dev/null +++ b/.studio/research/2026-09-12-walk3-claim-latency.md @@ -0,0 +1,92 @@ +# 走查③ 认领延迟:WU 建成 → loop 拾取 → 首个执行步开始的时延构成 + +- 票:#508(part of #504) 分支:research/walk3-claim-latency 日期:2026-09-12 +- 方法:读码为主 + 真 AgentLoop 端到端 bench(`apps/api/bench/claim-latency.ts`,以 ~/.studio 为 1x 模板合成 tmp 副本:49 WU / 3 频道 / 996 事件行;WU 用 test 特征 scope 走 B2 守卫,认领后即关闭,不烧 token) + +## 结论先行 + +1. **票面锚点「workunit.created EVENT trigger 立即唤醒」在当前代码不成立。** EVENT handler(`apps/api/src/modules/agents/loop/agent-loop.ts:269-275`)只 fire-and-forget 跑一次 `observe()`,结果丢弃——不调 `wakeIdle`、不进 resolveTarget/claim。认领只发生在 runLoop 主循环(`agent-loop.ts:401-453`),idle 分支睡满 15s(`agent-loop.ts:410`)。实测 3 轮随机相位建单 → 认领延迟 8527 / 11958 / 12014ms(均匀落于 15s 地板内;若 EVENT 真唤醒应在几十 ms 量级)。 +2. **唯一能打断 idle sleep 的是 `channel.message_sent`,且只覆盖「本实例 active/blocked WU 的人类消息」**(`agent-loop.ts:292-293` 订阅、`:661-669` 过滤 + #493 闩锁 `:674-678`;过滤口径 `lastActiveWuIds` 由 observe 每轮刷新 `:773`)。实测 人类消息 publish → 下一次 observe = **4ms**。新建 WU 必不在 `lastActiveWuIds` → **一切建单路径的认领地板 = 0~15s 轮询(均值 ~7.5s)**。 +3. **认领互斥/租约开销可忽略**:claim 全链路(getIndex + 文件冲突检查 + flock mkdir 悲观互斥 + 租约写 + status_changed)实测 p50 23.4ms / p95 34.0ms;observe 三读口合计 p50 <1ms。时延几乎全是「等轮询」。 +4. **分场景地板**:@mention 派单 = 0~15s + ~40ms;默认角色新建 = 同左,合并窗口并入在途 WU 时视目标 WU 归属(本实例 active/blocked → ~4ms 唤醒;unassigned/他角色 → 0~15s);线程回复注入(WU 属本实例 active/blocked)= **~4ms + 簿记 <50ms**,但回复若落在步间 plain sleep(`dynamicInterval` 3s/10s/30s,`agent-loop.ts:446` 不可中断)要等余量,worst ~30s(need_input 后)。 +5. **即使把 EVENT handler 改成真唤醒,仍有覆盖不到的可认领路径**:auditor okr_proposal 直写 commitSnapshot 绕过 create(`apps/api/src/modules/agents/auditor/auditor-rules.ts:291-316`);pending→unassigned 人闸确认 / unclaim 释放回池 / blocked→active 复活 / reopen,全部只发 status_changed 不发 created(`apps/api/src/modules/workunit/workunit.service.ts:199-202`、`workunit-crud.ts:527-553`、`waiting-input.ts:143`);跨进程写者与重启间隙 eventBus 天然丢失(`agent-loop.ts:288-290` 注释自承)。 + +## 时延构成拆解(WU 建成 → 首个执行步开始) + +| 段 | 内容 | 实测/量级 | 佐证 | +|---|---|---|---| +| 建单落盘 | commitSnapshot(appendEvent+upsert 同锁)+ claimable 解析 + `workunit.created` 发布 | p50 12.2ms / p95 22.8ms | `workunit-crud.ts:201-249,284-295`;bench A | +| 等拾取 | idle sleep 地板 15s,随机相位均匀 0~15s | **8527~12014ms(n=3,mean 10.8s)** | `agent-loop.ts:410`;bench B1 | +| observe | getIndex 0.21ms + listChannels 0.18ms + queryAllMessages 0.12ms(p50) | 合计 <1ms | `agent-loop.ts:710-795`;bench A | +| 认领 | flock mkdir 悲观互斥(锁内 status 复查)+ 租约 timeoutAt 写 + status_changed | p50 23.4ms / p95 34.0ms | `workunit-crud.ts:485-522`、`file-store-workunit.ts:160-161`、`file-store-base.ts:27,195`(withLock 超时 5s);bench A | +| 认领→步开始 | 心跳写 + SSE 发布 + ensureLease + agentStep 前置(守卫/channelVersion/PMO 分支/worktree 创建/prompt 组装)→ executor spawn | 未逐项计时(bench 在 prompt 组装前被 B2 守卫截停);其中 git worktree add 与 knowledge inject 是该段最大变量 | `agent-loop.ts:428-441,891-893,917-961,1005-1008`、`executor.ts:21-24` | +| 步间调度 | recordResult 后 `sleep(dynamicInterval)`:progress 3s / complete 10s / need_input 30s / failed 15s,**普通 setTimeout 不可中断** | 0~30s(仅步间场景) | `agent-loop.ts:446`、`agent-loop-parsers.ts:150-158` | + +补充结构性事实: + +- 认领竞争失败方 sleep 1s 重试(`agent-loop.ts:417-420`);flock 跨进程互斥、进程内 per-lockDir mutex 排队(`file-store-base.ts:195-223`)。 +- observe 每轮 unassigned 只取最早 5 条(`agent-loop.ts:759-760`)、myActive 只取最新 20 条(`:719-723`)——突发建单 >5 时排队延迟会超过 15s 地板。 +- 租约:认领即写 5min TTL(`workunit-crud.ts:516-517`),持有期 30s 心跳续租(`lease-heartbeat.ts:20`)——是持有期开销,不进认领延迟。 +- EVENT trigger 无 filter(`agent-loop.ts:280`):每次建单触发**所有** loop 各跑一次空 observe(读放大,非延迟问题)。 + +## 分场景认领延迟地板 + +### ① @mention 派单(`message-routing.ts:235-422`) + +- 链路:先落派发消息(`:315-321`,此时 workUnitId=null)→ `wuService.create`(`:322-358`,status=unassigned)→ `publishCreated`(`workunit-crud.ts:284-295`)→ EVENT observe(结果被丢弃)→ **等 idle 轮询边界** → observe 指名过滤(`agent-loop.ts:741-742`)→ claim。 +- 派发消息的 `channel.message_sent` 不唤醒:发布时 workUnitId 为 null(`channel-message.service.ts:94-108`;回填 linkWorkUnit 在建单之后,`message-routing.ts:372`,且发的是 message_updated)。新 WU 也不可能命中 `lastActiveWuIds`。 +- **地板 = 0~15s + ~40ms(observe+claim),实测 8.5~12.0s。** 例外:无归属挂起(parked,`:311,326-327`)落 blocked 根本不可认领,等人回复归属后才复活。 + +### ② 默认角色(决策 12)派单 / #495 合并(`message-routing.ts:425-500`) + +- 窗口外/无在途 WU → 新建(`:466-486`):同 @mention,**0~15s**。 +- 合并窗口内(默认 5min,`:55-58`)并入在途 WU 线程(`:430-463`): + - 目标 WU 属本实例且 active/blocked → `channel.message_sent`(workUnitId 命中 lastActiveWuIds)**~4ms 唤醒**(bench B2);回复经 pendingReplies 注入(`waiting-input.ts:81-88` 或 `:442-457`),实际消费在下一步 prompt。 + - 目标 WU unassigned/pending(未认领/人闸中)→ 不在任何实例的 lastActiveWuIds → 不唤醒,WU 拾取仍 **0~15s**(合并消息只是搭车)。 +- 若 loop 正处于步间 plain sleep(`dynamicInterval`),闩锁要等 idleSleep 入口才消费(`agent-loop.ts:674-678`)→ 延迟余量 worst ~30s。 + +### ③ 线程回复注入(replyTo,`message-routing.ts:189-216`) + +- 链路:继承父消息 workUnitId(`:200`)→ 落人类消息(发 `channel.message_sent`,`channel-message.service.ts:108`)→ `resumeWaitingWorkUnit`(`:209-216`;blocked→active + pendingReplies,`waiting-input.ts:64-147`)。 +- WU 属本实例 active/blocked(在 lastActiveWuIds)→ **唤醒 ~4ms**(bench B2 publish→observe 实测),下一 tick observe 新回复检测(`agent-loop.ts:781-792`,>= updatedAt 水位线,#493 同毫秒边界)→ agentStep。**地板 ≈ 毫秒级 + 簿记 <50ms,是三场景里唯一事件驱动全覆盖的。** +- 盲区:a) 回复落在步间 plain sleep → 等 dynamicInterval 余量(worst ~30s);b) WU 不属本实例(跨角色/unassigned 合并目标)→ 退回 15s 地板;c) 父消息已归档冷层(#327)→ 降级放行不触达任务(`message-routing.ts:195-199,219-230`)。 + +## EVENT 唤醒覆盖不到的建单/可认领路径 + +按「假设 EVENT handler 已修复为真唤醒」盘点仍漏的路径: + +1. **auditor okr_proposal 建单旁路**:直写 `fileStore.commitSnapshot` 绕过 `WorkUnitService.create`,无 `publishCreated`(`apps/api/src/modules/agents/auditor/auditor-rules.ts:291-316`)。 +2. **pending → unassigned 人闸确认**:feature/task/spec 默认落 pending(`workunit.types.ts:340-353` PENDING_CONFIRM_TYPES),确认走 transitionStatus 只发 status_changed(`workunit.service.ts:199-202`)。确认时展开的 routing 子单走 create 有事件,被确认的父单本身没有。 +3. **unclaim 释放回池**:超时释放(timeout-release)/人工释放 → unassigned,只发 status_changed(`workunit-crud.ts:527-553`)。 +4. **blocked → active 复活**:`resumeWaitingWorkUnit` 走 transitionStatus(`waiting-input.ts:143`),无 created 事件——但同消息流的 `channel.message_sent` 唤醒补位(限 lastActiveWuIds 内 WU)。 +5. **reopen closed → unassigned**(`workunit.service.ts:218-224`)。 +6. **进程外写者 / 重启间隙**:eventBus 进程内 fire-and-forget 无持久,跨进程建单与重启间隙的事件天然丢失,靠 15s 轮询 + 启动首轮 observe 兜底(`agent-loop.ts:288-290` 注释自承此语义)。 +7. 现状下(handler 只 observe 不唤醒)以上全部等价:**所有路径统一吃 0~15s 地板**。 + +## 实测数字汇总(bench/claim-latency.ts,1x 生产规模合成副本) + +``` +Phase A 组件耗时(n=30, ms): + getIndex p50 0.21 p95 0.34 + listChannels p50 0.18 p95 0.57 + queryAllMessages p50 0.12 p95 0.29 + create p50 12.2 p95 22.8 (含 flock + claimable 解析 + 事件发布) + claim p50 23.4 p95 34.0 (含 flock 互斥 + 租约写 + status_changed) + +Phase B 真 AgentLoop 端到端: + B1 建单→认领(随机相位 n=3): 8527 / 11958 / 12014ms(mean 10.8s,∈ 15s idle 地板) + B2 人类消息 publish→下一次 observe: 4ms +``` + +口径备注:B1 的 claimedAt 取自 index 快照(1ms 精度),轮询检出粒度 50ms;B2 以插桩 FileStore 的 getIndex 调用时刻为 observe 指纹。bench 数据根在 tmp(合成副本,已清理),脚本留存 `apps/api/bench/claim-latency.ts` 可复跑。 + +## 候选优化手段(只列,不评判优先级、不动手) + +1. EVENT handler 真唤醒:`agent-loop.ts:269-275` 的 observe-only handler 改为置 pendingWake + 调 `wakeIdle`(复用 #493 闩锁),让建单即刻驱动一轮完整 loop 迭代——单点改动覆盖全部经 publishCreated 的建单路径。 +2. 唤醒按事件负载过滤:workunit.created 负载已带 assigneeId/channelId/claimable,可按「指名本 role 或本频道成员」选择性唤醒,避免每次建单唤醒全部 loop。 +3. auditor-rules 建单旁路改走 `WorkUnitService.create`(或补 publishCreated 调用),消除无事件建单。 +4. 可认领迁移补事件:transitionStatus/unclaim 落入 unassigned 时补发「可认领」事件(或 loop 订阅 workunit.status_changed 并按负载 claimable 过滤),把人闸确认 / 超时释放 / reopen 纳入事件驱动。 +5. 缩短 idle 地板:15s → 2~5s(observe 单轮实测 <1ms,空转成本可忽略),或事件 + 短轮询双保险。 +6. 步间 sleep 可中断:`dynamicInterval` 的 plain sleep 改走可消费 pendingWake 闩锁的 idleSleep 同款,让步间到达的线程回复即时注入。 +7. 扩展 message_sent 唤醒过滤口径:对「unassigned 且指名本 role」的 WU 的合并/回复消息也唤醒(当前只认 lastActiveWuIds)。 +8. 跨进程兜底:FileStore index.json mtime 监视(fs.watch)作为事件总线之外的唤醒源,覆盖未来跨进程写者。 diff --git a/.studio/research/2026-09-12-walk4-receipt-fanout.md b/.studio/research/2026-09-12-walk4-receipt-fanout.md new file mode 100644 index 00000000..76ca76b3 --- /dev/null +++ b/.studio/research/2026-09-12-walk4-receipt-fanout.md @@ -0,0 +1,80 @@ +# 走查④回执呈现:系统播报与 SSE 扇出的开销(#509) + +- 日期:2026-09-12 +- 范围:系统播报路径(createAgentMessage 唯一发布路径)、SSE 扇出、事件负载契约符合性;重点嫌疑点⑥ execution-step-events.ts:421-430 Layer B 逐 chunk 扇出成本 +- 方法:读码 + 实测(bench 脚本喂真实会话数据,跑在主 checkout 源码上,只读) + +## 结论先行 + +1. **嫌疑点⑥不成立为性能问题**:Layer B 逐 chunk `eventBus.publish` + SSE 扇出的服务端成本实测为微秒级(1 客户端 1.4µs/事件、20 客户端 24µs/事件),一次典型执行步(≈26 个 chunk)总扇出成本 <1ms。真正的成本不在 CPU,而在**网络字节量**与 **replay buffer 挤占**(见发现 3、4)。 +2. **一次典型执行的 SSE 事件量量级**:实测真实会话数据,每个 stream-json 行恰好产 1 个 chunk(868/868),典型步 ≈26 个 stream chunk(信封均 533B),即每步 ≈26 帧 ≈14KB;重步上界 ~155 帧。一次 WU 执行(多步)量级 = 数十到数百帧、数十 KB——量级温和。 +3. **事件负载契约符合**:Layer A/B 负载均自足(chunk 带 workUnitId/step/kind/text 全量),前端消费端零 REST 补拉(useWorkUnitStreamEvents 纯内存;ExecutionSteps/ExecutionFlow 首拉打底 + SSE 就地 append + 重连一次性 refetch——契约明文允许的形态)。无门铃事件。 +4. **唯一发布路径属实**:`channel.message_sent` 全仓仅 channel-message.service.ts:108/144 两处发出(createHumanMessage/createAgentMessage 内),wu-messenger 等全部委托于此;audit 订的是 `events:audit` 独立 topic,不吃 Layer B 流量。 +5. **播报流程无明显多余/缺失**;可批处理/降频空间存在但收益主要在网络帧数与 replay buffer 纯度,不在服务端 CPU。 + +## 分嫌疑点/分问题发现 + +### ⑥ Layer B 逐 chunk 扇出成本(execution-step-events.ts:421-430) + +机制:`agent-loop.ts:1096-1098` onStreamLine 每行 → `emitExecutionStreamLine`(execution-step-events.ts:433-441)→ `buildExecutionStreamChunks` 提炼 0..n chunk → `publishStreamChunks`(421-430)逐 chunk `eventBus.publish('events', …)`。`events` topic 生产环境唯一订阅者 = sse.routes.ts:60(其余命中均为测试),扇出 = topic 前缀映射(sse.routes.ts:42-53)→ replay buffer 入队(:65)→ 每订阅客户端 `JSON.stringify` + 2 次 `res.write`(:79-93)。 + +实测(bench 脚本跑 100k 次迭代均值,Node 同进程;数据源 = `~/.claude/projects/-root-projects-studio*` 最近 40 个真实会话文件,与 stream-json 行同构): + +| 项 | 实测值 | +|---|---| +| 每行产 chunk 数 | 868 行全部恰好 1 chunk(chunksPerLineDist `{1: 868}`),0 产出行 0 | +| chunk kind 分布 | tool 298 / tool-result 298 / thinking 190 / text 82 | +| SSE 信封字节 | 均 533B,最大 892B(容量纪律:单 chunk ≤500 字符,execution-step-events.ts:297) | +| 会话行数(≈步内事件数) | 均 31、中位 11、最大 155;步均 ≈ 868行/34步 ≈ 26 帧 | +| eventBus.publish 裸开销 | 0.14µs/次 | +| 服务端整链路扇出 | 1 客户端 1.4µs、5 客户端 5.9µs、20 客户端 24.2µs(每事件) | +| Web 端分发(JSON.parse + N handler 早退) | 5 handler 1.6µs、10 handler 1.8µs(每事件) | + +推算:典型一步 ≈26 帧,20 个并发 SSE 客户端下服务端总成本 ≈ 26 × 24µs ≈ 0.6ms,摊在几分钟的步时长里可忽略。网络侧 ≈ 26 × 533B × 客户端数 ≈ 14KB/步/客户端。 + +### SSE 事件量量级(一次典型执行) + +- Layer B(stream,SSE-only 不落盘,execution-step-events.ts:292-295):每步 ≈26 帧(上界 ~155 帧/重步)+ 1 帧 step-start(agent-loop.ts:1156)+ 1 帧 result(execution-step-events.ts:392-400,空文本也产,作「本回合结束」信号)。 +- Layer A(step,落盘 + SSE,execution-step-events.ts:230-246):每步 1 帧,负载 ≤30 toolCall×160 字符 + 3×500 thinking + 500 text ≈ 上界 ~7KB(容量纪律 :44-48)。 +- 频道播报(回执本体):recordResult → postToDiscussionSpace(agent-loop.ts:1930-1942)→ postWuSystemMessage(wu-messenger.ts:101-133)→ createAgentMessage,每条频道消息 1 帧 `channel.message_sent`(channel-message.service.ts:144-145);里程碑消息另写 NotificationService 持久通知(wu-messenger.ts:77-95,仅 milestone,非每帧成本)。 +- 合计一次典型执行(1 步):**~30 帧 / ~15-20KB**;一个 WU 走完(步安全阀 15 步,agents/CONTEXT.md:24):**数百帧 / 数百 KB 量级**。 + +### 扇出成本落在哪(各订阅端) + +- **服务端**:微秒级 CPU(上表),可忽略;真实成本 = socket 写字节 × 客户端数。背压策略已就位:write 返回 false 即断开慢客户端(sse.routes.ts:84, 95-100)。 +- **Web 端**:全应用唯一 EventSource(web/src/CONTEXT.md:86,websocket.tsx:9-15 单一 handlersRef 分发);每帧 1 次 JSON.parse + ~5-10 个早退 handler ≈ 2µs。渲染成本已被架构压掉:stream chunk 只重渲抽屉实时区块(useWorkUnitStreamEvents.ts:13-43,内存上限 50 条)与对应 roster 卡切片(rosterActivityStore,#348 状态下沉,render-count 测试锁定既有消息项零重渲)。 +- **audit/通知**:audit 订 `events:audit`(audit-subscriber.ts:10),与 `events` topic 无交集;通知只在 milestone 落持久通知。两者对 Layer B 零成本。 + +### replay buffer 挤占(实测关联的次生发现) + +sse-replay-buffer 是全 topic 共享的 500 条环形 buffer(sse-replay-buffer.ts:23, sse.routes.ts:27)。Layer B chunk 与 `channel.message_sent`/`workunit.status_changed` 等关键事件同坑竞争:一个活跃执行每步 ~26 帧 stream chunk,500 条窗口 ≈ 19 步的纯 stream 流量即打满——多执行并发时,重连 Last-Event-ID 补发窗口内关键事件可能已被 stream chunk 挤出(replay 返回 null → 前端全量 refetch 兜底,sse-replay-buffer.ts:51-57,正确性无损,但 #491 的补发机制在高 stream 负载下实际失效)。 + +### 事件负载契约符合性 + +根 CONTEXT.md:113-115 契约:①归属身份 ②足量负载、禁 REST 补拉 ③additive ④断线重连一次性 refetch。逐条核: + +- Layer B chunk:workUnitId/executionId/channelId/step/kind/text/toolUseId 全量自足(execution-step-events.ts:302-320);useWorkUnitStreamEvents 纯内存消费零补拉(:26-38)。符合。 +- Layer A step:负载含 thinking/toolCalls/text/usage 全量摘要(:56-82);ExecutionSteps/ExecutionFlow 负载就地 append、executionId-step 去重,重连才 refetch(web/src/CONTEXT.md:89 批 4)。符合。 +- 频道消息:`channel.message_sent`/`message_updated` 均挂全量 shaped message 本体(channel-message.service.ts:144-145, 174-180,#311 ADR D1/D2)。符合。 +- 无门铃事件形态。 + +### 播报流程多余/缺失 + +- 双发观察:createAgentMessage 同 payload 发两次——eventBus `channel.message_sent` topic(:144,订阅者 = agent-loop 唤醒 agent-loop.ts:293、evolution channel-review channel-review.ts:121)+ SSE 信封经 `events` topic(:145)。两路订阅者不同,不算冗余。 +- 无明显缺失:`workunit:failed` 只落盘不进频道为显式设计(execution-step-events.ts:271-273,频道里程碑由 recordResult 负责);Layer B 无 REST 回放同为显式设计(:14-15,步级归档由 Layer A 负责)。 +- 轻微不一致:SSE 信封发布在三处各自手写(channel-message.service.ts:73-81 publishSSE、execution-step-events.ts:235-240 / 421-430),无统一 helper——风格观察,非缺陷。 + +## 候选优化手段清单(只列,不评判优先级) + +1. **stream chunk 合并帧**:publishStreamChunks 逐 chunk 发改为单行 chunks 合并一帧(数组负载)——但实测 1 chunk/行,收益近零,仅防未来多块行。 +2. **服务端时间窗批处理**:按 executionId 250-500ms 窗口合并 thinking/text chunk 成一帧,burst 期帧数可降 5-10×;抽屉端按数组展开,渲染次数同步下降。 +3. **stream 类事件不进 replay buffer**(或 buffer 按 topic 分桶):chunk 前端只留当前步、无重放价值,踢出后 500 条窗口全留给关键事件,#491 补发机制在高负载下恢复有效。 +4. **chunk 负载再裁剪**:thinking/text chunk 500 字符对 roster 动态文案(只用前几十字)与抽屉实时行均富余,可降至 ~120-200 字符,字节量立降 60%+。 +5. **兴趣信号降频**:无客户端订阅 workunits topic / 无抽屉打开时服务端跳过 build+publish——需客户端上行兴趣或按 clients 订阅 topics 判断(sse.routes 已有 clients.topics 可查,可零协议改动实现「无 workunits 订阅者则不扇出」的前半段;build 成本仍在)。 +6. **前端 rAF/微批 setState**:useWorkUnitStreamEvents/rosterActivityStore 对 burst chunk 按动画帧合批,再降渲染次数(当前已被状态下沉压住,属锦上添花)。 + +## 附:测量方法与数据口径 + +- bench 脚本(未提交):worktree `tmp-walk4/bench-sse-fanout.ts`,以 `npx tsx` 在主 checkout 跑(只读),直接 import 主 checkout 的 `buildExecutionStreamChunks` / `SseReplayBuffer` / `eventBus`。 +- 数据源:`~/.claude/projects/-root-projects-studio*` 最近 40 个会话 jsonl(33 文件,28 个含有效行);会话行与 stream-json stdout 行同构(`{type, message:{content:[blocks]}}`,过 `parseStreamLine`),过滤 type∈{assistant,user} 且 content 为块数组的行;stepPrompts=34(type=user 且 content 为 string 的行 ≈ 新步 prompt)。 +- 局限:本机会话偏短(冒烟/开发会话多),重负载步以 maxLinesPerSession=155 为上界参考;B/C 段计时为单进程微基准,不含真实 socket/内核缓冲行为(背压路径由 sse.routes.ts:84 断开策略覆盖,未测)。 diff --git a/.studio/research/2026-09-12-walk5-page-load.md b/.studio/research/2026-09-12-walk5-page-load.md new file mode 100644 index 00000000..17dd2ed4 --- /dev/null +++ b/.studio/research/2026-09-12-walk5-page-load.md @@ -0,0 +1,91 @@ +# 走查⑤ 页面加载:消息分页与读路径成本(#510) + +- 日期:2026-09-12 | 分支:research/walk5-page-load | 方法:代码走查 + bench 实测 +- bench:`apps/api/bench/walk5-page-load.ts`(本分支新增),`cd apps/api && node_modules/.bin/tsx bench/walk5-page-load.ts` 可复跑 +- 测量纪律:~/.studio 全程只读;合成规模(5k/20k/50k 行热层、12 冷月×1000 行)在 tmp 目录生成;读口耗时拆分走 #323 read-metrics 埋点(stat/readParse/clone 三段) +- 现实规模基线(2026-09-12 实测 ~/.studio):3 频道共 496K;最大频道 sys = 热 492 行/272KB + 冷 1 月 79 行/87KB;19 agents / 8 profiles;studio-events.jsonl 250KB;knowledge 18MB + +## 结论先行 + +1. **四个嫌疑点在现实数据规模下全部不构成问题**。消息分页 store 层 warm p50 = 3.2ms(热 492 行);monitoring 四端点 warm 全部 ≤ 11ms;SSE 兜底轮询每页面每分钟 6 次 × ~3ms,CPU 占比 <0.1%。 +2. **嫌疑点①(全热读+clone+sort)是真实的线性增长隐患,当前无感**:warm 成本随热层行数近似线性——492 行 3.2ms → 5k 行 27ms → 20k 行 90ms → 50k 行 252ms。structuredClone 占 55-60%,merge+全量 sort 占其余大头。热层按 30 天归档有界(`file-store.ts:219` MESSAGE_ARCHIVE_MAX_AGE_DAYS=30),当前节奏约 500 行/30 天,要 10 倍以上活跃度才到 5k 行档。 +3. **嫌疑点②(countColdLines 扫冷月算 total)是纯粹的浪费型代码,但现实成本微小**:每请求不缓存字节扫全部冷月,只为算前端不消费的 `total`(`file-store.ts:866-868` 注释自述)。实测 12 月×1000 行(~6.3MB)≈ 12-16ms/请求;现实 1 月 87KB → 亚毫秒。 +4. **消息端点不该挂 apiCache**:现实规模无收益(warm 3ms 级),且与 SSE 断线兜底轮询的语义直接冲突——兜底轮询存在的意义就是拿新消息,TTL 缓存会在窗口内喂陈旧首页(见下「apiCache 判定」)。 +5. 附带发现:**深冷页成本与锚深成正比**(符合 `file-store.ts:918-919` 设计注释):锚在最老冷月时 12 月×1000 行 warm 69ms/页,每请求对全部 12 个冷月做 cache-hit 全量 clone。前端 loadMore 逐页翻历史时逐页付此价;现实冷数据 1 月 79 行,无感。 + +## 分嫌疑点发现 + +### ① queryMessagesPage 全热文件读入 + 全量 sort 再切 50 条(`packages/studio-shared/src/file-store.ts:870-938`) + +机制确认: + +- `file-store.ts:871` resolveActiveMessages → readJsonl 全热文件读入;**cache hit 也全量 structuredClone**(`file-store.ts:334-337` cloneCached) +- `file-store.ts:873` 全量 sort,比较器 `new Date(a.createdAt).getTime()` 每次比较 2 次 Date 解析(O(n·log n) 次解析) +- 切片只取最后 50 条(`file-store.ts:881`) + +实测(read-metrics 三段拆分,warm = jsonlCache 命中): + +| 热层行数 | cold 首调 | warm p50 | warm mean | 其中 clone/次 | 残余(merge+sort 等)/次 | +|---|---|---|---|---|---| +| 492(真实 1x,含 1 冷月) | 20.6ms | 3.2ms | 4.3ms | 1.25ms | ~1.1ms | +| 5,000 | 43.5ms | 26.9ms | 29.2ms | 16.3ms | ~10ms | +| 20,000 | 159.1ms | 90.5ms | 92.0ms | 52.7ms | ~37ms | +| 50,000 | 407.4ms | 252.3ms | 262.1ms | 160.5ms | ~91ms | + +- 生产端 HTTP 端到端实测(systemd studio-api,sys 频道):warm 13-21ms,偶发 96ms(事件循环共享,agent loop 同进程);store 层占其中 ~3ms +- **判定:真问题(条件性)**。当前 1x 可忽略;增长是线性的且无分页内化(尾读)兜底,热层行数 = 30 天消息量,活跃度上一个量级即进 5k 行档(27ms/请求) + +### ② countColdLines 每请求字节扫全部冷月算 total(`file-store.ts:959-965`,调用点 `:876-877`) + +- 机制确认:`queryMessagesPage` 每请求无条件调 countColdLines(`:876`),逐月 countFileLines 64KB 块字节扫数 LF(`:985-1010`),**结果不缓存**;`total = 热 + 冷原始行数` 仅作响应字段,前端不消费(`:866-868` 注释自述「前端不消费 total」) +- 实测隔离(同热层、冷 12 月×1000 行 ~6.3MB vs 无冷,warm p50 之差):5k 热 43.2−26.9 ≈ **16ms/请求**;20k 热 102.9−90.5 ≈ **12ms/请求**。真实 1x(1 冷月 87KB):亚毫秒(含在 A1 的 3.2ms 内) +- **判定:真问题但当前成本微小**。属于「为无人消费的字段每请求付全量冷历史扫描」,冷月累积后线性变贵,且是最容易消除的一类(total 惰性化或缓存) + +### ④ SSE 断线 10s 兜底轮询叠加全热扫(`apps/web/src/hooks/useChannelEvents.ts:95`) + +- 机制确认:`useGatedPoll(fetchMessages, 10000)`,门禁 = 页面 visible ∧ SSE status ≠ 'connected'(`useGatedPoll.ts:35`),每次轮询 = 一次首页全量拉取(`channelApi.listMessages`,走嫌疑点①路径) +- 成本核算:单页面 6 次/分钟 × warm p50 3.2ms(store 层,真实 1x)≈ 19ms/分钟,单核占比 <0.1%;即便 20k 行档也就 90ms×6 = 0.54s/分钟 ≈ 单核 0.9% +- **判定:可忽略**。兜底语义正确(只在 SSE 断开时启用),成本完全继承①,①不修它不贵、①修了它更便宜 + +### ⑤ monitoring 四端点无缓存每请求重聚合(`apps/api/src/modules/monitoring/monitoring.routes.ts:12-53`) + +机制确认:四端点确实无 apiCache;但底层 FileStore 读穿缓存(readJson/readJsonl mtime 校验)+ knowledge MtimeMemoKnowledgeStore(#343)已承担「不重复读盘」。每请求重付的是 clone + 聚合 + (/overhead、/flywheel 的)窗口事件尾读。 + +实测(真实 1x,warm = 进程内缓存热): + +| 端点 | cold 首调 | warm p50 | 说明 | +|---|---|---|---| +| /agents(getAgentSummary) | 17.3ms | 1.6ms | 19 state + 8 profile + WU 上下文,全走 FileStore 缓存 | +| /stats(getStats) | 3.5ms | 1.0ms | listStates + WU index,全缓存 | +| /overhead(getOverheadStats) | 23.2ms | 4.9ms | 窗口尾读 studio-events(250KB)**不缓存**,每请求重扫 30d 窗口 | +| /flywheel ≈ store.list 1.7ms + scanKnowledgeEvents 4.5ms + computeOutcomeMetrics 4.5ms | 46.2+6.1+5.6ms | **~11ms** | memo 指纹校验 1.7ms;两次窗口事件扫描不缓存,各 ~4.5ms | + +(/flywheel 未直接测 knowledgeService 单例——`knowledge-singletons.ts:43-46` 装载期有 `pkill mcp-local-rag ingest` 副作用,bench 进程不可引入;改为实测其两个成本组件后相加,组件实现路径:`knowledge-service.ts:1012-1027, 1043-1071` → `knowledge-metrics.ts:265, 327`。/agents 的 listProjects 用等价直读 stub 替代 lazy import project.service,读量一致。) + +- **判定:可忽略(现实规模)**。监控页低频访问,warm 全部 ≤ 11ms;同文件 /overview、/efficiency 已有 60s 缓存先例(`monitoring.routes.ts:59-85` 注释),若要省也是照抄既有口径而非新机制 + +### 附带:深冷页成本(`file-store.ts:918-937`) + +锚在最老冷月时 iterateColdMessages 须先扫过全部较新冷月才到锚点:合成 12 月×1000 行,warm 69ms/页(每请求 15 个读口事件、cache-hit 全量 clone 27ms/次 + 12k 行 merge/sort 残余 ~40ms)。符合候选 8 注释「成本与页深成正比」(`:943`)的设计口径,非 bug;现实冷数据量下无感。 + +## 消息端点该不该挂 apiCache —— 不该(当前) + +1. **无收益**:真实规模 store 层 warm 3.2ms、HTTP 端到端 13-21ms;缓存省的是个位数 ms +2. **语义冲突**:10s 兜底轮询(④)的存在意义是 SSE 断线时拿到**新**消息;apiCache 按 TTL 喂陈旧首页(`api-cache.ts:19-47`,无写时失效除非逐路由接 clearCache),等于在兜底路径上自相矛盾地降低新鲜度 +3. **正确修点在存储层不在响应层**:若热层长大到咬人,该做的是首页尾读/省 total/排序快照(下表),响应缓存会把这些真正修复的必要性掩盖掉 + +## 候选优化手段清单(只列,不评判优先级) + +- queryMessagesPage 首页(无 before)改走 readJsonlTail 尾部倒读:热行数 > limit 时只读尾部 limit+1 行即可出页 + 判 hasMore,省全量读/clone/sort;同模式先例已有 getChannelVersion(`file-store.ts:776-783`)与 getMessagesSince(`:795-821`) +- 热层排序快照缓存:mtime 不变时复用 merge+sort 后的有序数组,每请求只 clone 切出的 50 条(消除嫌疑点①的 clone+sort 双大头) +- total 惰性化:路由/存储层加 includeTotal 开关,默认跳过 countColdLines(前端本就不消费) +- 冷行数随归档写路径落账(archive 写入时维护 count 元数据),countColdLines 变 O(1) 读 +- countColdLines 结果按文件 mtime 走 jsonlCache 同 seam 缓存 +- iterateColdMessages 月内改尾部倒读 + 锚查找早停(降低深冷页逐页成本) +- /overhead、/flywheel 的窗口事件扫描加短 TTL memo(照抄 /overview、/efficiency 60s 缓存口径,`monitoring.routes.ts:57,73`) + +## 复跑与原始数据 + +- bench 脚本:`apps/api/bench/walk5-page-load.ts`(本分支) +- 合成数据 tmp 根:`/tmp/walk5-bench-*`(跑完保留,手工清理) +- 生产 HTTP 计时:`curl -w '%{time_total}' http://localhost:13101/api/v1/channels//messages`(systemd studio-api,PORT=13101) diff --git a/.studio/research/2026-09-12-walk6-flow-perceivable.md b/.studio/research/2026-09-12-walk6-flow-perceivable.md new file mode 100644 index 00000000..c8379e47 --- /dev/null +++ b/.studio/research/2026-09-12-walk6-flow-perceivable.md @@ -0,0 +1,71 @@ +# 走查⑥流转链路:频道可感知的延迟与断点(#511) + +> 范围:PMO `publish` 建 plan WU → analysis-handoff 拆派 task 子单 → review-dispatcher 派评审,只查频道能感觉到的段;PMO/REQ 内部不翻。#443-447 情境引导已修断链不重复。 +> 实测:本机真实 `~/.studio/data`(49 WU / 3 频道 / 8 active profile / 最大频道 492 条消息),只读 bench 脚本 `.studio/research/walk6-bench.ts`,2026-09-12 跑。 + +## 结论先行 + +1. **三段主链全是同步建单 + 事件驱动,「对账扫描 5min 档」只是断链自愈兜底,不是常态时延。** publish 在一次 HTTP 请求内同步落频道消息 + plan WU;in_review 确认卡、done 拆派清单、review 子单都由 `workunit.status_changed` 事件链同步触发(eventBus 进程内同步 emit)。常态频道感知时延 = ms 级。 +2. **频道视角唯一无界的延迟段是人闸**:plan/analysis WU `in_review` → 人工确认,无超时无催办,确认前频道只有一张确认卡。 +3. **断链兜底的真实档位 = 10min 宽限 + 5min cron + 60s tick 量化 ≈ 最差 ~16min**;连续 3 次失败停跑升 critical。 +4. **两个频道感知断点(非 #443-447 已修部分)**:① review 子 WU 建成 happy path 频道零消息,首次感知靠认领播报,无人认领则完全静默;② 对账自愈(补建 task / 重派 review)按决议在本频道不出声,只走 #62 告警管线,频道无从得知「断过又好了」。 +5. **路由 fallback 出声成本已被 #497 冷却闸压到 1 条/小时/频道/档/原因**,单条实测 ~3ms;但 workunit-crud「未配置路由也提示」不走冷却闸,是出声成本的残留热点。 + +## 分段发现 + +### 段 1:PMO publish → 频道看到 plan 单 + +同步一段完成,无扫描档: + +- `POST /api/v1/pmo/project/:id/publish` → `projectService.publish` 一次请求内同步:建频道 human 消息(`apps/api/src/modules/pmo/project.service.ts:560`)→ 建 plan WU(:578-626)→ 回写 project.channelId(:641)。路由 fallback 提醒非阻断同请求发出(:630-637)。 +- 频道可见性:`ChannelMessageService.createHumanMessage/createAgentMessage` 内 eventBus + SSE 双发(`apps/api/src/modules/channels/channel-message.service.ts:108-109,144-145`),web 单 EventSource `/events/stream` 推送(`apps/web/src/api/websocketHooks.ts`),无轮询档。 +- plan WU 被认领:`workunit.created` EVENT 触发器即时唤醒各 loop observe(`apps/api/src/modules/agents/loop/agent-loop.ts:264-287`);事件丢失兜底 = idle 轮询 15s(`agent-loop.ts:410`)。认领即发声「『角色』已认领任务,开始执行」(`apps/api/src/modules/workunit/claim-announce.ts:33`)。 + +**时延构成**:HTTP 请求内同步落库(实测相关读口全 ms 级,见下)+ SSE 推送 + loop 事件唤醒 ≈ 秒级内频道看到「需求消息 + 认领播报」。若频道无 active 成员 loop,plan WU 滞留 unassigned,**频道无任何出声**(滞留信号只在 #62 告警管线,非本频道)。 + +### 段 2:analysis-handoff 拆派 task 子单 + +- plan WU → `in_review`:ReviewDispatcher 对 MANUAL_GATE_TYPES(含 plan)不派自动评审(`apps/api/src/modules/agents/loop/review-dispatcher.ts:58`);AnalysisHandoff 在事件链内发确认卡(`apps/api/src/modules/pmo/analysis-handoff.ts:59-62,166-173`)。eventBus emit 同步(`packages/studio-shared/src/event-bus.ts:14-26`),handler 异步 fire-and-forget,实测落盘量级 ms。 +- **人闸**:确认无超时无催办(:103-115)。无频道 + trigger 来源 + 无 TASK 的巡检单免确认直转 done(:118-129);无频道其余情形提示投 Web 收件箱(:132-143)。 +- 确认(done)→ `spawnTasks` 事件链内同步建未指派 task 子 WU + 频道发任务清单(:177-245),子 WU `workunit.created` 再唤醒认领。幂等哨兵 `analysisTasksSpawnedAt/Spawned`(:193-199)。 + +**断链兜底档**:哨兵落档 ≥10min 才参与对账(`apps/api/src/modules/agents/dispatch-reconciliation.ts:36,87`)→ cron `*/5 * * * *`(`apps/api/src/modules/agents/default-triggers.ts:144`)→ SCHEDULE 触发器 60s tick 量化(`apps/api/src/modules/triggers/trigger-scheduler.ts:20,127`)。**最差检测 ≈ 10 + 5 + 1 = 16min**;3 次失败停跑升 critical(dispatch-reconciliation.ts:38,117-123)。 + +**断点 2a**:对账补建(`respawnScopes`)在本频道不出声(dispatch-reconciliation.ts:6,48 注释,决议 5;`analysis-handoff.ts:320-324` 注释),也不补发任务清单——频道对「补建的子单」的感知只剩后来的认领播报;自愈/失败告警走 #62 管线:`monitor:alert` 事件 + `notifyAlert`(告警频道 + 企业微信 webhook,`apps/api/src/modules/agents/monitor/monitor-alerts.ts:100-121`),均非本频道。 + +### 段 3:review-dispatcher 派评审 + +- 父 WU → `in_review`:事件链内同步建 review 子 WU,锁内同父唯一性 guard(review-dispatcher.ts:58-62,109-116,308-322)。 +- **断点 3a(happy path 静默)**:建成时频道零消息——只有路由 fallback(:187-193)与自评兜底(:195-205)两种异常才出声。频道首次感知 = 某成员认领播报;若频道内无可认领成员(除实现者外无人 / 都忙),review 子单滞留 unassigned,**频道全程静默**,仅 Web WU 列表可见。 +- 评审收口:子 WU done → 解析 `reviewReport` → 父 reviewPassed/reviewRejected(:342-413);merge-on-review-pass 发里程碑合并消息(`apps/api/src/modules/workunit/merge-on-review-pass.ts:256-298`);REVIEW_RESULT 缺失转人工频道消息(review-dispatcher.ts:371-374);迟到 reject 转人工复核(:395-407)。 +- 断链兜底同段 2 档:in_review 持续 ≥10min(updatedAt 锚)+ 5min cron + 60s tick ≈ 最差 16min 幂等重派(dispatch-reconciliation.ts:165-195);人工补派端点 `dispatchReviewNow`(review-dispatcher.ts:221-247)。 + +### #466/#477/#497 路由表 fallback 出声成本 + +- 收口骨架 `resolveOrNotice`(`apps/api/src/modules/channels/routing.ts:101-116`),4 个调用点:publish plan 档(project.service.ts:575-577)、analysis-handoff implement 档(analysis-handoff.ts:214-218)、review-dispatcher review 档(review-dispatcher.ts:165)、workunit-crud feature 展开 implement 档(`apps/api/src/modules/workunit/workunit-crud.ts:307-310`)。 +- 冷却闸:同频道同档同原因 1h 窗内只出声 1 次(routing.ts:123,131-142);进程内 Map,重启即重置、补一条可接受(:121-122 注释)。配错场景出声上限 = 3 档 × 1 条/小时/频道。 +- **残留热点**:workunit-crud 的 `notConfiguredText`「未配置也提示」**不走冷却闸**(routing.ts:88-89,115——明示与改造前逐点行为一致),feature 展开频次高时每次展开都出声一条。 +- 单条出声实测成本:无 anchor 时 `findAnchorMessage` 频道内 `queryMessages` p50 2.8ms / p95 4.6ms(最大频道 492 条消息,bench B1;`wu-messenger.ts:44-53`)+ JSONL append,可忽略。注意 #494 的 `anchorMessageId` 只落在 @mention 派单路径(`apps/api/src/modules/channels/message-routing.ts:312-336,465-482`),publish/拆派/评审建单不落,anchor 查找失败会退到跨频道全扫描(wu-messenger.ts:46-48)。 + +### 实测数字(bench `.studio/research/walk6-bench.ts`,n=20,本机真实数据) + +| 测量 | min | p50 | p95 | +|---|---|---|---| +| getIndex(49 WU,对账/建单 guard 每 tick 必读) | 0.2ms | 0.2ms | 3.1ms | +| 对账 analysis 侧读口(done analysis/plan 过滤,现存 1 候选) | 0.2ms | 0.2ms | 0.8ms | +| 对账 review 侧读口(in_review + 快照,现存 0 候选) | 0.2ms | 0.3ms | 1.7ms | +| listProfiles(active)(路由解析口径,8 profile) | 0.6ms | 0.8ms | 3.5ms | +| queryMessages 最大频道(492 条,anchor 查找口径) | 1.7ms | 2.8ms | 4.6ms | +| getChannel | 0.1ms | 0.1ms | 0.2ms | + +结论:当前数据规模下系统侧时延全是设计等待(人闸、cron 量化),IO 完全不构成延迟;数据规模小(49 WU),数字只证明量级、不代表 10x/50x 后仍如此(loop-read-metrics 先例可扩档复测)。 + +## 候选优化手段清单(只列不评判) + +1. review 子 WU 建成时在父 WU 线程补一条轻量「已派评审(#子单号)」系统消息,消除 happy path 静默段。 +2. 对账自愈(补建 task / 重派 review)后在本频道补一条「断链已自愈」消息(现决议 5 为不出声,改需过决议)。 +3. WU unassigned 滞留超 N 分钟在本频道出声提醒(现仅 #62 告警管线,非本频道)。 +4. plan/analysis 人闸超龄催办:in_review 待确认超 X 小时在频道再提醒一次。 +5. workunit-crud「未配置路由」提示纳入冷却闸或改为一次性提示(当前每次 feature 展开都出声)。 +6. publish / spawnTasks / review 建单路径落 `anchorMessageId`(对齐 #494 mention 路径),消除 anchor 查找跨频道全扫描回退与独立根消息。 +7. 对账告警 `notifyAlert` 载荷带本频道/WU 直链(现已带 wuId 链,monitor-alerts.ts:116-119;可评估回本频道的开关)。 diff --git a/AGENTS.md b/AGENTS.md index 746b3594..62e68853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ pnpm start # 启动生产服务 ## Governance Rules - + ### Iron Laws (违反将阻断) - **no_completion_without_verification**: 在声明任务完成前,必须重新运行新鲜的验证命令——受改动影响的测试(vitest run --changed origin/master)+ type check,使用新鲜的输出作为完成证据,不得复用旧结果。全量测试由 CI / 发布流程兜底。 - **incremental_progress**: 一次只处理一个任务。改动涉及多个模块、超过 100 行、或影响多个文件时,必须拆分为小步骤分步执行,每步有独立 checkpoint 可回滚。不要试图一次性完成所有改动。 diff --git a/CONTEXT.md b/CONTEXT.md index 3257ad18..0ef1fb77 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -94,6 +94,10 @@ _Avoid_: tab 面板、静态状态堆叠 「流转信息」按场景分两个词(#377 Q7 决议):频道域 = **流转链路**——一条需求从频道讨论 → REQ → 拆 WU → 交付的端到端链路(「正在做的事情的链路」);WU 详情 = **流转时间线**——单 WU 生命周期(待领取→进行中→待验收→完成 + 挂起/恢复等事件)。两者数据源与形态不同,不混用。 _Avoid_: 一个词两种用法、频道右栏放单 WU 时间线 +**频道主链路**: +一条频道消息的系统旅程(2026-09-12 频道流程与性能优化图开图决议):消息进(发送/附件)→ 路由派单(routeMessage:replyTo 线程 → @mention → 默认角色合并窗口 → 纯文本)→ 认领(loop 拾取 WU)→ 回执呈现(系统播报/SSE)→ 页面加载(消息分页/列表渲染)。与「流转链路」分家:主链路 = 单条消息在系统内的处理路径,流转链路 = 一条需求跨模块的端到端推进;两者只在「流转链路频道可感知段」(派单慢、状态不回传、断链)相交。 +_Avoid_: 一词混指两条链路、把 PMO 内部流程算作主链路 + **项目阶段**: 项目级生命周期四词(#385 决议,2026-08-29):**讨论→开发→验收→交付**,PMO 项目页 stepper 专用。与 WU 状态词(待领取/进行中/待验收/完成)刻意不同词——同名不同义曾是「三处进度表达互相矛盾」观感的来源。 _Avoid_: 项目阶段复用 WU 状态词(进行中/待验收) diff --git a/apps/api/bench/CONTEXT.md b/apps/api/bench/CONTEXT.md index c82afdef..423de8e1 100644 --- a/apps/api/bench/CONTEXT.md +++ b/apps/api/bench/CONTEXT.md @@ -4,6 +4,8 @@ #323 阶段一(周期循环读口量化测量)的基准 harness:以真实 `~/.studio` 为 1x 模板合成 1x/10x/50x 数据集到 tmp,子进程驱动 8 个周期循环体各 N 轮(外加 monitor 日级窗口补测),聚合产出 markdown 报告。**去留随 #323 评审**(阶段一为一次性测量,不作为常驻工具维护)。 +#521(2026-09-13)新增常驻测量工具:`mainline-align.ts`(频道主链路离线对齐,随六份走查 bench 先例 `npx tsx` 直跑)——只读三家数据源(频道消息热文件 / WU 快照 / studio-events 事件流)对齐出「派单 / 等认领 / 执行总时长(含步级分解)/ 回执落库」四段 p50/p95 分布 + 按 traceId 单链明细;口径与数据结构见 `mainline-align-core.ts` 头注。 + ### 核心导出 | 导出 | 文件 | 说明 | @@ -12,6 +14,8 @@ | `parseArgs` | loop-read-metrics.ts | bench 入口参数解析(`--rounds`/`--scales`);入口脚本(tsx 直跑)合成 → 子进程驱动 → 聚合 | | `bucketOfFor` | loop-read-worker.ts | 读口事件按存储源分桶(wu-index/studio-events/agent-state/...);worker 为每档子进程 | | `summarize` / `renderMarkdown` / `loadWorkerResults` | read-metrics-aggregate.ts | 轮次聚合(分桶计数、各阶段 P50/P95、残差)与 markdown 渲染纯函数 | +| `buildChains` / `summarizeChains` / `filterChainsByWindow` / `findChainsByTraceId` | mainline-align-core.ts | #521 主链路对齐纯函数:三家数据行 → 单链四段(缺失段 skips 标注)→ 分布/明细 | +| `parseArgs` / `loadInput` | mainline-align.ts | #521 CLI 壳参数解析(`--since 24h\|7d\|ISO` / `--until` / `--traceId` / `--studio-home` / `--events-file`)与三家数据源读取(缺文件降级空源 + 数据提示) | ### 注意事项 diff --git a/apps/api/bench/__tests__/claim-latency.test.ts b/apps/api/bench/__tests__/claim-latency.test.ts new file mode 100644 index 00000000..cb5d4e51 --- /dev/null +++ b/apps/api/bench/__tests__/claim-latency.test.ts @@ -0,0 +1,37 @@ +/** + * #508 走查③ 认领延迟 bench:纯函数(parseArgs/pct/summary)测试。 + * bench 主流程为一次性测量脚本(真 AgentLoop + tmp 合成数据集),不进单测。 + */ +import { describe, it, expect } from 'vitest'; +import { parseArgs, pct, summary } from '../claim-latency.js'; + +describe('parseArgs', () => { + it('默认 5 轮', () => { + expect(parseArgs([])).toEqual({ rounds: 5 }); + }); + + it('自定义 --rounds', () => { + expect(parseArgs(['--rounds', '3'])).toEqual({ rounds: 3 }); + }); +}); + +describe('pct', () => { + it('p50 取中位下标,输入无序也能算', () => { + expect(pct([30, 10, 20], 50)).toBe(20); + }); + + it('p95 不越界(小样本顶到 max)', () => { + expect(pct([1, 2, 3], 95)).toBe(3); + }); +}); + +describe('summary', () => { + it('输出含 n/min/p50/p95/max/mean 六段', () => { + const s = summary([10, 20, 30]); + expect(s).toContain('n=3'); + expect(s).toContain('min=10'); + expect(s).toContain('p50=20'); + expect(s).toContain('max=30'); + expect(s).toContain('mean=20.0'); + }); +}); diff --git a/apps/api/bench/__tests__/mainline-align-core.test.ts b/apps/api/bench/__tests__/mainline-align-core.test.ts new file mode 100644 index 00000000..a9a2b57a --- /dev/null +++ b/apps/api/bench/__tests__/mainline-align-core.test.ts @@ -0,0 +1,226 @@ +/** + * #521(spec .studio/specs/2026-09-12-channel-mainline-measurement §2): + * 离线对齐工具纯函数测试——合成消息/WU/事件三份样本(含已知时间差), + * 断言四段分布(派单/等认领/执行总时长/回执落库)与单链明细正确; + * 缺失段样本如实标注跳过;时间窗过滤与按 traceId 单链查询可用。 + */ +import { describe, it, expect } from 'vitest'; +import { + buildChains, + filterChainsByWindow, + summarizeChains, + findChainsByTraceId, + type ChannelMessageRow, + type WorkUnitSnapshotRow, + type StudioEventRow, +} from '../mainline-align-core.js'; + +const T0 = Date.parse('2026-09-12T10:00:00.000Z'); +const iso = (offsetMs: number) => new Date(T0 + offsetMs).toISOString(); + +// ── 合成样本:三条链路,时间差全已知 ── +// 链 A(完整四段):消息落库 +2s 建单,+10s 认领,执行 60s(两步:+20s/+60s),回执 +3s 落库 +// 链 B(缺回执):+1s 建单,+5s 认领,执行 30s,无回执消息 +// 链 C(未认领):+4s 建单,无 claimedAt/completedAt +const msgADispatch: ChannelMessageRow = { + id: 'msg-a', channelId: 'ch1', authorType: 'human', workUnitId: 'wu-a', createdAt: iso(0), +}; +const msgAReceipt: ChannelMessageRow = { + id: 'msg-a-receipt', channelId: 'ch1', authorType: 'agent', workUnitId: 'wu-a', createdAt: iso(2_000 + 10_000 + 60_000 + 3_000), +}; +const msgBDispatch: ChannelMessageRow = { + id: 'msg-b', channelId: 'ch1', authorType: 'human', workUnitId: 'wu-b', createdAt: iso(100_000), +}; +const msgCDispatch: ChannelMessageRow = { + id: 'msg-c', channelId: 'ch1', authorType: 'human', workUnitId: 'wu-c', createdAt: iso(200_000), +}; +// 同 id 更新副本(linkWorkUnit 回填):后行者胜出,createdAt 不变 +const msgADispatchBackfill: ChannelMessageRow = { + ...msgADispatch, workUnitId: 'wu-a', +}; + +const wuA: WorkUnitSnapshotRow = { + id: 'wu-a', channelId: 'ch1', status: 'closed', + createdAt: iso(2_000), claimedAt: iso(12_000), completedAt: iso(72_000), + metadata: JSON.stringify({ traceId: 'trace-a', anchorMessageId: 'msg-a' }), +}; +const wuB: WorkUnitSnapshotRow = { + id: 'wu-b', channelId: 'ch1', status: 'closed', + createdAt: iso(101_000), claimedAt: iso(106_000), completedAt: iso(136_000), + metadata: JSON.stringify({ traceId: 'trace-b', anchorMessageId: 'msg-b' }), +}; +const wuC: WorkUnitSnapshotRow = { + id: 'wu-c', channelId: 'ch1', status: 'unassigned', + createdAt: iso(204_000), claimedAt: null, completedAt: null, + metadata: JSON.stringify({ traceId: 'trace-c', anchorMessageId: 'msg-c' }), +}; + +const stepEvents: StudioEventRow[] = [ + { + type: 'workunit:execution_step', + payload: JSON.stringify({ workUnitId: 'wu-a', executionId: 'exec-a', step: 1, status: 'success', at: iso(32_000) }), + createdAt: iso(32_000), + }, + { + type: 'workunit:execution_step', + payload: JSON.stringify({ workUnitId: 'wu-a', executionId: 'exec-a', step: 2, status: 'success', at: iso(72_000) }), + createdAt: iso(72_000), + }, + { + type: 'workunit:execution_step', + payload: JSON.stringify({ workUnitId: 'wu-b', executionId: 'exec-b', step: 1, status: 'failed', at: iso(136_000) }), + createdAt: iso(136_000), + }, + { type: 'client.perf.receipt_render', source: 'web-client', payload: JSON.stringify({ messageId: 'msg-a-receipt', workUnitId: 'wu-a', ms: 42 }), createdAt: iso(76_000) }, + { type: 'knowledge:distill', payload: JSON.stringify({ foo: 1 }), createdAt: iso(80_000) }, +]; + +const baseInput = () => ({ + messages: [msgADispatch, msgAReceipt, msgBDispatch, msgCDispatch, msgADispatchBackfill], + workunits: [wuA, wuB, wuC], + events: stepEvents, +}); + +describe('buildChains', () => { + it('对齐出四段已知时间差;步级分解正确', () => { + const chains = buildChains(baseInput()); + expect(chains).toHaveLength(3); + const a = chains.find(c => c.workUnitId === 'wu-a')!; + expect(a.traceId).toBe('trace-a'); + expect(a.dispatchMessageId).toBe('msg-a'); + expect(a.dispatchMs).toBe(2_000); + expect(a.waitClaimMs).toBe(10_000); + expect(a.executionMs).toBe(60_000); + expect(a.steps).toEqual([ + { step: 1, at: T0 + 32_000, deltaMs: 20_000, status: 'success' }, + { step: 2, at: T0 + 72_000, deltaMs: 40_000, status: 'success' }, + ]); + expect(a.receiptMessageId).toBe('msg-a-receipt'); + expect(a.receiptMs).toBe(3_000); + expect(a.skips).toEqual([]); + }); + + it('缺失段如实标注跳过,不静默丢样本', () => { + const chains = buildChains(baseInput()); + const b = chains.find(c => c.workUnitId === 'wu-b')!; + expect(b.dispatchMs).toBe(1_000); + expect(b.waitClaimMs).toBe(5_000); + expect(b.executionMs).toBe(30_000); + expect(b.receiptMs).toBeNull(); + expect(b.skips).toContain('receipt:no_receipt_message'); + + const c = chains.find(c => c.workUnitId === 'wu-c')!; + expect(c.dispatchMs).toBe(4_000); + expect(c.waitClaimMs).toBeNull(); + expect(c.executionMs).toBeNull(); + expect(c.receiptMs).toBeNull(); + expect(c.skips).toContain('waitClaim:no_claimedAt'); + expect(c.skips).toContain('execution:no_claimedAt'); + expect(c.skips).toContain('receipt:no_completedAt'); + }); + + it('无 anchorMessageId 时回退到最早的人类派发消息;晚于建单的人类消息(合并窗口搭车)不算派发', () => { + const mergedMsg: ChannelMessageRow = { + id: 'msg-d-merge', channelId: 'ch1', authorType: 'human', workUnitId: 'wu-d', createdAt: iso(500_000), + }; + const dispatchD: ChannelMessageRow = { + id: 'msg-d', channelId: 'ch1', authorType: 'human', workUnitId: 'wu-d', createdAt: iso(400_000), + }; + const wuD: WorkUnitSnapshotRow = { + id: 'wu-d', channelId: 'ch1', status: 'active', + createdAt: iso(403_000), claimedAt: iso(408_000), completedAt: null, + metadata: JSON.stringify({ traceId: 'trace-d' }), // 无 anchorMessageId + }; + const chains = buildChains({ messages: [mergedMsg, dispatchD], workunits: [wuD], events: [] }); + const d = chains.find(c => c.workUnitId === 'wu-d')!; + expect(d.dispatchMessageId).toBe('msg-d'); + expect(d.dispatchMs).toBe(3_000); + }); + + it('找不到派发消息时派单段标注跳过', () => { + const wuOrphan: WorkUnitSnapshotRow = { + id: 'wu-orphan', channelId: 'ch9', status: 'closed', + createdAt: iso(600_000), claimedAt: iso(601_000), completedAt: iso(602_000), + metadata: JSON.stringify({ traceId: 'trace-o', anchorMessageId: 'msg-gone' }), + }; + const chains = buildChains({ messages: [], workunits: [wuOrphan], events: [] }); + const o = chains[0]; + expect(o.dispatchMs).toBeNull(); + expect(o.skips).toContain('dispatch:no_dispatch_message'); + expect(o.waitClaimMs).toBe(1_000); + }); + + it('畸形 metadata / 缺失时间字段不抛错,相应段标注跳过', () => { + const wuBad: WorkUnitSnapshotRow = { + id: 'wu-bad', channelId: 'ch1', status: 'closed', + createdAt: undefined, claimedAt: undefined, completedAt: undefined, + metadata: '{not-json', + }; + const chains = buildChains({ messages: [], workunits: [wuBad], events: [] }); + const bad = chains[0]; + expect(bad.traceId).toBeNull(); + expect(bad.dispatchMs).toBeNull(); + expect(bad.skips).toContain('dispatch:no_wu_createdAt'); + expect(bad.skips).toContain('waitClaim:no_wu_createdAt'); + expect(bad.skips).toContain('execution:no_claimedAt'); + }); +}); + +describe('summarizeChains', () => { + it('四段分布 p50/p95 正确;跳过原因计数', () => { + const report = summarizeChains(buildChains(baseInput())); + expect(report.chainCount).toBe(3); + // 派单样本 [2000, 1000, 4000] → 排序 [1000, 2000, 4000] + expect(report.segments.dispatch.n).toBe(3); + expect(report.segments.dispatch.p50Ms).toBe(2_000); + expect(report.segments.dispatch.p95Ms).toBe(4_000); + // 等认领 [10000, 5000](nearest-rank:偶数样本 p50 取下中位);wu-c 缺 claimedAt 跳过 + expect(report.segments.waitClaim.n).toBe(2); + expect(report.segments.waitClaim.p50Ms).toBe(5_000); + expect(report.segments.waitClaim.skipped['waitClaim:no_claimedAt']).toBe(1); + // 执行 [60000, 30000] + expect(report.segments.execution.n).toBe(2); + expect(report.segments.execution.p50Ms).toBe(30_000); + expect(report.segments.execution.p95Ms).toBe(60_000); + // 回执 [3000];b 无回执消息、c 未完成各跳过 + expect(report.segments.receipt.n).toBe(1); + expect(report.segments.receipt.p50Ms).toBe(3_000); + expect(report.segments.receipt.skipped['receipt:no_receipt_message']).toBe(1); + expect(report.segments.receipt.skipped['receipt:no_completedAt']).toBe(1); + }); + + it('client.perf 事件有样本出分布,无样本如实标注', () => { + const report = summarizeChains(buildChains(baseInput()), baseInput().events); + expect(report.clientPerf['client.perf.receipt_render'].n).toBe(1); + expect(report.clientPerf['client.perf.receipt_render'].p50Ms).toBe(42); + expect(report.clientPerf['client.perf.send_click'].n).toBe(0); + expect(report.clientPerf['client.perf.send_click'].note).toBe('no_samples'); + + const empty = summarizeChains(buildChains(baseInput()), []); + expect(empty.clientPerf['client.perf.receipt_render'].n).toBe(0); + expect(empty.clientPerf['client.perf.receipt_render'].note).toBe('no_samples'); + }); +}); + +describe('filterChainsByWindow', () => { + it('按 WU 建成时间过滤', () => { + const chains = buildChains(baseInput()); + const win = filterChainsByWindow(chains, { sinceMs: T0 + 50_000, untilMs: T0 + 150_000 }); + expect(win.map(c => c.workUnitId)).toEqual(['wu-b']); + const sinceOnly = filterChainsByWindow(chains, { sinceMs: T0 + 150_000 }); + expect(sinceOnly.map(c => c.workUnitId)).toEqual(['wu-c']); + const all = filterChainsByWindow(chains, {}); + expect(all).toHaveLength(3); + }); +}); + +describe('findChainsByTraceId', () => { + it('按 traceId 查单链明细(含消息与步)', () => { + const chains = buildChains(baseInput()); + const hits = findChainsByTraceId(chains, 'trace-a'); + expect(hits).toHaveLength(1); + expect(hits[0].workUnitId).toBe('wu-a'); + expect(hits[0].steps).toHaveLength(2); + expect(findChainsByTraceId(chains, 'trace-nonexistent')).toEqual([]); + }); +}); diff --git a/apps/api/bench/__tests__/mainline-align.test.ts b/apps/api/bench/__tests__/mainline-align.test.ts new file mode 100644 index 00000000..f78e8433 --- /dev/null +++ b/apps/api/bench/__tests__/mainline-align.test.ts @@ -0,0 +1,86 @@ +/** + * #521 离线对齐工具命令行壳测试:参数解析(相对/绝对时间窗、traceId、数据根覆盖) + * 与数据加载(合成 tmp studio-home;缺目录/缺文件优雅降级为空数据源 + 数据提示)。 + * 壳的报表渲染不逐字断言(spec:命令行壳不测——此处只锁参数与读口契约)。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { parseArgs, loadInput } from '../mainline-align.js'; + +const NOW = Date.parse('2026-09-12T12:00:00.000Z'); + +describe('parseArgs', () => { + it('相对时间窗(24h/7d/30m)换算为 sinceMs', () => { + expect(parseArgs(['--since', '24h'], NOW).sinceMs).toBe(NOW - 24 * 3_600_000); + expect(parseArgs(['--since', '7d'], NOW).sinceMs).toBe(NOW - 7 * 86_400_000); + expect(parseArgs(['--since', '30m'], NOW).sinceMs).toBe(NOW - 30 * 60_000); + }); + + it('ISO 时间与 --until;缺省无窗', () => { + const a = parseArgs(['--since', '2026-09-11T00:00:00Z', '--until', '2026-09-12T00:00:00Z'], NOW); + expect(a.sinceMs).toBe(Date.parse('2026-09-11T00:00:00Z')); + expect(a.untilMs).toBe(Date.parse('2026-09-12T00:00:00Z')); + const none = parseArgs([], NOW); + expect(none.sinceMs).toBeNull(); + expect(none.untilMs).toBeNull(); + }); + + it('非法时间值 → null(按无窗处理,不抛错)', () => { + expect(parseArgs(['--since', 'not-a-time'], NOW).sinceMs).toBeNull(); + }); + + it('--traceId 与 --studio-home/--events-file 覆盖', () => { + const a = parseArgs(['--traceId', 'trace-x', '--studio-home', '/tmp/sh', '--events-file', '/tmp/ev.jsonl'], NOW); + expect(a.traceId).toBe('trace-x'); + expect(a.studioHome).toBe('/tmp/sh'); + expect(a.eventsFile).toBe('/tmp/ev.jsonl'); + }); +}); + +describe('loadInput', () => { + let home: string; + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'mainline-align-')); + }); + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + const args = (over: Partial> = {}) => ({ + sinceMs: null, untilMs: null, traceId: null, studioHome: home, eventsFile: null, ...over, + }); + + it('读取三家数据源:频道消息热文件 / WU 快照 / 事件流;损坏行跳过', () => { + const chDir = path.join(home, 'data', 'channels', 'ch1'); + fs.mkdirSync(chDir, { recursive: true }); + fs.writeFileSync(path.join(chDir, 'messages.jsonl'), [ + JSON.stringify({ id: 'm1', channelId: 'ch1', authorType: 'human', createdAt: '2026-09-12T10:00:00Z' }), + '{bad-line', + JSON.stringify({ id: 'm2', channelId: 'ch1', authorType: 'agent', workUnitId: 'wu1', createdAt: '2026-09-12T10:01:00Z' }), + ].join('\n')); + fs.mkdirSync(path.join(home, 'data', 'workunits'), { recursive: true }); + fs.writeFileSync(path.join(home, 'data', 'workunits', 'index.json'), JSON.stringify([ + { id: 'wu1', channelId: 'ch1', createdAt: '2026-09-12T10:00:02Z' }, + ])); + fs.mkdirSync(path.join(home, 'logs'), { recursive: true }); + fs.writeFileSync(path.join(home, 'logs', 'studio-events.jsonl'), JSON.stringify({ + type: 'workunit:execution_step', payload: '{"workUnitId":"wu1","step":1,"at":"2026-09-12T10:00:30Z"}', createdAt: '2026-09-12T10:00:30Z', + }) + '\n'); + + const input = loadInput(args()); + expect(input.messages).toHaveLength(2); // 损坏行跳过 + expect(input.workunits).toHaveLength(1); + expect(input.events).toHaveLength(1); + expect(input.dataNotes).toEqual([]); + }); + + it('目录/文件缺失 → 空数据源 + 数据提示,不抛错', () => { + const input = loadInput(args({ studioHome: path.join(home, 'missing') })); + expect(input.messages).toEqual([]); + expect(input.workunits).toEqual([]); + expect(input.events).toEqual([]); + expect(input.dataNotes.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/apps/api/bench/__tests__/route-dispatch-merge-window.test.ts b/apps/api/bench/__tests__/route-dispatch-merge-window.test.ts new file mode 100644 index 00000000..786dd9f7 --- /dev/null +++ b/apps/api/bench/__tests__/route-dispatch-merge-window.test.ts @@ -0,0 +1,77 @@ +// #507 bench 脚本纯函数单测:tailScanHuman / tailScanFirstHumanWu / stats +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { tailScanHuman, tailScanFirstHumanWu, stats } from '../route-dispatch-merge-window.js'; + +let tmpDir = ''; +let file = ''; + +function row(id: string, authorType: string, workUnitId: string | null = null) { + return JSON.stringify({ id, channelId: 'c1', authorType, agentName: null, content: 'x', replyToId: null, meta: '{}', workUnitId, createdAt: '2026-09-12T00:00:00.000Z' }); +} + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-dispatch-bench-test-')); + file = path.join(tmpDir, 'messages.jsonl'); + // 8 行:agent 多数;human 两条(h1 无 wu、h2 带 wu);尾部 agent 收尾 + const lines = [ + row('a1', 'agent'), + row('h1', 'human'), + row('a2', 'agent'), + row('h2', 'human', 'wu-1'), + row('a3', 'agent'), + row('a4', 'agent'), + // 更新副本:h1 的 meta 更新 append 在尾(createdAt 不变,#317 形态) + row('h1', 'human'), + row('a5', 'agent'), + ]; + fs.writeFileSync(file, lines.join('\n') + '\n'); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('tailScanHuman', () => { + it('倒扫去重收集 human,凑满 need 即停', async () => { + const res = await tailScanHuman(file, 2); + // h1 计一次(尾部副本先见、原行被去重)+ h2 = 2 条即停 + expect(res.count).toBe(2); + expect(res.scanned).toBeLessThan(8); + }); + + it('need 超过总数时扫到文件头', async () => { + const res = await tailScanHuman(file, 20); + expect(res.count).toBe(2); + expect(res.scanned).toBe(8); + }); +}); + +describe('tailScanFirstHumanWu', () => { + it('遇第一条去重后 human+workUnitId 即停', async () => { + const res = await tailScanFirstHumanWu(file); + expect(res.found).toBe(true); + // 从尾部 a5 → h1副本 → a4 → a3 → h2 命中 = 5 行 + expect(res.scanned).toBe(5); + }); + + it('无 human+workUnitId 时扫全文件返回 false', async () => { + const empty = path.join(tmpDir, 'empty.jsonl'); + fs.writeFileSync(empty, [row('a1', 'agent'), row('h9', 'human')].join('\n') + '\n'); + const res = await tailScanFirstHumanWu(empty); + expect(res.found).toBe(false); + expect(res.scanned).toBe(2); + }); +}); + +describe('stats', () => { + it('min/median/p95/mean 基本口径', () => { + const r = stats([1, 2, 3, 4, 10]); + expect(r.min).toBe(1); + expect(r.median).toBe(3); + expect(r.mean).toBe(4); + expect(r.p95).toBeGreaterThanOrEqual(r.median); + }); +}); diff --git a/apps/api/bench/__tests__/walk5-page-load.test.ts b/apps/api/bench/__tests__/walk5-page-load.test.ts new file mode 100644 index 00000000..6c06ffea --- /dev/null +++ b/apps/api/bench/__tests__/walk5-page-load.test.ts @@ -0,0 +1,57 @@ +/** + * #510 走查⑤ bench:纯函数/合成器测试(statOf 统计 + synthChannel 数据合成)。 + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { statOf, synthChannel } from '../walk5-page-load.js'; + +describe('statOf', () => { + it('单调序列:p50/p95/max 与均值正确', () => { + const s = statOf([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(s.n).toBe(10); + expect(s.mean).toBeCloseTo(5.5); + expect(s.p50).toBe(6); // floor(0.5*10)=5 → 升序第 6 个元素 + expect(s.p95).toBe(10); // floor(0.95*10)=9 → 末元素 + expect(s.max).toBe(10); + }); + + it('单元素:各分位恒等', () => { + const s = statOf([42]); + expect(s).toEqual({ n: 1, mean: 42, p50: 42, p95: 42, max: 42 }); + }); +}); + +describe('synthChannel', () => { + it('生成热文件 + 冷月文件,行数与命名符合 FileStore 布局', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'walk5-test-')); + try { + synthChannel(base, 'ch', 3, 2, 4); + const dir = path.join(base, 'channels', 'ch'); + const hot = fs.readFileSync(path.join(dir, 'messages.jsonl'), 'utf-8').trim().split('\n'); + expect(hot).toHaveLength(3); + const row = JSON.parse(hot[0]); + expect(row.channelId).toBe('ch'); + expect(row.id).toBe('m-ch-0'); + const months = fs.readdirSync(path.join(dir, 'archive')).sort(); + expect(months).toEqual(['messages-2024-01.jsonl', 'messages-2024-02.jsonl']); + const cold = fs.readFileSync(path.join(dir, 'archive', months[0]), 'utf-8').trim().split('\n'); + expect(cold).toHaveLength(4); + // 冷行 id 接在热行之后(深冷页锚点定位依赖此约定) + expect(JSON.parse(cold[0]).id).toBe('m-ch-3'); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); + + it('coldMonths=0 时不建 archive 目录', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'walk5-test-')); + try { + synthChannel(base, 'ch', 2, 0, 0); + expect(fs.existsSync(path.join(base, 'channels', 'ch', 'archive'))).toBe(false); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/api/bench/claim-latency.ts b/apps/api/bench/claim-latency.ts new file mode 100644 index 00000000..b487cbec --- /dev/null +++ b/apps/api/bench/claim-latency.ts @@ -0,0 +1,253 @@ +/** + * #508 走查③「认领延迟」bench:WU 建成 → loop 拾取 → 首个执行步开始的时延构成实测。 + * + * 用法(在 apps/api 下):npx tsx bench/claim-latency.ts [--rounds N](默认 5 轮) + * + * 数据:以真实 ~/.studio 为 1x 模板经 synthesize-dataset 合成到 tmp(模板只读), + * 全部写落合成副本,不碰生产数据区。 + * + * Phase A 组件耗时(合成副本上采样):create / observe 三读口(getIndex + listChannels + * + queryAllMessages)/ claim(含 flock + 租约写 + status_changed)。 + * Phase B 真 AgentLoop 端到端(健康探针走真实 `claude --version`;WU scope 带 test + * 特征词触发 B2 守卫——认领后立即关闭,不起 LLM 会话、不烧 token): + * B1 拾取延迟:建 unassigned WU(指名本 role)→ 轮询 index 拿 claimedAt,N 轮随机相位; + * 同时记录 getIndex 调用时刻,验证 workunit.created EVENT 触发的 observe 是否带来认领。 + * B2 事件唤醒延迟:blocked WU(assignee=实例)进入 lastActiveWuIds 后发人类消息, + * 测量 message_sent publish → 下一次 observe(getIndex) 的时延。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// 数据区隔离必须在任何 studio 模块 import 前生效(ESM 提升 → studio 模块全部走动态 import); +// 收进 initBenchEnv 只在直跑 main 时执行,vitest import 本模块测纯函数无副作用 +function initBenchEnv(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'claim-latency-bench-')); + process.env.STUDIO_HOME = root; + process.env.STUDIO_DATA_DIR = path.join(root, 'data'); + process.env.STUDIO_EVENTS_JSONL = path.join(root, 'logs', 'studio-events.jsonl'); + delete process.env.VITEST; + delete process.env.NODE_ENV; + return root; +} + +export function parseArgs(argv: string[] = process.argv.slice(2)): { rounds: number } { + const i = argv.indexOf('--rounds'); + return { rounds: i >= 0 ? Number(argv[i + 1]) : 5 }; +} + +export function pct(xs: number[], p: number): number { + const s = [...xs].sort((a, b) => a - b); + return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; +} + +export function summary(xs: number[]): string { + return `n=${xs.length} min=${Math.min(...xs)} p50=${pct(xs, 50)} p95=${pct(xs, 95)} max=${Math.max(...xs)} mean=${(xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(1)}`; +} + +interface Mark { at: number; note: string } + +async function main(): Promise { + const { rounds: ROUNDS } = parseArgs(); + const benchRoot = initBenchEnv(); + const { synthesizeDataset } = await import('./synthesize-dataset.js'); + const { FileStore, eventBus } = await import('@dommaker/studio-shared'); + const { WorkUnitService } = await import('../src/modules/workunit/workunit.service.js'); + const { ChannelMessageService } = await import('../src/modules/channels/channel-message.service.js'); + const { AgentLoop } = await import('../src/modules/agents/loop/agent-loop.js'); + + // ── 合成 1x 数据集(模板 ~/.studio 只读)── + const templateHome = path.join(os.homedir(), '.studio'); + const stats = synthesizeDataset({ templateHome, outHome: benchRoot, scale: 1, recentExecCount: 0 }); + console.log('[bench] dataset synthesized:', JSON.stringify(stats)); + + const dataDir = path.join(benchRoot, 'data'); + + // 插桩 FileStore:记录 getIndex 调用时刻(observe 的心跳指纹) + const getIndexMarks: Mark[] = []; + class InstrumentedStore extends FileStore { + override async getIndex(...args: Parameters) { + getIndexMarks.push({ at: Date.now(), note: 'getIndex' }); + return super.getIndex(...args); + } + } + const fileStore = new InstrumentedStore(dataDir); + const wuService = new WorkUnitService(fileStore); + const msgService = new ChannelMessageService(fileStore); + + const channels = await fileStore.listChannels(); + const channelId = channels[0]!.id; + console.log(`[bench] channel: ${channels[0]!.name} (${channelId})`); + + // ══ Phase A:组件耗时 ══ + console.log('\n══ Phase A: 组件耗时(ms)══'); + const SAMPLES = 30; + const time = async (fn: () => Promise): Promise => { + const t0 = performance.now(); + await fn(); + return performance.now() - t0; + }; + + // observe 三读口 + const getIndexTs: number[] = []; + const listChannelsTs: number[] = []; + const queryMsgsTs: number[] = []; + const someWuIds = (await fileStore.getIndex()).slice(0, 3).map(s => s.id); + for (let i = 0; i < SAMPLES; i++) { + getIndexTs.push(await time(() => fileStore.getIndex())); + listChannelsTs.push(await time(() => fileStore.listChannels())); + queryMsgsTs.push(await time(() => + fileStore.queryAllMessages({ workUnitIds: someWuIds, authorType: 'human', channelIds: [channelId] }))); + } + console.log(`getIndex: ${summary(getIndexTs)}`); + console.log(`listChannels: ${summary(listChannelsTs)}`); + console.log(`queryAllMessages: ${summary(queryMsgsTs)}`); + + // create(commitSnapshot + claimable 解析 + workunit.created 发布) + const createTs: number[] = []; + const claimTs: number[] = []; + for (let i = 0; i < SAMPLES; i++) { + let wuId = ''; + createTs.push(await time(async () => { + const wu = await wuService.create({ + scope: `bench 组件计时 ${i}`, type: 'task', status: 'unassigned', channelId, + }); + wuId = wu.id; + })); + claimTs.push(await time(() => wuService.claim(wuId, 'bench-claimer'))); + await wuService.unclaim(wuId); // 放回池,避免堆积 active + } + console.log(`create: ${summary(createTs)}`); + console.log(`claim: ${summary(claimTs)}`); + + // ══ Phase B:真 AgentLoop 端到端 ══ + console.log('\n══ Phase B: 真 AgentLoop 端到端 ══'); + const role = { + id: 'bench-role-claim-latency', + name: 'bench-claim-latency', + description: '#508 bench role', + provider: 'claude', + channels: '[]', + acceptedTypes: [], + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + const loop = new AgentLoop(role, fileStore); + const started = await loop.start(); + if (!started) throw new Error('AgentLoop start failed'); + console.log('[bench] loop started'); + + const states = await fileStore.listStates(); + const instanceId = states.find(s => s.roleId === role.id && s.status !== 'terminated')!.id; + console.log(`[bench] instance: ${instanceId}`); + + // B1:拾取延迟(随机相位建单 → claimedAt) + console.log('\n── B1: WU 建成 → 认领(随机相位,含 15s idle 地板)──'); + const pickup: Array<{ createdAt: string; claimedAt: string; latencyMs: number; eventObserveGapMs: number | null }> = []; + for (let r = 0; r < ROUNDS; r++) { + // 等 loop 回 idle(上轮 WU 关闭 + 簿记完成) + await new Promise(res => setTimeout(res, 1_000 + Math.floor(Math.random() * 6_000))); + const marksBefore = getIndexMarks.length; + const wu = await wuService.create({ + scope: `bench test 拾取延迟 round-${r}`, // test 特征词 → B2 守卫认领后关闭,不起会话 + type: 'task', status: 'unassigned', assigneeId: role.id, channelId, + }); + const tCreate = Date.now(); + // 轮询等认领(50ms 粒度) + let claimedAt: string | null = null; + const deadline = Date.now() + 25_000; + while (Date.now() < deadline) { + const snap = (await fileStore.getIndex({ id: wu.id }))[0]; + if (snap && snap.status !== 'unassigned' && snap.claimedAt) { claimedAt = snap.claimedAt; break; } + await new Promise(res => setTimeout(res, 50)); + } + if (!claimedAt) { console.log(`round ${r}: TIMEOUT (25s 未认领)`); continue; } + // EVENT 触发的 observe(建单后第一次额外 getIndex)与认领的间隔: + // 若 EVENT 能唤醒认领,认领应紧随该 observe;否则认领落在 idle 轮询边界 + const eventObserve = getIndexMarks.slice(marksBefore).find(m => m.at >= tCreate); + const claimMs = new Date(claimedAt).getTime(); + pickup.push({ + createdAt: wu.createdAt.toISOString(), + claimedAt, + latencyMs: claimMs - new Date(wu.createdAt).getTime(), + eventObserveGapMs: eventObserve ? claimMs - eventObserve.at : null, + }); + console.log(`round ${r}: latency=${pickup[pickup.length - 1].latencyMs}ms eventObserveGap=${pickup[pickup.length - 1].eventObserveGapMs}ms`); + // 等 B2 守卫关闭 WU(避免占用 myActive 槽位影响下一轮) + for (let i = 0; i < 100; i++) { + const snap = (await fileStore.getIndex({ id: wu.id }))[0]; + if (snap && (snap.status === 'closed' || snap.status === 'done')) break; + await new Promise(res => setTimeout(res, 200)); + } + } + const lat = pickup.map(p => p.latencyMs); + if (lat.length > 0) console.log(`B1 拾取延迟: ${summary(lat)}`); + + // B2:channel.message_sent 唤醒延迟 + console.log('\n── B2: 人类消息 → 唤醒 idle sleep → 下一次 observe ──'); + // 建 blocked WU 挂本实例,等它进入 lastActiveWuIds(observe 每 ≤15s 刷一次)。 + // scope 必须带 test 特征词:唤醒后 loop 会真的拾取该 WU 起会话——B2 守卫拦在 + // prompt 组装前直接关闭,防误烧 token(2026-09-12 首跑教训:无 test 词的 scope + // 被唤醒的 loop 当真任务 spawn 了真实 CLI 会话)。 + const blockedWu = await wuService.create({ + scope: 'bench test 唤醒挂起任务', type: 'task', status: 'blocked', assigneeId: instanceId, channelId, + metadata: { waitingForInput: true, waitingSince: new Date().toISOString() }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const seam = loop as any; + const regDeadline = Date.now() + 20_000; + while (!seam.lastActiveWuIds?.has(blockedWu.id) && Date.now() < regDeadline) { + await new Promise(res => setTimeout(res, 100)); + } + if (!seam.lastActiveWuIds?.has(blockedWu.id)) { + console.log('B2 SKIP: blocked WU 20s 未进入 lastActiveWuIds'); + } else { + console.log('[bench] blocked WU 已进入 lastActiveWuIds,loop 处于 idleSleep'); + // 等 loop 确实进入 idleSleep(wakeIdle 挂起) + const idleDeadline = Date.now() + 20_000; + while (!seam.wakeIdle && Date.now() < idleDeadline) await new Promise(res => setTimeout(res, 50)); + const wakeLat: number[] = []; + for (let i = 0; i < 3; i++) { + // 等回 idleSleep + const d = Date.now() + 20_000; + while (!seam.wakeIdle && Date.now() < d) await new Promise(res => setTimeout(res, 20)); + const marksBefore = getIndexMarks.length; + const t0 = Date.now(); + await msgService.createHumanMessage(channelId, `bench 唤醒回复 ${i}`, undefined, blockedWu.id); + const obsDeadline = Date.now() + 5_000; + let obsAt: number | null = null; + while (Date.now() < obsDeadline) { + const m = getIndexMarks.slice(marksBefore)[0]; + if (m) { obsAt = m.at; break; } + await new Promise(res => setTimeout(res, 5)); + } + if (obsAt !== null) { + wakeLat.push(obsAt - t0); + console.log(`wake round ${i}: publish→observe = ${obsAt - t0}ms`); + } else { + console.log(`wake round ${i}: TIMEOUT (5s 无 observe)`); + } + // 等这一轮 observe 的后续簿记落定、loop 回 idle + await new Promise(res => setTimeout(res, 3_000)); + } + if (wakeLat.length > 0) console.log(`B2 唤醒延迟: ${summary(wakeLat)}`); + } + + loop.stop(); + await loop.waitForStop(); + eventBus.unsubscribeAll('channel.message_sent'); + eventBus.unsubscribeAll('workunit.created'); + + console.log(`\n[bench] done. bench root kept at ${benchRoot}(手工清理)`); + console.log('[bench] 注意:合成副本数据=生产 1x 规模快照;picked/claimed 时间戳精度 1ms(ISO)'); +} + +// 仅作为脚本直跑时执行(vitest import 不触发,供 __tests__ 测纯函数) +if (require.main === module) { + main().catch(err => { + console.error('[bench] failed:', err); + process.exit(1); + }); +} diff --git a/apps/api/bench/mainline-align-core.ts b/apps/api/bench/mainline-align-core.ts new file mode 100644 index 00000000..b5ee9c88 --- /dev/null +++ b/apps/api/bench/mainline-align-core.ts @@ -0,0 +1,358 @@ +/** + * #521(spec .studio/specs/2026-09-12-channel-mainline-measurement §2,决议 #505): + * 频道主链路离线对齐工具——纯函数核心。 + * + * 只读三家数据源(CLI 壳负责读取,本模块只见行数据): + * 1. 频道消息热文件(channels/*\/messages.jsonl):{id, channelId, authorType, workUnitId, createdAt} + * —— 注意更新副本(linkWorkUnit 回填)同 id 追加在尾、createdAt 不变(#317),按 id 去重后行者胜出; + * 2. WU 快照(workunits/index.json):{id, channelId, createdAt, claimedAt, completedAt, metadata(JSON string)} + * —— metadata.traceId = 最近一次触达该 WU 的请求 traceId(#519 口径,三路径一致), + * metadata.anchorMessageId = 派发消息锚点(#494); + * 3. studio-events 事件流(logs/studio-events.jsonl):workunit:execution_step(步级,payload.at) + * 与 client.perf.*(前端三埋点 #520,source web-client,可能尚无样本)。 + * + * 四段口径(映射「消息的旅程」六段中的服务端四段,spec §7): + * 派单 = WU.createdAt - 派发消息.createdAt + * 等认领 = WU.claimedAt - WU.createdAt + * 执行总时长 = WU.completedAt - WU.claimedAt(步级分解取 execution_step 事件的 at 差分, + * 首步 delta = 步 at - claimedAt,即「认领→首步产出」) + * 回执落库 = 首条 createdAt ≥ WU.completedAt 的 agent 消息.createdAt - WU.completedAt + * 缺失段不静默丢样本:链上 skips[] 与分布的 skipped{} 如实标注原因。 + */ + +// ── 输入行类型(CLI 壳从文件读出后传入;字段宽容,缺失走跳过标注)── +export interface ChannelMessageRow { + id: string; + channelId: string; + authorType: string; + workUnitId?: string | null; + createdAt?: string; +} + +export interface WorkUnitSnapshotRow { + id: string; + channelId?: string | null; + status?: string; + createdAt?: string; + claimedAt?: string | null; + completedAt?: string | null; + metadata?: string | Record | null; +} + +export interface StudioEventRow { + type?: string; + source?: string; + payload?: unknown; + createdAt?: string; +} + +export interface AlignInput { + messages: ChannelMessageRow[]; + workunits: WorkUnitSnapshotRow[]; + events: StudioEventRow[]; +} + +// ── 输出类型 ── +export interface ChainStep { + step: number; + at: number; + /** 相对上一步(首步相对 claimedAt)的耗时;无从相对时为 null */ + deltaMs: number | null; + status?: string; +} + +export interface MainlineChain { + workUnitId: string; + traceId: string | null; + channelId: string | null; + wuCreatedAt: number | null; + dispatchMessageId: string | null; + dispatchMs: number | null; + waitClaimMs: number | null; + executionMs: number | null; + steps: ChainStep[]; + receiptMessageId: string | null; + receiptMs: number | null; + /** 缺失段标注(如 'receipt:no_receipt_message'),不静默丢样本 */ + skips: string[]; +} + +export interface SegmentStats { + n: number; + p50Ms: number | null; + p95Ms: number | null; + minMs: number | null; + maxMs: number | null; + skipped: Record; +} + +export interface ClientPerfStats { + n: number; + p50Ms: number | null; + p95Ms: number | null; + /** n=0 时 'no_samples'(前端埋点数据未产生时照常可跑,如实标注) */ + note?: 'no_samples'; +} + +export const CLIENT_PERF_TYPES = [ + 'client.perf.send_click', + 'client.perf.receipt_render', + 'client.perf.page_load', +] as const; + +export interface AlignReport { + chainCount: number; + segments: { + dispatch: SegmentStats; + waitClaim: SegmentStats; + execution: SegmentStats; + receipt: SegmentStats; + }; + clientPerf: Record<(typeof CLIENT_PERF_TYPES)[number], ClientPerfStats>; + chains: MainlineChain[]; +} + +// ── 内部工具 ── +function ts(v: unknown): number | null { + if (typeof v !== 'string' || !v) return null; + const t = Date.parse(v); + return Number.isFinite(t) ? t : null; +} + +function parseMetadata(raw: WorkUnitSnapshotRow['metadata']): Record { + if (raw && typeof raw === 'object') return raw as Record; + if (typeof raw !== 'string' || !raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed as Record : {}; + } catch { + return {}; // 同路由层 parseWuMetadata 容错口径:畸形 metadata 落 {} 不抛错 + } +} + +function parsePayload(raw: unknown): Record | null { + if (raw && typeof raw === 'object') return raw as Record; + if (typeof raw !== 'string' || !raw) return null; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed as Record : null; + } catch { + return null; + } +} + +/** nearest-rank 分位;空数组 → null */ +function percentile(sorted: number[], p: number): number | null { + if (sorted.length === 0) return null; + const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); + return sorted[idx]; +} + +function segmentStats(chains: MainlineChain[], pick: (c: MainlineChain) => number | null, prefix: string): SegmentStats { + const samples: number[] = []; + const skipped: Record = {}; + for (const c of chains) { + const v = pick(c); + if (v !== null) { + samples.push(v); + } else { + for (const s of c.skips) { + if (s.startsWith(`${prefix}:`)) skipped[s] = (skipped[s] ?? 0) + 1; + } + } + } + samples.sort((a, b) => a - b); + return { + n: samples.length, + p50Ms: percentile(samples, 50), + p95Ms: percentile(samples, 95), + minMs: samples.length ? samples[0] : null, + maxMs: samples.length ? samples[samples.length - 1] : null, + skipped, + }; +} + +/** 消息按 id 去重(更新副本后行者胜出;createdAt 由消息自身携带、副本不变) */ +function dedupeMessages(rows: ChannelMessageRow[]): Map { + const byId = new Map(); + for (const m of rows) { + if (m && typeof m.id === 'string') byId.set(m.id, m); + } + return byId; +} + +// ── 核心:对齐出单链 ── +export function buildChains(input: AlignInput): MainlineChain[] { + const msgById = dedupeMessages(input.messages); + + // 频道内按 workUnitId 索引(人类消息 / agent 消息分开) + const humanByWu = new Map(); + const agentByWu = new Map(); + for (const m of msgById.values()) { + if (!m.workUnitId) continue; + const bucket = m.authorType === 'agent' ? agentByWu : m.authorType === 'human' ? humanByWu : null; + if (!bucket) continue; + const list = bucket.get(m.workUnitId) ?? []; + list.push(m); + bucket.set(m.workUnitId, list); + } + + // execution_step 事件按 workUnitId 索引 + const stepsByWu = new Map>(); + for (const e of input.events) { + if (e?.type !== 'workunit:execution_step') continue; + const p = parsePayload(e.payload); + const wuId = typeof p?.workUnitId === 'string' ? p.workUnitId : null; + const at = ts(p?.at) ?? ts(e.createdAt); + if (!wuId || at === null) continue; + const list = stepsByWu.get(wuId) ?? []; + list.push({ + step: typeof p?.step === 'number' ? p.step : list.length + 1, + at, + status: typeof p?.status === 'string' ? p.status : undefined, + }); + stepsByWu.set(wuId, list); + } + + const chains: MainlineChain[] = []; + for (const wu of input.workunits) { + if (!wu || typeof wu.id !== 'string') continue; + const meta = parseMetadata(wu.metadata); + const skips: string[] = []; + const wuCreated = ts(wu.createdAt); + const claimed = ts(wu.claimedAt); + const completed = ts(wu.completedAt); + + // ── 派单段:anchorMessageId 优先;回退最早的人类消息(合并窗口搭车消息晚于建单,排除)── + let dispatchMessageId: string | null = null; + let dispatchMs: number | null = null; + if (wuCreated === null) { + skips.push('dispatch:no_wu_createdAt'); + } else { + let dispatchMsg: ChannelMessageRow | undefined; + if (typeof meta.anchorMessageId === 'string') dispatchMsg = msgById.get(meta.anchorMessageId); + if (!dispatchMsg) { + const candidates = (humanByWu.get(wu.id) ?? []) + .filter(m => { const t = ts(m.createdAt); return t !== null && t <= wuCreated; }) + .sort((a, b) => ts(a.createdAt)! - ts(b.createdAt)!); + dispatchMsg = candidates[0]; + } + if (dispatchMsg && ts(dispatchMsg.createdAt) !== null) { + dispatchMessageId = dispatchMsg.id; + dispatchMs = wuCreated - ts(dispatchMsg.createdAt)!; + } else { + skips.push('dispatch:no_dispatch_message'); + } + } + + // ── 等认领段 ── + let waitClaimMs: number | null = null; + if (wuCreated === null) skips.push('waitClaim:no_wu_createdAt'); + else if (claimed === null) skips.push('waitClaim:no_claimedAt'); + else waitClaimMs = claimed - wuCreated; + + // ── 执行总时长 + 步级分解 ── + let executionMs: number | null = null; + if (claimed === null) skips.push('execution:no_claimedAt'); + else if (completed === null) skips.push('execution:no_completedAt'); + else executionMs = completed - claimed; + + const rawSteps = (stepsByWu.get(wu.id) ?? []).sort((a, b) => a.step - b.step || a.at - b.at); + const steps: ChainStep[] = rawSteps.map((s, i) => ({ + step: s.step, + at: s.at, + deltaMs: i === 0 ? (claimed !== null ? s.at - claimed : null) : s.at - rawSteps[i - 1].at, + ...(s.status ? { status: s.status } : {}), + })); + + // ── 回执落库段:首条 createdAt ≥ completedAt 的 agent 消息 ── + let receiptMessageId: string | null = null; + let receiptMs: number | null = null; + if (completed === null) { + skips.push('receipt:no_completedAt'); + } else { + const candidates = (agentByWu.get(wu.id) ?? []) + .filter(m => { const t = ts(m.createdAt); return t !== null && t >= completed; }) + .sort((a, b) => ts(a.createdAt)! - ts(b.createdAt)!); + const receipt = candidates[0]; + if (receipt) { + receiptMessageId = receipt.id; + receiptMs = ts(receipt.createdAt)! - completed; + } else { + skips.push('receipt:no_receipt_message'); + } + } + + chains.push({ + workUnitId: wu.id, + traceId: typeof meta.traceId === 'string' ? meta.traceId : null, + channelId: typeof wu.channelId === 'string' ? wu.channelId : null, + wuCreatedAt: wuCreated, + dispatchMessageId, + dispatchMs, + waitClaimMs, + executionMs, + steps, + receiptMessageId, + receiptMs, + skips, + }); + } + return chains; +} + +// ── 时间窗过滤(按 WU 建成时间;无建成时间的链在设窗时排除)── +export function filterChainsByWindow( + chains: MainlineChain[], + window: { sinceMs?: number | null; untilMs?: number | null }, +): MainlineChain[] { + const { sinceMs = null, untilMs = null } = window; + if (sinceMs === null && untilMs === null) return chains; + return chains.filter(c => { + if (c.wuCreatedAt === null) return false; + if (sinceMs !== null && c.wuCreatedAt < sinceMs) return false; + if (untilMs !== null && c.wuCreatedAt > untilMs) return false; + return true; + }); +} + +// ── 汇总:四段分布 + 前端埋点段 ── +export function summarizeChains(chains: MainlineChain[], events: StudioEventRow[] = []): AlignReport { + const clientPerf = Object.fromEntries( + CLIENT_PERF_TYPES.map(type => { + const msSamples = events + .filter(e => e?.type === type) + .map(e => { + const p = parsePayload(e.payload); + return typeof p?.ms === 'number' && Number.isFinite(p.ms) ? p.ms : null; + }) + .filter((v): v is number => v !== null) + .sort((a, b) => a - b); + const n = events.filter(e => e?.type === type).length; + const stats: ClientPerfStats = { + n, + p50Ms: percentile(msSamples, 50), + p95Ms: percentile(msSamples, 95), + ...(n === 0 ? { note: 'no_samples' as const } : {}), + }; + return [type, stats]; + }), + ) as AlignReport['clientPerf']; + + return { + chainCount: chains.length, + segments: { + dispatch: segmentStats(chains, c => c.dispatchMs, 'dispatch'), + waitClaim: segmentStats(chains, c => c.waitClaimMs, 'waitClaim'), + execution: segmentStats(chains, c => c.executionMs, 'execution'), + receipt: segmentStats(chains, c => c.receiptMs, 'receipt'), + }, + clientPerf, + chains, + }; +} + +// ── 按 traceId 查单链明细 ── +export function findChainsByTraceId(chains: MainlineChain[], traceId: string): MainlineChain[] { + return chains.filter(c => c.traceId === traceId); +} diff --git a/apps/api/bench/mainline-align.ts b/apps/api/bench/mainline-align.ts new file mode 100644 index 00000000..74b9f118 --- /dev/null +++ b/apps/api/bench/mainline-align.ts @@ -0,0 +1,192 @@ +/** + * #521 频道主链路离线对齐工具(spec .studio/specs/2026-09-12-channel-mainline-measurement §2)。 + * + * 用法:npx tsx bench/mainline-align.ts [--since 24h|7d|ISO] [--until ISO] [--traceId ] + * [--studio-home ] [--events-file ] + * --since/--until 时间窗(按 WU 建成时间过滤;缺省 = 全量) + * --traceId 只输出该 traceId 的单链明细(含四段 + 步级分解 + 关联消息) + * --studio-home 数据根(默认 $STUDIO_HOME 或 ~/.studio) + * --events-file studio-events.jsonl 覆盖(默认 /logs/studio-events.jsonl) + * + * 只读三家数据源:频道消息热文件、WU 快照/事件、studio-events 事件流; + * 零新服务端埋点,不碰生产写路径。四段口径与跳过标注见 mainline-align-core.ts 头注。 + * 命令行壳不测(spec Testing Decisions),纯函数测试见 __tests__/mainline-align-core.test.ts。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + buildChains, + filterChainsByWindow, + summarizeChains, + findChainsByTraceId, + CLIENT_PERF_TYPES, + type AlignInput, + type ChannelMessageRow, + type WorkUnitSnapshotRow, + type StudioEventRow, + type MainlineChain, + type SegmentStats, +} from './mainline-align-core.js'; + +export interface CliArgs { + sinceMs: number | null; + untilMs: number | null; + traceId: string | null; + studioHome: string; + eventsFile: string | null; +} + +/** --since 取值:相对时长(24h/7d/30m)或 ISO 时间 */ +function parseTimeArg(v: string, now: number): number | null { + const rel = /^(\d+)([mhd])$/.exec(v); + if (rel) { + const n = Number(rel[1]); + const unit = { m: 60_000, h: 3_600_000, d: 86_400_000 }[rel[2] as 'm' | 'h' | 'd']; + return now - n * unit; + } + const t = Date.parse(v); + return Number.isFinite(t) ? t : null; +} + +export function parseArgs(argv: string[] = process.argv.slice(2), now: number = Date.now()): CliArgs { + const get = (name: string) => { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : undefined; + }; + return { + sinceMs: get('--since') ? parseTimeArg(get('--since')!, now) : null, + untilMs: get('--until') ? parseTimeArg(get('--until')!, now) : null, + traceId: get('--traceId') ?? null, + studioHome: get('--studio-home') ?? process.env.STUDIO_HOME ?? path.join(os.homedir(), '.studio'), + eventsFile: get('--events-file') ?? null, + }; +} + +function readJsonl(file: string): T[] { + try { + return fs.readFileSync(file, 'utf8') + .split('\n') + .filter(line => line.trim()) + .flatMap(line => { + try { + return [JSON.parse(line) as T]; + } catch { + return []; // 损坏行跳过(同 readJsonl 口径) + } + }); + } catch { + return []; // 文件不存在/不可读 → 空数据源,跳过标注由汇总层如实呈现 + } +} + +export function loadInput(args: CliArgs): AlignInput & { dataNotes: string[] } { + const dataNotes: string[] = []; + const channelsDir = path.join(args.studioHome, 'data', 'channels'); + const messages: ChannelMessageRow[] = []; + let channelDirs: string[] = []; + try { + channelDirs = fs.readdirSync(channelsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + } catch { + dataNotes.push(`频道目录不可读:${channelsDir}(消息数据源为空)`); + } + for (const ch of channelDirs) { + messages.push(...readJsonl(path.join(channelsDir, ch, 'messages.jsonl'))); + } + + let workunits: WorkUnitSnapshotRow[] = []; + const wuIndex = path.join(args.studioHome, 'data', 'workunits', 'index.json'); + try { + const parsed = JSON.parse(fs.readFileSync(wuIndex, 'utf8')); + workunits = Array.isArray(parsed) ? parsed : []; + } catch { + dataNotes.push(`WU 快照不可读:${wuIndex}(WU 数据源为空)`); + } + + const eventsFile = args.eventsFile ?? path.join(args.studioHome, 'logs', 'studio-events.jsonl'); + const events = readJsonl(eventsFile); + if (events.length === 0) dataNotes.push(`事件流为空或不可读:${eventsFile}`); + + return { messages, workunits, events, dataNotes }; +} + +const fmtMs = (v: number | null) => (v === null ? '—' : v >= 10_000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`); + +function fmtSegment(name: string, s: SegmentStats): string { + const lines = [`### ${name}(n=${s.n})`, '']; + if (s.n > 0) { + lines.push(`p50 ${fmtMs(s.p50Ms)} ・ p95 ${fmtMs(s.p95Ms)} ・ min ${fmtMs(s.minMs)} ・ max ${fmtMs(s.maxMs)}`); + } else { + lines.push('无样本。'); + } + const skips = Object.entries(s.skipped); + if (skips.length > 0) { + lines.push(`跳过标注:${skips.map(([k, n]) => `${k} ×${n}`).join(',')}`); + } + return lines.join('\n'); +} + +function fmtChainDetail(c: MainlineChain): string { + const isoAt = (t: number | null) => (t === null ? '—' : new Date(t).toISOString()); + const lines = [ + `- WU \`${c.workUnitId}\`(频道 ${c.channelId ?? '—'},traceId ${c.traceId ?? '—'},建成 ${isoAt(c.wuCreatedAt)})`, + ` - 派单:${fmtMs(c.dispatchMs)}(派发消息 ${c.dispatchMessageId ?? '—'})`, + ` - 等认领:${fmtMs(c.waitClaimMs)}`, + ` - 执行总时长:${fmtMs(c.executionMs)}`, + ]; + if (c.steps.length > 0) { + lines.push(' - 步级分解:'); + for (const s of c.steps) { + lines.push(` - step ${s.step} @ ${isoAt(s.at)}(+${fmtMs(s.deltaMs)}${s.status ? `,${s.status}` : ''})`); + } + } + lines.push(` - 回执落库:${fmtMs(c.receiptMs)}(回执消息 ${c.receiptMessageId ?? '—'})`); + if (c.skips.length > 0) lines.push(` - 跳过标注:${c.skips.join(',')}`); + return lines.join('\n'); +} + +function main(): void { + const args = parseArgs(); + const { messages, workunits, events, dataNotes } = loadInput(args); + const allChains = buildChains({ messages, workunits, events }); + const chains = filterChainsByWindow(allChains, { sinceMs: args.sinceMs, untilMs: args.untilMs }); + + const out: string[] = ['# 频道主链路离线对齐报告', '']; + out.push(`数据源:${args.studioHome}(消息 ${messages.length} 行 / WU ${workunits.length} 条 / 事件 ${events.length} 行)`); + const winDesc = [ + args.sinceMs !== null ? `since ${new Date(args.sinceMs).toISOString()}` : null, + args.untilMs !== null ? `until ${new Date(args.untilMs).toISOString()}` : null, + ].filter(Boolean).join(' '); + out.push(`时间窗:${winDesc || '全量'};链路样本 ${chains.length} / ${allChains.length}(窗内/全部)`); + for (const note of dataNotes) out.push(`> 数据提示:${note}`); + out.push(''); + + if (args.traceId) { + const hits = findChainsByTraceId(chains, args.traceId); + out.push(`## 单链明细(traceId = ${args.traceId},命中 ${hits.length} 条)`, ''); + if (hits.length === 0) out.push('未命中(时间窗内无该 traceId 的 WU)。'); + for (const c of hits) out.push(fmtChainDetail(c)); + } else { + const report = summarizeChains(chains, events); + out.push('## 四段分布', ''); + out.push(fmtSegment('派单(消息落库 → WU 建成)', report.segments.dispatch), ''); + out.push(fmtSegment('等认领(WU 建成 → claimedAt)', report.segments.waitClaim), ''); + out.push(fmtSegment('执行总时长(认领 → 完成)', report.segments.execution), ''); + out.push(fmtSegment('回执落库(执行产出 → 频道消息落库)', report.segments.receipt), ''); + out.push('## 前端埋点段(client.perf.*,source web-client)', ''); + for (const type of CLIENT_PERF_TYPES) { + const s = report.clientPerf[type]; + out.push(s.n === 0 + ? `- ${type}:跳过(no_samples——前端埋点数据未产生)` + : `- ${type}:n=${s.n}${s.p50Ms !== null ? `,ms p50 ${fmtMs(s.p50Ms)} ・ p95 ${fmtMs(s.p95Ms)}` : '(点事件无 ms)'}`); + } + } + console.log(out.join('\n')); +} + +// 仅作为脚本直跑时执行(vitest import 不触发,供 __tests__ 测 parseArgs/loadInput;同 loop-read-metrics 先例) +if (require.main === module) { + main(); +} diff --git a/apps/api/bench/route-dispatch-merge-window.ts b/apps/api/bench/route-dispatch-merge-window.ts new file mode 100644 index 00000000..129e11c6 --- /dev/null +++ b/apps/api/bench/route-dispatch-merge-window.ts @@ -0,0 +1,260 @@ +/** + * #507 走查②路由派单 bench:routeMessage 链路各读/写口成本量化。 + * + * 用法(只读 ~/.studio,合成数据全在 tmp): + * VITEST=1 STUDIO_HOME= node_modules/.bin/tsx <本脚本> [--rounds N] [--scales 1,10,50] + * + * 测什么(对应 routeMessage 各环节的真实读口): + * A. appendMessage —— 每条落库消息的写成本(含 per-channel flock) + * B. queryMessages(after write) —— 决策12 合并窗口 findMergeTargetWorkUnit 的查询, + * 稳态 = 缓存失效后全热文件读+parse+clone(上一条消息的 append 已失效 jsonlCache) + * C. queryMessages(warm hit) —— 对照:无写入间隙时 mtime 命中路径(stat + structuredClone) + * D. tailScan20Human —— 候选替代:iterateJsonlLinesBackward 倒扫凑满 20 条人类消息即停 + * E. getMessageById(after write)—— replyTo 线程回复父消息查找 + mention 派单 linkWorkUnit 查找, + * 扫全部频道热文件(写后 = 被写频道 miss,其余 hit) + * F. getMessageById(warm hit) —— 对照 + * G. getIndex({id}) —— WU 点读(findMergeTargetWorkUnit 第二步 / resumeWaitingWorkUnit) + * H. commitSnapshot —— 建 WU 持久化成本(锁内 appendEvent + 全量索引重写) + * + * 数据:1x = 真实 ~/.studio/data 只读复制;10x/50x = 最大频道消息行复制放大 + * (id 重生成防 mergeActiveRows 归并塌缩,行内分布/大小保持真实)。WU 索引各档同为真实 1x(49 条)。 + */ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; +import { FileStore } from '/root/projects/studio/packages/studio-shared/src/file-store.ts'; +import { iterateJsonlLinesBackward } from '/root/projects/studio/packages/studio-shared/src/jsonl-tail.ts'; + +const REAL_DATA = path.join(os.homedir(), '.studio', 'data'); +const ROUNDS = Number(process.argv[process.argv.indexOf('--rounds') + 1] || 20); +const SCALES = (process.argv[process.argv.indexOf('--scales') + 1] || '1,10,50').split(',').map(Number); +/** 只跑指定测量项(逗号分隔 key 字母,如 BENCH_ONLY=B,D)——隔离大克隆 GC 噪声时用 */ +const ONLY = process.env.BENCH_ONLY ? new Set(process.env.BENCH_ONLY.split(',')) : null; +const enabled = (key: string) => !ONLY || ONLY.has(key[0]); +/** 指定放大的频道(缺省 = 行数最多频道);706…为人类消息频道时观察近尾命中场景 */ +const BIG_CID = process.env.BENCH_BIG_CID || null; + +interface Row { id: string; authorType?: string; createdAt?: string; workUnitId?: string | null; [k: string]: unknown } + +function readLines(file: string): string[] { + return fs.readFileSync(file, 'utf-8').split('\n').filter(l => l.trim().length > 0); +} + +/** 复制真实 channels 到 out;largestCid 的消息行按 scale 放大(id 加后缀防归并塌缩) */ +function synthesize(outRoot: string, scale: number): { channelIds: string[]; largestCid: string; largestLines: number; largestBytes: number } { + const srcChannels = path.join(REAL_DATA, 'channels'); + const outChannels = path.join(outRoot, 'channels'); + fs.mkdirSync(outChannels, { recursive: true }); + const cids = fs.readdirSync(srcChannels).filter(d => fs.existsSync(path.join(srcChannels, d, 'messages.jsonl'))); + // 找放大目标频道(BENCH_BIG_CID 指定;缺省取行数最多者) + let largestCid = ''; + let largestRows: Row[] = []; + for (const cid of cids) { + const rows = readLines(path.join(srcChannels, cid, 'messages.jsonl')).map(l => JSON.parse(l) as Row); + if (rows.length > largestRows.length) { largestRows = rows; largestCid = cid; } + } + if (BIG_CID && cids.includes(BIG_CID)) { + largestCid = BIG_CID; + largestRows = readLines(path.join(srcChannels, BIG_CID, 'messages.jsonl')).map(l => JSON.parse(l) as Row); + } + for (const cid of cids) { + fs.mkdirSync(path.join(outChannels, cid), { recursive: true }); + const srcFile = path.join(srcChannels, cid, 'messages.jsonl'); + const outFile = path.join(outChannels, cid, 'messages.jsonl'); + if (cid !== largestCid || scale === 1) { + fs.copyFileSync(srcFile, outFile); + } else { + const lines: string[] = []; + for (let r = 0; r < scale; r++) { + for (const row of largestRows) lines.push(JSON.stringify({ ...row, id: `${row.id}__r${r}` })); + } + fs.writeFileSync(outFile, lines.join('\n') + '\n'); + } + } + // WU 索引/事件:各档同为真实 1x 复制(commitSnapshot 与 getIndex 的真实底数) + const srcWu = path.join(REAL_DATA, 'workunits'); + const outWu = path.join(outRoot, 'workunits'); + fs.mkdirSync(outWu, { recursive: true }); + for (const f of ['index.json', 'events.jsonl']) { + const p = path.join(srcWu, f); + if (fs.existsSync(p)) fs.copyFileSync(p, path.join(outWu, f)); + } + const outBig = path.join(outChannels, largestCid, 'messages.jsonl'); + return { + channelIds: cids, + largestCid, + largestLines: readLines(outBig).length, + largestBytes: fs.statSync(outBig).size, + }; +} + +/** 候选替代:尾部倒扫,凑满 need 条去重后的人类消息即停;返回扫描行数供成本对照 */ +export async function tailScanHuman(filePath: string, need = 20): Promise<{ count: number; scanned: number }> { + const handle = await fsp.open(filePath, 'r'); + try { + const stat = await handle.stat(); + const seen = new Set(); + let found = 0; + let scanned = 0; + for await (const { text } of iterateJsonlLinesBackward(handle, stat.size)) { + scanned++; + let row: Row; + try { row = JSON.parse(text) as Row; } catch { continue; } + if (seen.has(row.id)) continue; + seen.add(row.id); + if (row.authorType !== 'human') continue; + found++; + if (found >= need) break; + } + return { count: found, scanned }; + } finally { + await handle.close(); + } +} + +/** 候选替代 v2:倒扫遇第一条(去重后)human+workUnitId 即停——合并目标语义本身只要这一条 */ +export async function tailScanFirstHumanWu(filePath: string): Promise<{ found: boolean; scanned: number }> { + const handle = await fsp.open(filePath, 'r'); + try { + const stat = await handle.stat(); + const seen = new Set(); + let scanned = 0; + for await (const { text } of iterateJsonlLinesBackward(handle, stat.size)) { + scanned++; + let row: Row; + try { row = JSON.parse(text) as Row; } catch { continue; } + if (seen.has(row.id)) continue; + seen.add(row.id); + if (row.authorType === 'human' && row.workUnitId) return { found: true, scanned }; + } + return { found: false, scanned }; + } finally { + await handle.close(); + } +} + +export function stats(samples: number[]): { min: number; median: number; p95: number; mean: number } { + const s = [...samples].sort((a, b) => a - b); + const pick = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))]; + return { min: s[0], median: pick(0.5), p95: pick(0.95), mean: s.reduce((a, b) => a + b, 0) / s.length }; +} + +type Samples = Record; + +async function time(samples: Samples, key: string, fn: () => Promise): Promise { + const t0 = performance.now(); + await fn(); + const dt = performance.now() - t0; + (samples[key] ??= []).push(dt); +} + +async function main(): Promise { + const benchRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'route-dispatch-bench-')); + console.log(`[bench] root: ${benchRoot}`); + console.log(`[bench] source (read-only): ${REAL_DATA}`); + console.log(`[bench] rounds=${ROUNDS} scales=${SCALES.join(',')}`); + + const report: Record = { benchRoot, rounds: ROUNDS, scales: {} }; + + for (const scale of SCALES) { + const label = `${scale}x`; + const root = path.join(benchRoot, label); + const info = synthesize(root, scale); + const store = new FileStore(root); + const bigFile = path.join(root, 'channels', info.largestCid, 'messages.jsonl'); + // getMessageById 目标:取最小频道(非最大频道)的首条消息 id —— 模拟 replyTo 父消息在普通频道 + const smallCid = info.channelIds.find(c => c !== info.largestCid)!; + const smallFirst = JSON.parse(readLines(path.join(root, 'channels', smallCid, 'messages.jsonl'))[0]) as Row; + const wuIndex = JSON.parse(fs.readFileSync(path.join(root, 'workunits', 'index.json'), 'utf-8')) as Row[]; + const wuTemplate = wuIndex[0]; + + const samples: Samples = {}; + let tailScanned = 0; + let tailScanned2 = 0; + + for (let r = 0; r < ROUNDS; r++) { + const msg: Row = { + id: randomUUID(), + channelId: info.largestCid, + authorType: 'human', + agentName: null, + content: `bench ping ${r}`, + replyToId: null, + meta: '{}', + workUnitId: null, + createdAt: new Date().toISOString(), + }; + // A: 消息写(B/E 的失效前置:ONLY 不含 A 时也要先写一发保持「写后」语义) + if (enabled('A')) await time(samples, 'A.appendMessage', () => store.appendMessage(info.largestCid, msg as never)); + else if (enabled('B') || enabled('E')) await store.appendMessage(info.largestCid, msg as never); + // B: 合并窗口查询(写后 = 稳态缓存失效路径,与生产逐消息一致) + if (enabled('B')) await time(samples, 'B.queryMessages.afterWrite', () => + store.queryMessages(info.largestCid, { authorType: 'human', limit: 20 })); + // C: 对照暖命中 + if (enabled('C')) await time(samples, 'C.queryMessages.warmHit', () => + store.queryMessages(info.largestCid, { authorType: 'human', limit: 20 })); + // D: 尾部倒扫候选(凑满 20 条 human 即停,与现 limit:20 同口径) + if (enabled('D')) await time(samples, 'D.tailScan20Human', async () => { + const res = await tailScanHuman(bigFile, 20); + tailScanned = res.scanned; + }); + // D2: 尾部倒扫 v2(首条 human+workUnitId 即停) + if (enabled('D')) await time(samples, 'D2.tailScanFirstHumanWu', async () => { + const res = await tailScanFirstHumanWu(bigFile); + tailScanned2 = res.scanned; + }); + // E: getMessageById 写后(被写频道 miss + 其余频道 hit 的并行扇出) + if (enabled('E')) await time(samples, 'E.getMessageById.afterWrite', () => store.getMessageById(smallFirst.id)); + // F: 对照暖命中 + if (enabled('F')) await time(samples, 'F.getMessageById.warmHit', () => store.getMessageById(smallFirst.id)); + // G: WU 点读 + if (enabled('G')) await time(samples, 'G.getIndex.pointRead', () => store.getIndex({ id: String(wuTemplate.id) })); + // H: 建 WU 持久化(锁内 appendEvent + 索引全量重写) + if (enabled('H')) { + const wu = { ...wuTemplate, id: randomUUID(), updatedAt: new Date().toISOString() }; + await time(samples, 'H.commitSnapshot', () => + store.commitSnapshot( + { type: 'created', wuId: String(wu.id), timestamp: new Date().toISOString(), data: wu as Record }, + wu as never, + )); + } + } + + const table: Record = { + largestChannel: info.largestCid, + largestLines: info.largestLines, + largestKB: Math.round(info.largestBytes / 1024), + channelCount: info.channelIds.length, + wuIndexEntries: wuIndex.length, + tailScanRowsScanned: tailScanned, + tailScan2RowsScanned: tailScanned2, + measurements: Object.fromEntries( + Object.entries(samples).map(([k, v]) => [k, Object.fromEntries( + Object.entries(stats(v)).map(([sk, sv]) => [sk, Number(sv.toFixed(3))]), + )]), + ), + }; + (report.scales as Record)[label] = table; + console.log(`\n[bench] === ${label} (largest ${info.largestLines} lines / ${Math.round(info.largestBytes / 1024)}KB, ${info.channelIds.length} channels, ${wuIndex.length} WUs) ===`); + for (const [k, v] of Object.entries(table.measurements as Record>)) { + console.log(` ${k.padEnd(28)} min=${v.min}ms median=${v.median}ms p95=${v.p95}ms mean=${v.mean}ms`); + } + console.log(` tailScan rows scanned (last round): D=${tailScanned} D2=${tailScanned2}`); + } + + const outPath = path.join(benchRoot, 'route-dispatch-bench-results.json'); + fs.writeFileSync(outPath, JSON.stringify(report, null, 2)); + console.log(`\n[bench] results: ${outPath}`); + console.log(`[bench] done. bench root kept at ${benchRoot}`); +} + +// 仅作为脚本直跑时执行(vitest import 不触发) +if (require.main === module) { + main().catch(err => { + console.error('[bench] failed:', err); + process.exit(1); + }); +} diff --git a/apps/api/bench/walk5-page-load.ts b/apps/api/bench/walk5-page-load.ts new file mode 100644 index 00000000..54539c12 --- /dev/null +++ b/apps/api/bench/walk5-page-load.ts @@ -0,0 +1,169 @@ +/** + * #510 走查⑤ bench:GET /:id/messages 分页读路径 + monitoring 读端点成本量化。 + * + * 运行:cd apps/api && node_modules/.bin/tsx bench/walk5-page-load.ts + * + * 纪律: + * - ~/.studio 全程只读(FileStore 读口 / 窗口事件读 / 目录列举,零写入); + * - 合成规模数据全部在 os.tmpdir() 下新建,跑完保留路径打印,手工清理; + * - monitoring /agents 的 listProjects 用等价轻量 stub(直接读 ~/.studio/projects/*.json), + * 避免 lazy import project.service 拉入 channels/workunit 模块链的装载副作用; + * - /flywheel 不测 knowledgeService 单例(knowledge-singletons 装载期 pkill 副作用), + * 改为分别实测其两个成本组件:memo 包 knowledge store list({}) + 窗口事件扫描×2。 + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { FileStore } from '@dommaker/studio-shared'; +import { setReadMetricsSink, runWithLoopLabel, type ReadMetricEvent } from '@dommaker/studio-shared/read-metrics'; +import { FileKnowledgeStore } from '@dommaker/harness'; +import { MonitoringService } from '../src/modules/monitoring/monitoring.service.js'; +import { MtimeMemoKnowledgeStore } from '../src/modules/knowledge/knowledge-store-memo.js'; +import { scanKnowledgeEvents, computeOutcomeMetrics } from '../src/modules/knowledge/knowledge-metrics.js'; + +const REAL_DATA = '/root/.studio/data'; +const REAL_EVENTS = '/root/.studio/logs/studio-events.jsonl'; +const REAL_KNOWLEDGE = '/root/.studio/knowledge'; +const REAL_PROJECTS = '/root/.studio/projects'; +const SYS_CHANNEL = 'sys-1785722554417-mnre'; // 492 行 / 272KB 热 + 1 冷月 79 行(实测最大频道) + +// ── 计时与统计 ── +export interface Stat { n: number; mean: number; p50: number; p95: number; max: number } +export function statOf(xs: number[]): Stat { + const s = [...xs].sort((a, b) => a - b); + const pick = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))]; + return { n: s.length, mean: s.reduce((a, b) => a + b, 0) / s.length, p50: pick(0.5), p95: pick(0.95), max: s[s.length - 1] }; +} +const fmt = (s: Stat) => `n=${s.n} mean=${s.mean.toFixed(2)}ms p50=${s.p50.toFixed(2)}ms p95=${s.p95.toFixed(2)}ms max=${s.max.toFixed(2)}ms`; + +// ── read-metrics 收集 ── +const events: ReadMetricEvent[] = []; +setReadMetricsSink(e => events.push(e)); +function drainEvents(label: string): ReadMetricEvent[] { + return events.filter(e => e.loop === label); +} +function sumEvents(evs: ReadMetricEvent[]): { stat: number; parse: number; clone: number; hits: number; misses: number } { + return { + stat: evs.reduce((a, e) => a + e.statMs, 0), + parse: evs.reduce((a, e) => a + e.readParseMs, 0), + clone: evs.reduce((a, e) => a + e.cloneMs, 0), + hits: evs.filter(e => e.cacheHit).length, + misses: evs.filter(e => !e.cacheHit).length, + }; +} + +// ── 合成频道数据 ── +const CONTENT = 'x'.repeat(420); // 真实样本行均 ~550B/行(272KB/492 行) +export function synthChannel(base: string, channelId: string, hotLines: number, coldMonths: number, coldLinesPerMonth: number): void { + const dir = path.join(base, 'channels', channelId); + fs.mkdirSync(dir, { recursive: true }); + const t0 = Date.now() - (hotLines + coldMonths * coldLinesPerMonth) * 60_000; + const row = (i: number) => JSON.stringify({ + id: `m-${channelId}-${i}`, channelId, authorType: 'agent', agentName: 'Bench', + content: `${CONTENT} ${i}`, replyToId: null, meta: '{"cardType":"bench"}', workUnitId: null, + createdAt: new Date(t0 + i * 60_000).toISOString(), + }); + const hot: string[] = []; + for (let i = 0; i < hotLines; i++) hot.push(row(i)); + fs.writeFileSync(path.join(dir, 'messages.jsonl'), hot.join('\n') + '\n'); + if (coldMonths > 0) { + fs.mkdirSync(path.join(dir, 'archive'), { recursive: true }); + for (let m = 0; m < coldMonths; m++) { + const month = `2024-${String(m + 1).padStart(2, '0')}`; + const rows: string[] = []; + for (let i = 0; i < coldLinesPerMonth; i++) rows.push(row(hotLines + m * coldLinesPerMonth + i)); + fs.writeFileSync(path.join(dir, 'archive', `messages-${month}.jsonl`), rows.join('\n') + '\n'); + } + } +} + +async function benchMessagesPage(label: string, store: FileStore, channelId: string, warmCalls: number, opts?: { before?: string }): Promise { + const walls: number[] = []; + events.length = 0; + for (let i = 0; i < warmCalls + 1; i++) { + const tag = `${label}#${i === 0 ? 'cold' : 'warm'}`; + const t0 = performance.now(); + await runWithLoopLabel(tag, () => store.queryMessagesPage(channelId, { limit: 50, before: opts?.before })); + const ms = performance.now() - t0; + if (i > 0) walls.push(ms); + else console.log(` [${label}] cold(首调,缓存全空): ${ms.toFixed(2)}ms events=${JSON.stringify(sumEvents(drainEvents(tag)))}`); + } + const warmEvs = drainEvents(`${label}#warm`); + const per = warmEvs.length / warmCalls; + const sum = sumEvents(warmEvs); + console.log(` [${label}] warm: ${fmt(statOf(walls))}`); + console.log(` [${label}] warm读口/次: events=${per.toFixed(1)} stat=${(sum.stat / warmCalls).toFixed(2)}ms parse=${(sum.parse / warmCalls).toFixed(2)}ms clone=${(sum.clone / warmCalls).toFixed(2)}ms hits=${sum.hits / warmCalls} misses=${sum.misses / warmCalls}`); +} + +async function benchFn(label: string, fn: () => Promise, warmCalls = 25): Promise { + const walls: number[] = []; + const t0 = performance.now(); + await fn(); + console.log(` [${label}] cold(首调): ${(performance.now() - t0).toFixed(2)}ms`); + for (let i = 0; i < warmCalls; i++) { + const t = performance.now(); + await fn(); + walls.push(performance.now() - t); + } + console.log(` [${label}] warm: ${fmt(statOf(walls))}`); +} + +async function main(): Promise { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'walk5-bench-')); + console.log(`[bench] tmp root: ${tmp}`); + + // ── Part A1:真实 1x(只读 ~/.studio/data)── + console.log('\n== A1 queryMessagesPage 真实 1x(sys 频道:热 492 行/272KB + 冷 1 月 79 行) =='); + await benchMessagesPage('A1-real', new FileStore(REAL_DATA), SYS_CHANNEL, 30); + + // ── Part A2:合成规模扫描 ── + for (const [hot, coldM, coldL] of [[5000, 0, 0], [5000, 12, 1000], [20000, 0, 0], [20000, 12, 1000], [50000, 0, 0]] as const) { + const base = path.join(tmp, `h${hot}-c${coldM}x${coldL}`); + synthChannel(base, 'ch', hot, coldM, coldL); + console.log(`\n== A2 queryMessagesPage 合成:热 ${hot} 行 + 冷 ${coldM} 月×${coldL} 行 ==`); + await benchMessagesPage(`A2-h${hot}-c${coldM}`, new FileStore(base), 'ch', 25); + } + + // ── Part A3:深冷页(锚在最老冷月,iterateColdMessages 全月扫)── + { + const base = path.join(tmp, 'deep-cold'); + synthChannel(base, 'ch', 1000, 12, 1000); + const oldestId = 'm-ch-1000'; // 最老冷月(2024-01)首行 id + console.log('\n== A3 深冷页:热 1000 + 冷 12 月×1000,锚在最老冷月(遍历全 12 月) =='); + await benchMessagesPage('A3-deepcold', new FileStore(base), 'ch', 15, { before: oldestId }); + } + + // ── Part B:monitoring 聚合(只读真实数据)── + console.log('\n== B monitoring 聚合(真实 1x:19 agents/8 profiles、WU index、events 250KB、knowledge 18MB) =='); + const fileStore = new FileStore(REAL_DATA); + const listProjects = async () => { + // 等价 projectService.list 的读量:逐文件读 ~/.studio/projects/*.json + const dir = REAL_PROJECTS; + const out: unknown[] = []; + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith('.json')) continue; + try { out.push(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'))); } catch { /* 坏文件跳过 */ } + } + return out as never[]; + }; + const svc = new MonitoringService(fileStore, null, { listProjects }); + await benchFn('B1-/agents getAgentSummary', () => svc.getAgentSummary()); + await benchFn('B2-/stats getStats', () => svc.getStats()); + await benchFn('B3-/overhead getOverheadStats', () => svc.getOverheadStats({ eventsFile: REAL_EVENTS })); + + // /flywheel 组件:memo knowledge store(tmp 副本,防污染)+ 窗口事件扫描 ×2 + const kbCopy = path.join(tmp, 'knowledge'); + fs.cpSync(REAL_KNOWLEDGE, kbCopy, { recursive: true }); + const memoStore = new MtimeMemoKnowledgeStore(new FileKnowledgeStore({ baseDir: kbCopy })); + await benchFn('B4-/flywheel 组件a store.list({})(memo 指纹校验)', async () => { memoStore.list({}); }); + await benchFn('B5-/flywheel 组件b scanKnowledgeEvents(30d 窗口尾读)', () => scanKnowledgeEvents({ eventsFile: REAL_EVENTS })); + await benchFn('B6-/flywheel 组件c computeOutcomeMetrics(30d 窗口尾读)', () => computeOutcomeMetrics({ eventsFile: REAL_EVENTS })); + + console.log(`\n[bench] done. tmp root kept: ${tmp}`); +} + +// 仅作为脚本直跑时执行(vitest import 不触发,供 __tests__ 测 statOf/synthChannel) +if (require.main === module) { + main().catch(err => { console.error('[bench] failed:', err); process.exit(1); }); +} diff --git a/apps/api/src/modules/channels/CONTEXT.md b/apps/api/src/modules/channels/CONTEXT.md index 1aa816c0..a4f321b8 100644 --- a/apps/api/src/modules/channels/CONTEXT.md +++ b/apps/api/src/modules/channels/CONTEXT.md @@ -42,6 +42,7 @@ Channel 域:频道 CRUD/成员/路由表、消息创建与路由(replyTo 线 - **鉴权分层(2026-07-24 收紧,姿态 A)**:`/api/v1/channels` 在 PUBLIC_API —— GET(`/`、`/:id`、`/:id/messages`)保持**匿名公开**(Lurk Wall 围观本体,不要再给 GET 加中间件);9 条写端点(建频道/发消息/删频道/archive/restore/PATCH/members/convert-to-task×2)= `requireAuth()+requireNotGuest()`(card-decision 端点 #356 起随 auditor_suggestion 卡接线 review-proposal 正本而删)。注意 `POST /:id/messages` 经 @mention 派单/恢复挂起 WU 可直接触发 agent 执行与 LLM 消耗,是收紧前最危险的匿名入口。requirements-docs PUT 同为 requireNotGuest - **apiCache 写后失效(#448,2026-09-02)**:`GET /` 挂 30s apiCache,频道记录写端点(POST `/`、PATCH `/:id`、PATCH `/:id/members`、DELETE `/:id`、PUT archive/restore)成功后 `clearCache(req.baseUrl)` 失效列表缓存——缓存 key 含 baseUrl(api-cache.ts `generateCacheKey`),按挂载点前缀精确清除;`POST /:id/messages` 等不改频道记录的端点不清(高频消息保持缓存有效) - **消息路由优先级**(`message-routing.ts` routeMessage):`replyToId` 线程回复(继承父消息 workUnitId;**#327 起父消息不在热层——已归档/不存在不可区分——降级放行**:帖子成立、replyToId 保留、workUnitId 继承失效落 null、不整帖抛错)→ `@mention` 派单(建 WorkUnit,`metadata.creationMode='mention'`;§9.5 只匹配本频道 members,members 为空回退全量 active profile)→ 纯文本仅存储。mention = 纯文本 `@name`(无结构化 id),检测与 scope 剥离用 Unicode 正则 `[\p{L}\p{N}_-]+/u`;手打中文连写无空格(`@开发你好`)由 **#496 前缀兜底**兜住——token 精确匹配失败时对成员名做最长前缀匹配(互为前缀取最长,等长歧义/同名 profile 并列回退未匹配现状),命中后 scope 只剥 `@成员名` 保留连写剩余文本,`metadata.mentionName` 落解析后正名;studio 系统角色不参与前缀兜底。归属解析出 PMO 项目时创建期落 `metadata.pmoId`(2026-08 归因统一 canonical key;原 `ownershipProjectId` 废弃不再写入,读取侧同级兼容)。**#464 出声**:@mention 未匹配(角色不存在/非本频道成员;@studio 无默认入口)→ 线程回「未找到角色/未配置入口,已转自动认领」系统消息(此前 WU 照建但频道零提示);无归属挂起提问带 milestone(atHuman 响铃 + 行动中心通知)。**#494 派单原子性**:mention/决策12 两派单路径先落派发消息再建 WU,`metadata.anchorMessageId` 显式携带派发消息 id(认领播报优先锚它,消 created 事件先于派发消息落库的 findAnchorMessage 竞态),建单后 `linkWorkUnit` 回填消息 ↔ WU 关联(best-effort,同 createFromMessage 路径)。**#495 决策12 合并窗口(方案 a)**:defaultProfileId 派单前先查合并目标——同频道最近一条携带 workUnitId 的人类消息在窗口内(默认 5 分钟,`STUDIO_CHANNEL_MERGE_WINDOW_MINUTES` 覆盖)且 WU 在途(pending/unassigned/active/blocked)→ 消息并入该 WU 线程(replyToId=anchorMessageId,workUnitId=该 WU)不新建;blocked 经 resumeWaitingWorkUnit 复活注入,其余状态锁内追加 `metadata.pendingReplies`(loop 下一步 prompt-composer 注入)。窗口外/终态(done/closed/in_review)/@mention 照章新建。合并消息自身携带 workUnitId → 滑动窗口(连发 N 条 = 1 张单)。合并目标 metadata 解析走 `parseWuMetadata` 容错口径(畸形 JSON 落 {}、anchorMessageId 缺省走首根回退),不抛错拖垮整道路由 +- **traceId 全链路关联键(#519,2026-09-12)**:发消息路由生成 traceId(复用 audit 中间件 requestId,同一次 HTTP 请求同值;无则 randomUUID),三条派单路径统一落 WU `metadata.traceId`——@mention / 决策12 新建 WU 创建时写入;线程回复与合并窗口关联到既有 WU 时经 `updateMetadata` 刷新为本次请求值(best-effort,失败仅 warn 不阻断)。口径:spec user story 5 二选一取「本次消息 traceId」,即 WU 的 traceId 始终指向最近一次触达它的请求,离线对齐工具按此一致处理 - **频道工单路由表(#466,2026-09-09)**:`routing.ts` 是唯一解析/校验事实源——`ChannelData.routing = { plan?, implement?, review? }`(profile id,某档空 = 回池涌现);`resolveStageRouting` 判定 = 配置了且角色 active 且频道成员(members 空回退 profile.channels,agent-loop 同口径)才指名,否则回池涌现 + 调用方用 `routingFallbackText` 发频道提醒;`validateRouting` 供 PATCH `/channels/:id`(值须 active profile id,'' /null = 清档,不强制成员——成员边界路由时判定,defaultProfileId 先例),PATCH 与存量合并(单档更新不清其他档)。三消费点:publish 建 plan 单(plan 档,显式 assigneeId 优先)、analysis-handoff TASK 拆派工 + workunit feature 展开(implement 档)、review-dispatcher 建评审单(review 档,路由=实现者本人时回退涌现——不许自评指名)。`migrate-routing.ts` 启动幂等吞并:存量 `defaultPipeline`(AC-6.1 name 数组,已退役)首跳 name→id 落 `routing.implement` 并删旧字段,无法解析按死配置移除。**#497 漂移收敛(2026-09-11)**:删 profile 同步清各频道 routing 悬空档(agent-profile.service.delete,与清 members 同一循环,幂等);PATCH `/:id/members` 移出被 routing 档/defaultProfileId 指名的角色 → 响应 `data.warning` 提示(不阻断);fallback 提醒过 `shouldEmitFallbackReminder` 冷却闸(进程内 Map,键 = 频道+档+原因,窗 1h `FALLBACK_REMINDER_COOLDOWN_MS`,重启重置),四个消费点(publish/analysis-handoff/workunit 展开/review-dispatcher)均已接线——冷却只压提醒,不影响路由回退本身。**#477 骨架收口(2026-09-11)**:四消费点共用 `resolveOrNotice`(解析 + fallback 判定 + 冷却闸 + 文案一次完成,返回 `{ resolution, notice }`);出声通道(WU 系统消息/频道 agent 消息)与时机(建单前即时/建单完成后)四点差异留调用侧,review-dispatcher「路由=实现者」专属文案与 workunit-crud #464 未配置提示(notConfiguredText 入参,不过冷却闸)为点级差异,留在调用侧 - **成员绑定**:`channel.members`(config.json 内 JSON 字符串数组)是成员关系唯一事实源,`AgentProfile.channels` 已废弃(启动时幂等迁移);PATCH `/:id/members` 合并 add/remove 后整体回写;删除 profile 时由 AgentProfileService 反向清理各频道 members 悬空引用 - **系统消息唯一发布路径(2026-08-06)**:`channelMessageService.createAgentMessage` 是唯一会发 `channel.message_sent`(eventBus + SSE)的入口;WU 线程系统消息(anchor + pmoId + atHuman)一律经 `workunit/wu-messenger.postWuSystemMessage` 委托到此——禁止再裸 `fileStore.appendMessage` 发系统消息(不发事件 = 通知铃不响、频道页不实时)。`message-routing` 的 @studio 改派/归属提问两处已迁移;`MessageRecord` 类型自本模块导出供 messenger 返回类型使用 diff --git a/apps/api/src/modules/channels/__tests__/message-routing-traceid.test.ts b/apps/api/src/modules/channels/__tests__/message-routing-traceid.test.ts index 48c12d1d..9415f060 100644 --- a/apps/api/src/modules/channels/__tests__/message-routing-traceid.test.ts +++ b/apps/api/src/modules/channels/__tests__/message-routing-traceid.test.ts @@ -1,9 +1,11 @@ /** - * P0 修复 6: traceId 贯穿 — message-routing 段 + * P0 修复 6 + #519: traceId 贯穿 — message-routing 段 * * - @mention 建 WU 时 options.traceId 写入 metadata.traceId * - 无 traceId 时 metadata 不带该字段(向后兼容) - * - 线程回复不建 WU,不受影响 + * - #519 三路径补齐:线程回复关联的 WU、默认角色派单(新建 + 合并窗口并入的在途 WU) + * 同样写入/刷新 metadata.traceId;口径统一为「本次消息 traceId」(spec user story 5 + * 二选一,取与 AC「与本次请求一致」对齐的一项) */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'node:fs'; @@ -85,4 +87,47 @@ describe('message-routing traceId (P0 修复 6)', () => { expect(reply.workUnitId).toBe(parent.workUnitId); expect((await fileStore.getIndex()).length).toBe(wuCountBefore); }); + + // #519: traceId 三条派单路径补齐(spec 2026-09-12-channel-mainline-measurement) + it('#519 线程回复:关联 WU 的 metadata.traceId 刷新为本次请求 traceId', async () => { + const parent = await routeMessage(channelId, '@Nobody 父消息', undefined, fileStore, { + traceId: 'trace-parent', + }); + + const reply = await routeMessage(channelId, '线程回复', parent.id, fileStore, { + traceId: 'trace-reply', + }); + + const meta = await findWuMeta(reply.workUnitId!); + expect(meta.traceId).toBe('trace-reply'); + }); + + it('#519 默认角色派单(新建 WU):options.traceId 写入 metadata.traceId', async () => { + await fileStore.updateChannel(channelId, { defaultProfileId: 'default-agent-1' }); + + const message = await routeMessage(channelId, '无 @ 的普通消息', undefined, fileStore, { + traceId: 'trace-default-new', + }); + + expect(message.workUnitId).toBeTruthy(); + const meta = await findWuMeta(message.workUnitId!); + expect(meta.traceId).toBe('trace-default-new'); + expect(meta.creationMode).toBe('channel-default'); + }); + + it('#519 默认角色合并窗口:并入的在途 WU metadata.traceId 刷新为本次消息 traceId', async () => { + await fileStore.updateChannel(channelId, { defaultProfileId: 'default-agent-1' }); + const first = await routeMessage(channelId, '第一条', undefined, fileStore, { + traceId: 'trace-merge-first', + }); + + const second = await routeMessage(channelId, '窗口内第二条', undefined, fileStore, { + traceId: 'trace-merge-second', + }); + + // 合并入同一张在途 WU,不新建 + expect(second.workUnitId).toBe(first.workUnitId); + const meta = await findWuMeta(first.workUnitId!); + expect(meta.traceId).toBe('trace-merge-second'); + }); }); diff --git a/apps/api/src/modules/channels/channel.routes.ts b/apps/api/src/modules/channels/channel.routes.ts index 0214e92c..7448c2dc 100644 --- a/apps/api/src/modules/channels/channel.routes.ts +++ b/apps/api/src/modules/channels/channel.routes.ts @@ -195,8 +195,8 @@ router.post('/:id/messages', requireAuth(), requireNotGuest(), async (req, res) return res.status(404).json({ success: false, error: 'Channel not found' }); } - // P0 修复 6: traceId — 复用 audit 中间件落在 req 上的 requestId(同一次 HTTP 请求同值), - // 没有则新建(如单测直连路由);@mention 建 WU 时写入 metadata.traceId。 + // P0 修复 6 + #519: traceId — 复用 audit 中间件落在 req 上的 requestId(同一次 HTTP 请求同值), + // 没有则新建(如单测直连路由);三条派单路径建出/关联的 WU 统一写入 metadata.traceId。 const traceId = (req as any).requestId ?? randomUUID(); // #481:body.workspaceId(F6 显式机器指针)已退役,不再接收/生效 diff --git a/apps/api/src/modules/channels/message-routing.ts b/apps/api/src/modules/channels/message-routing.ts index a1516734..a4c865ce 100644 --- a/apps/api/src/modules/channels/message-routing.ts +++ b/apps/api/src/modules/channels/message-routing.ts @@ -104,8 +104,10 @@ async function findMergeTargetWorkUnit( * REQ 需求编号(vision §5.3):@mention 派发时绑定需求 — * options.reqId 显式指定 > 消息文本 #REQ-XXXX token > 自动新建(best-effort)。 * - * P0 修复 6:options.traceId 链路追踪 id — 仅 @mention 建 WU 时写入 metadata.traceId; - * 线程回复不建 WU,不动。 + * P0 修复 6 + #519:options.traceId 链路追踪 id — 三条派单路径统一写入: + * @mention / 默认角色新建 WU 时写入 metadata.traceId;线程回复与合并窗口 + * 关联到既有 WU 时,把该 WU 的 metadata.traceId 刷新为本次请求值(#519 口径: + * spec user story 5 二选一,统一取「本次消息 traceId」,与 AC「与本次请求一致」对齐)。 * * #281(决策 #249 §2/§3 + #257 D7/D9):options.files @文件引用 —— 路由时存在性校验 * (repo ∈ 频道相关工程候选集 且 path ∈ 该仓 git ls-files 词表);有效引用写消息 @@ -185,6 +187,20 @@ export async function routeMessage( }, { source: 'message-routing' }); }; + // #519: 关联到既有 WU 的两条路径(线程回复 / 合并窗口)共用——把 WU metadata.traceId + // 刷新为本次请求 traceId(best-effort:失败仅缺本次关联,不阻断消息路由) + const refreshWuTraceId = async (workUnitId: string) => { + if (!options?.traceId) return; + await resolvedFs.updateMetadata(workUnitId, latest => ({ + ...latest, + traceId: options.traceId, + })).catch(err => + logger.warn('[MessageRouting] Refresh WorkUnit traceId failed (non-blocking)', { + workUnitId, traceId: options.traceId, error: String(err), + }) + ); + }; + // Priority 1: Thread reply — inherit workUnitId from parent if (replyToId) { const found = await resolvedFs.getMessageById(replyToId); @@ -213,6 +229,8 @@ export async function routeMessage( error: String(err), }) ); + // #519: 线程回复关联的 WU 同样携带本次 traceId(全链路统一关联键) + await refreshWuTraceId(inheritedWorkUnitId); } // #492(方案 a):父消息在冷层 → 降级放行的回复不会触达任何任务, // 频道发 Studio 系统提示(挂在该回复线程),用户不再静默失效;提示本身 best-effort。 @@ -455,6 +473,8 @@ export async function routeMessage( }) ); } + // #519: 合并窗口并入的在途 WU 刷新为本次消息 traceId(与线程回复同口径) + await refreshWuTraceId(mergeTarget.id); logger.info('[MessageRouting] Message merged into in-flight WorkUnit (merge window)', { channelId, workUnitId: mergeTarget.id, @@ -480,6 +500,8 @@ export async function routeMessage( creationMode: 'channel-default', // #494: 认领播报的显式线程锚点 anchorMessageId: dispatchMessage.id, + // #519: traceId 贯穿(与 @mention 路径同写法) + ...(options?.traceId ? { traceId: options.traceId } : {}), // #285: @文件引用落档(本路径不做归属解析,仅落档供 prompt-composer files 段消费) ...(filesMeta?.files ? { fileRefs: filesMeta.files } : {}), }, diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index c170fcde..d9fb9152 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -74,6 +74,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `Skeleton` / `SkeletonText` / `SkeletonCard` | `components/ui/Skeleton.tsx` | 批次 E-2 静态骨架占位(样式 `.skeleton`/`.skeleton-text` 在 theme.css「加载状态」区,紧邻 `.loading-spinner`):加载态替代「加载中...」纯文字,WU 列表/详情、ProjectDetail、PMO、DiscussionPanel、ProjectPipeline 6 处接入;整块 aria-hidden。**静态零动画——禁止给 skeleton 加 shimmer/pulse,style-guide §2.5 动效白名单不覆盖** | | `avatarPattern` / `avatarCellAt` | `utils/avatar.ts` | #440 identicon 纯函数:name 双 hash → --chart-1..9 选色 + 5x5 网格左 3 列点阵(右两列镜像),同名恒同图 | | `stripDuplicateH1` | `utils/stripDuplicateH1.ts` | #436 C9:阅览室文档页标题去重——正文首个内容行是与 doc.title 重复的 ATX H1 时剥除(仅首行、仅 `# ` 形态,mid-doc/Setext/`#`无空格不动;保守方向漏剥),LibraryDocPage 侧接入,MarkdownBody 组件不动 | +| `clientPerf` | `utils/clientPerf.ts` | #520 前端测量模块(测量②,spec 2026-09-12-channel-mainline-measurement):read-metrics 同构「模块级 sink + safeEmit」——默认开启(HTTP sink 经既有 POST /events 落 studio-events,不新增端点;测试模式默认 null),`setClientPerfSink(null)` 整体关闭零行为变化,sink 异常永不外泄。三埋点(`client.perf.*`):send_click(ChannelInput 提交瞬间)/ receipt_render(agent 消息 SSE 到达 `markReceiptArrived`(人类消息不记,复审 minor 修复) → ChannelDetailPage 渲染完成 `emitReceiptRendered`,含 ms)/ page_load(进页 `markPageEntry` → 首屏消息渲染完成 `emitPageFirstRender`,含 ms);起点台账模块级 Map、emit 消费即删(每条/每进页至多一次),关联字段用负载现成字段不改 SSE 契约 | ### 依赖关系 @@ -137,6 +138,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **频道翻页游标(#319,2026-08-24)**:`useChannelMessages.loadMore` 的 `before` = 最老消息 **id**(原 createdAt 时间戳,同毫秒多条会漏/重);后端锚点 id 不存在时返回空页 + hasMore=false,loadMore 据此自然停止 - **频道发送乐观回显(#486,2026-09-11)**:`useChannelMessages.sendMessage` 先插本地 pending 消息(id 前缀 `pending-`,`ChannelMessage.pending` 为仅客户端字段,服务端不下发),成功后服务端本体原位替换(SSE 回声先到按 id 去重同样收敛不重复),失败回滚 pending + 上抛(ChannelInput 回灌草稿 + toast 的批次A路径不变);pending 不作 loadMore 分页游标(本地 id 服务端不存在,作锚点会翻空页)、ChannelMessageItem 不出回复/转任务动作,渲 `.mc-msg-pending` 降不透明度 + 气泡内「发送中…」标记;ChannelInput 发送中 textarea 不再禁用(可接着打下一条),sending 只保留发送钮/handleSend 防重复提交 - **线程回复「已送达/等待 agent」即时反馈(#493,2026-09-11)**:`ChannelDetailPage.handleSend` 线程回复送达且 `sent.workUnitId` 非空(= workUnitId 继承成功、会触达 agent;顶层消息与归档降级回复不出)→ composer 上方出 `.mc-agent-ack` 状态条「已送达,等待 agent 响应…」(state `awaitingAgent` = { wuId, since });该 WU 的 agent 新消息到达(SSE 进 messages 即判)或 30s 兜底超时自动消失 +- **频道主链路前端埋点(#520 测量②,2026-09-12)**:`utils/clientPerf.ts` 常驻开启(read-metrics sink 模式但生产默认开、测试模式默认关),事件经既有 `POST /api/v1/events`(source `web-client`)落 studio-events 事件流。接线三点:ChannelInput.handleSend 守卫通过后 `emitSendClick`;`useChannelMessages` SSE `channel.message_sent` 仅新消息(不在列表的回声不记)`markReceiptArrived`;ChannelDetailPage messages effect(提交后 = 渲染完成)先 `emitPageFirstRender` 再逐条 `emitReceiptRendered`——首拉/翻页/水合的历史消息无标记天然跳过,空频道不发 page_load 属正常。 - **DeliveryPanel branch-only 标记已交付(#469,2026-09-10)**:branch-only 且未交付时,台账区除「证据已齐请合并分支」提示(deliverable 时)外,常驻 commit 哈希输入 + 「标记已交付」按钮(`projectApi.markDelivered`,空 commit 禁用;409 走 toast error.message;成功清输入 + onRefresh)——系统外合并后人工落档,台账不再永停「✓ 可交付」。 - **行动中心**(#468 统一行动中心,自通知中心 B2-003/B2-004 重塑;#415 重连对齐保留):「需要我做什么」两类事实一个端点 `GET /action-center`——状态派生 stateItems(reply/review/confirm,无已读概念、状态变即消)+ 事件持久 notifications(已读/未读墓碑)+ unreadCount,三段住 `stores/notificationStore`(读态跨组件共享前提——NotificationBell 只是视图)。**不发明第五套信号**:ChannelNeedInputChip(ChannelDetailPage waitingWus = stateItems 本频道 reply 项投影,本地 blocked REST/SSE 维护已删;reply 不再排除闸门类,排除改面板分区;点条目定位 = 该 WU 当前提问消息高亮,#483 起提问掉出已加载分页时复用 #439 翻页定位循环——上限 10 页,翻到底/超限 → toast 兜底不静默)、TriageBanner(未读 critical incident 通知投影——设计稿 §3 仅 critical 突破成横幅,warning 级留铃铛面板;severity 从 content 首行 `severity:` 解析,「关闭」= 逐条 markRead 持久化)、MoreDropdown 徽标(= unreadCount,原 monitoringApi 24h 计数已删)全部为同一数据源投影。**SSE 只作失效触发**:NotificationBell 上 channel.message_sent atHuman / workunit.status_changed → `load()` 重拉,SSE 不再直接入列。**打开频道即读**:ChannelDetailPage 进页 `markChannelRead(id)`,只清 channelId 匹配的未读(逐条 POST + unreadCount 乐观递减),其他频道不动。**点击直达消息**:频道分支跳转带 `?highlight=`,页面消费一次后交既有 highlightId 机制滚动定位+高亮 2s(目标在折叠线程先展开);后端通知 link 带消息粒度(#439),messageId 经 link 的 `?highlight=` 解析;目标掉出首页分页时沿 #319 翻页游标向前翻页定位(上限 10 页),翻到底/超限 → toast 可见反馈不静默;定位跳转先经 `unpinFromBottom` 解钉(#439 真机走查实测),highlight 目标是降级骨架行时骨架同样带 mc-msg-highlight。通知跳转优先级 #468 起为 WU > 频道(?highlight=) > PMO。**标题闪烁定时器**与行动中心挂钩:startFlash 开新必清旧(曾闭包单变量被覆盖导致 interval 永久泄漏闪烁),停止条件 = unreadCount + stateItems.length 归零/卸载(曾纯 10s 定时、全部已读后照闪)。**重连对齐**(#415,ADR D3):NotificationBell 注册 `onReconnect → load()`。**批次 D-2 处理待办流程断链修复**(docs/plans/2026-09-ui-interaction-polish.md 第一轮 1/2/3 项):① reply 深链——后端 stateItems 补 additive `messageId`(= 该 WU 频道线程最新非人类消息,与 chip「当前提问消息」#279 同口径),reply 点击跳频道带 `?highlight=`(messageId 缺失 fail-closed 不拼参数、无 channelId 仍落 WU 详情);② review/confirm 就地化——点击不再整页跳 `/workunits/:id`,改开 NotificationBell 自挂的 WorkUnitDrawer 实例(lazy 加载 + `.ac-drawer-host` 全断点 fixed 覆盖宿主,与频道页/列表页 drawer 状态机完全隔离;↗ 详情页深链入口随本次补进抽屉 subject 行);③ 下一个待办——闸门动作成功(当前 WU 掉出 stateItems 池 = 状态变即消语义反用)后抽屉内出「下一个 →」同池(review+confirm)导航/「待办都处理完了」收口;reply 线在频道页处理,由 NotificationBell 追踪点击过的 wuId、掉出池时 toast(toast 新增可选 `action` 行动按钮)给「下一个 →」跳下一条频道 | - **`.mc-ws button` 全局重置已 `:where()` 降权**(#431,2026-09-01;原 #375 待清理项了断):mission-control.css 头部重置从 `.mc-ws button`(0-1-1)改 `:where(.mc-ws) button`(0-0-1)——原先被吞的 20 条单类按钮规则(`.mc-btn-primary` 背景、`.mc-icon-btn`/`.mc-drawer-close` 等的 color/padding,清单 = `.studio/research/2026-08-31-style-hygiene.md` §2)自此**首次生效**;`cursor: pointer` 仍由重置承担(TW v4 preflight 不含 button cursor)。mc-* 壳内按钮的单类样式不再需要双类复合绕行(既有绕行保留未清)。 diff --git a/apps/web/src/components/channel/ChannelInput.tsx b/apps/web/src/components/channel/ChannelInput.tsx index a4a23136..1dce4fa7 100644 --- a/apps/web/src/components/channel/ChannelInput.tsx +++ b/apps/web/src/components/channel/ChannelInput.tsx @@ -13,6 +13,7 @@ import { useImeEnterGuard } from '../../hooks/useImeEnterGuard'; import { useRosterStore, activeAgentsOf } from '../../stores/rosterStore'; import { useChannelDataStore } from '../../stores/channelDataStore'; import { toast } from '../../utils/toast'; +import { emitSendClick } from '../../utils/clientPerf'; import { IconImage } from '../ui/icons'; interface Props { @@ -263,6 +264,8 @@ export function ChannelInput({ onSend, sending, replyTo, onCancelReply, channelI const handleSend = async () => { const trimmed = content.trim(); if (!trimmed || sending) return; + // #520 测量②:发送点击瞬间埋点(点事件,不等 REST 结果) + emitSendClick({ channelId, replyToId: replyTo?.id ?? null }); // #281: 只上送正文仍含其路径的引用(发送前删掉路径文本 = 撤销引用) const refs = fileRefs.filter(f => trimmed.includes(f.path)); // 乐观清空(原语义),失败回灌 diff --git a/apps/web/src/components/channel/__tests__/ChannelInput-perf.test.tsx b/apps/web/src/components/channel/__tests__/ChannelInput-perf.test.tsx new file mode 100644 index 00000000..e912c2d8 --- /dev/null +++ b/apps/web/src/components/channel/__tests__/ChannelInput-perf.test.tsx @@ -0,0 +1,67 @@ +// #520 测量②:composer 提交动作瞬间发 client.perf.send_click(埋点①)—— +// 点击发送/Enter 发送即埋点(不等 REST 结果);sink 关闭时发送行为完全不变。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const { mockListAgents, mockSink } = vi.hoisted(() => ({ + mockListAgents: vi.fn(), + mockSink: vi.fn(), +})); + +vi.mock('../../../api/channel', () => ({ + channelApi: { listAgents: mockListAgents }, +})); + +import { ChannelInput } from '../ChannelInput'; +import { useRosterStore } from '../../../stores/rosterStore'; +import { setClientPerfSink, resetClientPerfSink } from '../../../utils/clientPerf'; + +function setup(onSend = vi.fn().mockResolvedValue(undefined)) { + render(); + const textarea = screen.getByPlaceholderText('输入消息,@Agent 提及 Agent...') as HTMLTextAreaElement; + return { onSend, textarea }; +} + +describe('ChannelInput — #520 发送点击埋点', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetClientPerfSink(); + setClientPerfSink(mockSink); + mockListAgents.mockResolvedValue({ data: { data: [] } }); + useRosterStore.setState({ profiles: [], loadedAt: Date.now(), inflight: null, forbidden: false, lastToken: null }); + }); + + it('点击「发送」→ 发 client.perf.send_click(channelId + replyToId=null)', async () => { + const { onSend, textarea } = setup(); + fireEvent.change(textarea, { target: { value: '你好' } }); + fireEvent.click(screen.getByText('发送')); + await waitFor(() => expect(onSend).toHaveBeenCalledWith('你好', undefined)); + + expect(mockSink).toHaveBeenCalledTimes(1); + expect(mockSink.mock.calls[0][0]).toBe('client.perf.send_click'); + expect(mockSink.mock.calls[0][1]).toEqual({ channelId: 'ch-1', replyToId: null }); + }); + + it('Enter 发送同样埋点', () => { + const { textarea } = setup(); + fireEvent.change(textarea, { target: { value: 'enter 发送' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + expect(mockSink).toHaveBeenCalledTimes(1); + expect(mockSink.mock.calls[0][0]).toBe('client.perf.send_click'); + }); + + it('空内容不提交也不埋点', () => { + render(); + fireEvent.click(screen.getByText('发送')); + expect(mockSink).not.toHaveBeenCalled(); + }); + + it('sink 关闭时发送行为不变(onSend 正常调用、无事件)', async () => { + setClientPerfSink(null); + const { onSend, textarea } = setup(); + fireEvent.change(textarea, { target: { value: '关闭埋点' } }); + fireEvent.click(screen.getByText('发送')); + await waitFor(() => expect(onSend).toHaveBeenCalledWith('关闭埋点', undefined)); + expect(mockSink).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useChannelMessages-perf.test.ts b/apps/web/src/hooks/__tests__/useChannelMessages-perf.test.ts new file mode 100644 index 00000000..2ff1447c --- /dev/null +++ b/apps/web/src/hooks/__tests__/useChannelMessages-perf.test.ts @@ -0,0 +1,101 @@ +// #520 测量②:useChannelMessages SSE 到达即记回执渲染计时起点—— +// 新消息(未在列表)到达标记起点;已在列表的 SSE 回声不记(其渲染由 REST 替换完成,非本次到达)。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import type { ChannelMessage } from '../../api/channel'; +import type { WebSocketMessage } from '../../api/websocketHooks'; + +const { mockListMessages, mockOnEvent, mockSink } = vi.hoisted(() => ({ + mockListMessages: vi.fn(), + mockOnEvent: vi.fn(), + mockSink: vi.fn(), +})); + +vi.mock('../../api/channel', () => ({ + channelApi: { listMessages: mockListMessages, sendMessage: vi.fn() }, +})); + +vi.mock('../../api/websocketHooks', () => ({ + useWebSocketContext: () => ({ onEvent: mockOnEvent, status: 'connected' }), +})); + +import { useChannelMessages } from '../useChannelEvents'; +import { setClientPerfSink, resetClientPerfSink, emitReceiptRendered } from '../../utils/clientPerf'; + +function msg(id: string, over: Partial = {}): ChannelMessage { + return { + id, channelId: 'ch-1', authorType: 'agent', content: `内容-${id}`, + replyToId: null, workUnitId: null, meta: '{}', + createdAt: new Date(1000).toISOString(), ...over, + }; +} + +function sseMessage(message: ChannelMessage): WebSocketMessage { + return { + event_id: `ev-${message.id}`, + event_type: 'channel.message_sent', + timestamp: message.createdAt, + data: { channelId: 'ch-1', message }, + }; +} + +describe('useChannelMessages — #520 回执渲染计时起点', () => { + let handler: (msg: WebSocketMessage) => void; + + beforeEach(() => { + vi.clearAllMocks(); + resetClientPerfSink(); + setClientPerfSink(mockSink); + mockListMessages.mockResolvedValue({ data: { data: [], hasMore: false } }); + mockOnEvent.mockImplementation((h: (msg: WebSocketMessage) => void) => { + handler = h; + return () => {}; + }); + }); + + async function renderLoaded(initial: ChannelMessage[] = []) { + mockListMessages.mockResolvedValue({ data: { data: initial, hasMore: false } }); + const { result } = renderHook(() => useChannelMessages('ch-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + return result; + } + + it('SSE 新消息到达 → 标记起点(渲染完成可配对出 receipt_render 事件)', async () => { + await renderLoaded([]); + act(() => handler(sseMessage(msg('m-1', { workUnitId: 'WU-1' })))); + + emitReceiptRendered({ messageId: 'm-1', channelId: 'ch-1', workUnitId: 'WU-1' }); + expect(mockSink).toHaveBeenCalledTimes(1); + expect(mockSink.mock.calls[0][0]).toBe('client.perf.receipt_render'); + expect(mockSink.mock.calls[0][1]).toMatchObject({ messageId: 'm-1', channelId: 'ch-1', workUnitId: 'WU-1' }); + expect(typeof mockSink.mock.calls[0][1].ms).toBe('number'); + }); + + it('已在列表的消息的 SSE 回声 → 不标记(后续渲染不发事件)', async () => { + const m1 = msg('m-1'); + await renderLoaded([m1]); + act(() => handler(sseMessage(m1))); // 回声:id 已存在 + + emitReceiptRendered({ messageId: 'm-1', channelId: 'ch-1', workUnitId: null }); + expect(mockSink).not.toHaveBeenCalled(); + }); + + it('人类新消息到达 → 不标记(回执口径仅 agent 消息,复审 minor 修复)', async () => { + await renderLoaded([]); + act(() => handler(sseMessage(msg('h-1', { authorType: 'human' })))); + + emitReceiptRendered({ messageId: 'h-1', channelId: 'ch-1', workUnitId: null }); + expect(mockSink).not.toHaveBeenCalled(); + }); + + it('sink 关闭时 SSE 到达不标记、不抛错', async () => { + setClientPerfSink(null); + const result = await renderLoaded([]); + act(() => handler(sseMessage(msg('m-2')))); + expect(result.current.messages.map(m => m.id)).toEqual(['m-2']); + + setClientPerfSink(mockSink); + emitReceiptRendered({ messageId: 'm-2', channelId: 'ch-1', workUnitId: null }); + expect(mockSink).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useChannelEvents.ts b/apps/web/src/hooks/useChannelEvents.ts index ebd303d3..aa2ddd3e 100644 --- a/apps/web/src/hooks/useChannelEvents.ts +++ b/apps/web/src/hooks/useChannelEvents.ts @@ -5,6 +5,7 @@ import { channelApi, type ChannelMessage, type FileRef } from '../api/channel'; import { useWebSocketContext } from '../api/websocketHooks'; import { useGatedPoll } from './useGatedPoll'; import { degradeMessage, planPrune, PRUNE_KEEP_RECENT, PRUNE_DEGRADE_DISTANCE, PRUNE_HYDRATE_DISTANCE, PRUNE_HYDRATE_PAGE_LIMIT, type PruneOptions } from '../utils/messagePruning'; +import { markReceiptArrived } from '../utils/clientPerf'; /** #287(清单 P2 #19):增量到达按 createdAt 升序归位 + id 去重。 * 下游 groupIntoThreads 单遍归组要求 anchor 先于 reply 出现;一律 push 尾部会让 @@ -109,6 +110,11 @@ export function useChannelMessages(channelId: string | undefined, options?: UseC if (msg.event_type === 'channel.message_sent') { const data = msg.data as { channelId?: string; message?: ChannelMessage }; if (data?.channelId === channelId && data?.message) { + // #520 测量②:回执渲染计时起点——仅 agent 新消息记(人类消息不是回执,复审 minor 修复); + // 已在列表的 SSE 回声不记(其渲染由 REST 替换完成,非本次到达) + if (data.message.authorType === 'agent' && !messagesRef.current.some(m => m.id === data.message!.id)) { + markReceiptArrived(data.message.id); + } setMessages(prev => insertMessage(prev, data.message!)); } } else if (msg.event_type === 'channel.message_updated') { diff --git a/apps/web/src/pages/ChannelDetailPage.tsx b/apps/web/src/pages/ChannelDetailPage.tsx index 2aed4f32..cfa498b5 100644 --- a/apps/web/src/pages/ChannelDetailPage.tsx +++ b/apps/web/src/pages/ChannelDetailPage.tsx @@ -38,6 +38,7 @@ import { parseLiveWuRef } from '../components/workunit/execution-rows'; import type { Channel, ChannelMessage, ChannelSuggestion, FileRef } from '../api/channel'; import { channelApi } from '../api/channel'; import { saveLastChannelId } from '../utils/lastChannel'; +import { markPageEntry, emitPageFirstRender, emitReceiptRendered } from '../utils/clientPerf'; import { toast } from '../utils/toast'; /** #439:?highlight 定位的翻页页数上限(50 条/页 → 最多回看 500 条),超限/翻到底降级为可见反馈 */ @@ -115,8 +116,22 @@ export function ChannelDetailPage() { const { id } = useParams<{ id: string }>(); // #393:记录最近访问频道(/ 与 /channels 重定向落点,spec §2) useEffect(() => { if (id) saveLastChannelId(id); }, [id]); + + // #520 测量②:client.perf 埋点③起点——进页记时(埋点①在 ChannelInput,②起点在 useChannelMessages) + useEffect(() => { if (id) markPageEntry(id); }, [id]); const [channel, setChannel] = useState(null); const { messages, loading, error, sendMessage, loadMore, hasMore, refresh, syncPruning } = useChannelMessages(id); + + // #520 测量②:渲染完成终点(effect 于提交后跑 = 渲染已完成)—— + // ③ page_load:首屏消息渲染完成(每进页至多一次,起点消费后不再发;空频道不发属正常); + // ② receipt_render:仅 SSE 到达时标记过的消息发事件(首拉/翻页/水合的历史消息无标记,天然跳过) + useEffect(() => { + if (!id || loading || messages.length === 0) return; + emitPageFirstRender(id); + for (const m of messages) { + emitReceiptRendered({ messageId: m.id, channelId: id, workUnitId: m.workUnitId ?? null }); + } + }, [id, loading, messages]); const [sending, setSending] = useState(false); // 折叠 UI 状态(showCompleted / collapsedThreads / expandedProcGroups)按频道持久化(Step 3), // setter 语义同 useState;线程默认全部展开,collapsedThreads 只存手动收起的锚点 id diff --git a/apps/web/src/pages/__tests__/ChannelDetailPage-perf.test.tsx b/apps/web/src/pages/__tests__/ChannelDetailPage-perf.test.tsx new file mode 100644 index 00000000..2392112e --- /dev/null +++ b/apps/web/src/pages/__tests__/ChannelDetailPage-perf.test.tsx @@ -0,0 +1,112 @@ +// #520 测量②:频道页 jsdom 组件测试——埋点②(回执 SSE 到达→渲染完成)与 +// 埋点③(进页→首屏消息渲染完成)的时机断言;sink 关闭时页面行为不变。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const { mockApiGet, mockSink } = vi.hoisted(() => ({ + mockApiGet: vi.fn(), + mockSink: vi.fn(), +})); + +vi.mock('../../api', () => ({ api: { get: mockApiGet, post: vi.fn() } })); + +let currentMessages: ChannelMessage[] = []; +vi.mock('../../hooks/useChannelEvents', () => ({ + useChannelMessages: () => ({ + messages: currentMessages, + loading: false, + error: null, + hasMore: false, + sendMessage: vi.fn(), + loadMore: vi.fn(), + refresh: vi.fn(), + syncPruning: vi.fn(), + }), +})); + +vi.mock('../../api/workunit', () => ({ workunitApi: { list: vi.fn().mockResolvedValue({ data: { data: [] } }) } })); +vi.mock('../../api/requirements', () => ({ requirementApi: { list: vi.fn().mockResolvedValue({ data: { data: [] } }), get: vi.fn() } })); +vi.mock('../../api/websocketHooks', () => ({ + useWebSocketContext: () => ({ onEvent: vi.fn(() => () => {}), onReconnect: vi.fn(() => () => {}) }), +})); +vi.mock('../../components/channel/ChannelRail', () => ({ ChannelRail: () =>
})); +vi.mock('../../components/channel/WorkUnitDrawer', () => ({ WorkUnitDrawer: () => null })); +vi.mock('../../components/channel/ChannelMemberManager', () => ({ ChannelMemberManager: () => null })); +vi.mock('../../components/channel/ChannelDefaultProjectSelect', () => ({ ChannelDefaultProjectSelect: () => null })); +vi.mock('../../components/channel/ChannelCurrentPmoChip', () => ({ ChannelCurrentPmoChip: () => null })); +vi.mock('../../components/channel/ChannelInput', () => ({ ChannelInput: () => null })); +vi.mock('../../components/channel/RequirementsDocCard', () => ({ RequirementsDocCard: () => null })); +vi.mock('../../components/channel/KnowledgeConfirmCard', () => ({ KnowledgeConfirmCard: () => null })); +vi.mock('../../components/channel/ConvertToTaskDialog', () => ({ ConvertToTaskDialog: () => null })); + +import { ChannelDetailPage } from '../ChannelDetailPage'; +import type { ChannelMessage } from '../../api/channel'; +import { setClientPerfSink, resetClientPerfSink, markReceiptArrived } from '../../utils/clientPerf'; + +const iso = (s: number) => new Date(s * 1000).toISOString(); + +function msg(id: string, seq: number, over: Partial = {}): ChannelMessage { + return { + id, channelId: 'ch-1', authorType: 'agent', agentName: 'pm', + content: `正文-${id}`, createdAt: iso(seq), ...over, + }; +} + +const tree = () => ( + + + } /> + + +); + +const eventsOf = (type: string) => + mockSink.mock.calls.filter(c => c[0] === type).map(c => c[1]); + +describe('ChannelDetailPage — #520 页面加载/回执渲染埋点时机', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetClientPerfSink(); + setClientPerfSink(mockSink); + currentMessages = [msg('t-1', 1, { workUnitId: 'WU-1' })]; + mockApiGet.mockResolvedValue({ data: { data: null } }); + }); + + it('③ 进页且首屏消息渲染完成 → 发 client.perf.page_load(channelId + 耗时),仅一次', async () => { + const { rerender } = render(tree()); + await waitFor(() => expect(eventsOf('client.perf.page_load')).toHaveLength(1)); + expect(eventsOf('client.perf.page_load')[0].channelId).toBe('ch-1'); + expect(typeof eventsOf('client.perf.page_load')[0].ms).toBe('number'); + + // 消息流后续更新(新消息到达)不重发 page_load + currentMessages = [...currentMessages, msg('t-2', 2)]; + rerender(tree()); + await waitFor(() => expect(document.querySelector('[data-message-id="t-2"]')).toBeTruthy()); + expect(eventsOf('client.perf.page_load')).toHaveLength(1); + }); + + it('② SSE 标记过的消息渲染完成 → 发 client.perf.receipt_render(messageId/workUnitId/ms)', async () => { + const { rerender } = render(tree()); + await waitFor(() => expect(eventsOf('client.perf.page_load')).toHaveLength(1)); + // 首拉消息无 SSE 标记 → 不发 receipt_render + expect(eventsOf('client.perf.receipt_render')).toHaveLength(0); + + // SSE 到达(hook 记起点)→ 消息进列表渲染完成 + markReceiptArrived('a-9'); + currentMessages = [...currentMessages, msg('a-9', 2, { workUnitId: 'WU-1' })]; + rerender(tree()); + + await waitFor(() => expect(eventsOf('client.perf.receipt_render')).toHaveLength(1)); + const payload = eventsOf('client.perf.receipt_render')[0]; + expect(payload).toMatchObject({ channelId: 'ch-1', messageId: 'a-9', workUnitId: 'WU-1' }); + expect(typeof payload.ms).toBe('number'); + }); + + it('sink 关闭时页面正常渲染、零事件', async () => { + setClientPerfSink(null); + render(tree()); + await waitFor(() => expect(document.querySelector('[data-message-id="t-1"]')).toBeTruthy()); + expect(mockSink).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/utils/__tests__/clientPerf.test.ts b/apps/web/src/utils/__tests__/clientPerf.test.ts new file mode 100644 index 00000000..eebb5a23 --- /dev/null +++ b/apps/web/src/utils/__tests__/clientPerf.test.ts @@ -0,0 +1,139 @@ +// #520(spec 2026-09-12-channel-mainline-measurement §3/§4):clientPerf sink 单测—— +// 三埋点的事件名/负载字段/触发时机(mark→emit 配对)、默认 HTTP sink 落既有事件端点、 +// 关闭时零行为变化、sink 异常不外泄。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockApiPost } = vi.hoisted(() => ({ mockApiPost: vi.fn() })); + +vi.mock('../../api', () => ({ api: { post: mockApiPost } })); + +import { + setClientPerfSink, + resetClientPerfSink, + createHttpSink, + emitSendClick, + markReceiptArrived, + emitReceiptRendered, + markPageEntry, + emitPageFirstRender, + type ClientPerfEventType, + type ClientPerfPayload, +} from '../clientPerf'; + +type Call = { type: ClientPerfEventType; payload: ClientPerfPayload }; + +function collectSink() { + const calls: Call[] = []; + setClientPerfSink((type, payload) => { calls.push({ type, payload }); }); + return calls; +} + +beforeEach(() => { + vi.clearAllMocks(); + resetClientPerfSink(); +}); + +describe('clientPerf 三埋点(事件名/负载字段/时机)', () => { + it('① emitSendClick 发 client.perf.send_click,负载带 channelId/replyToId', () => { + const calls = collectSink(); + emitSendClick({ channelId: 'ch-1', replyToId: null }); + emitSendClick({ channelId: 'ch-1', replyToId: 'm-9' }); + expect(calls).toEqual([ + { type: 'client.perf.send_click', payload: { channelId: 'ch-1', replyToId: null } }, + { type: 'client.perf.send_click', payload: { channelId: 'ch-1', replyToId: 'm-9' } }, + ]); + }); + + it('② SSE 到达标记 → 渲染完成发 client.perf.receipt_render(含耗时与关联字段)', () => { + const calls = collectSink(); + markReceiptArrived('m-1'); + emitReceiptRendered({ messageId: 'm-1', channelId: 'ch-1', workUnitId: 'WU-1' }); + expect(calls).toHaveLength(1); + expect(calls[0].type).toBe('client.perf.receipt_render'); + expect(calls[0].payload.channelId).toBe('ch-1'); + expect(calls[0].payload.messageId).toBe('m-1'); + expect(calls[0].payload.workUnitId).toBe('WU-1'); + expect(typeof calls[0].payload.ms).toBe('number'); + expect(calls[0].payload.ms!).toBeGreaterThanOrEqual(0); + }); + + it('② 未标记的消息渲染不发事件;标记消费后同一条不重发', () => { + const calls = collectSink(); + emitReceiptRendered({ messageId: 'm-x', channelId: 'ch-1', workUnitId: null }); + expect(calls).toHaveLength(0); + + markReceiptArrived('m-1'); + emitReceiptRendered({ messageId: 'm-1', channelId: 'ch-1', workUnitId: null }); + emitReceiptRendered({ messageId: 'm-1', channelId: 'ch-1', workUnitId: null }); + expect(calls).toHaveLength(1); + }); + + it('③ 进页标记 → 首屏消息渲染完成发 client.perf.page_load(含耗时),每进页至多一次', () => { + const calls = collectSink(); + emitPageFirstRender('ch-1'); // 未进页标记 → 不发 + expect(calls).toHaveLength(0); + + markPageEntry('ch-1'); + emitPageFirstRender('ch-1'); + emitPageFirstRender('ch-1'); // 起点已消费 → 不重发 + expect(calls).toHaveLength(1); + expect(calls[0].type).toBe('client.perf.page_load'); + expect(calls[0].payload.channelId).toBe('ch-1'); + expect(typeof calls[0].payload.ms).toBe('number'); + }); +}); + +describe('clientPerf sink 关闭(零行为变化)', () => { + it('sink=null 时三埋点全部 no-op 且不抛', () => { + setClientPerfSink(null); + expect(() => { + emitSendClick({ channelId: 'ch-1', replyToId: null }); + markReceiptArrived('m-1'); + emitReceiptRendered({ messageId: 'm-1', channelId: 'ch-1', workUnitId: null }); + markPageEntry('ch-1'); + emitPageFirstRender('ch-1'); + }).not.toThrow(); + }); + + it('关闭期间的标记不留存——重新开启后补渲染不发事件', () => { + setClientPerfSink(null); + markReceiptArrived('m-1'); + markPageEntry('ch-1'); + const calls = collectSink(); // 重新装配 + emitReceiptRendered({ messageId: 'm-1', channelId: 'ch-1', workUnitId: null }); + emitPageFirstRender('ch-1'); + expect(calls).toHaveLength(0); + }); +}); + +describe('clientPerf 异常不外泄', () => { + it('sink 抛异常被 safeEmit 吞掉', () => { + setClientPerfSink(() => { throw new Error('boom'); }); + expect(() => { + emitSendClick({ channelId: 'ch-1', replyToId: null }); + markReceiptArrived('m-1'); + emitReceiptRendered({ messageId: 'm-1', channelId: 'ch-1', workUnitId: null }); + }).not.toThrow(); + }); +}); + +describe('clientPerf 默认 HTTP sink(经既有 POST /api/v1/events 落事件流)', () => { + it('createHttpSink 按端点契约 POST {type, source, payload}', () => { + mockApiPost.mockResolvedValue({ status: 201 }); + setClientPerfSink(createHttpSink()); + emitSendClick({ channelId: 'ch-1', replyToId: null }); + expect(mockApiPost).toHaveBeenCalledWith('/events', { + type: 'client.perf.send_click', + source: 'web-client', + payload: { channelId: 'ch-1', replyToId: null }, + }); + }); + + it('端点失败静默(reject 不外泄)', async () => { + mockApiPost.mockRejectedValue(new Error('network down')); + setClientPerfSink(createHttpSink()); + expect(() => emitSendClick({ channelId: 'ch-1', replyToId: null })).not.toThrow(); + // 让 reject 链跑完,断言无 unhandled rejection 打断测试 + await new Promise(resolve => setTimeout(resolve, 0)); + }); +}); diff --git a/apps/web/src/utils/clientPerf.ts b/apps/web/src/utils/clientPerf.ts new file mode 100644 index 00000000..d920fdcc --- /dev/null +++ b/apps/web/src/utils/clientPerf.ts @@ -0,0 +1,126 @@ +/** + * #520(spec .studio/specs/2026-09-12-channel-mainline-measurement §3/§4,决议 #505): + * 前端测量模块——频道主链路三处黑盒埋点,与 read-metrics 同构的「模块级 sink + safeEmit」模式: + * + * - 模块级 sink:默认开启(最小常驻版,这是与 read-metrics 生产恒 null 的差异)—— + * HTTP sink 经既有 POST /api/v1/events 落 studio-events 事件流,不新增端点; + * 测试模式(import.meta.env.MODE === 'test')默认 null;setClientPerfSink(null) 整体关闭, + * 关闭时每个埋点除一次判空外零开销、零行为变化。 + * - safeEmit:sink 抛异常一律吞掉,测量路径永不外泄(同 read-metrics 先例)。 + * + * 三个埋点(事件名统一 client.perf.* 前缀;关联字段用负载现成字段,不改 SSE 契约): + * ① send_click — composer 提交动作瞬间(ChannelInput.handleSend),点事件无耗时; + * ② receipt_render — 回执消息 SSE 到达(useChannelMessages 记起点)→ 渲染完成 + * (ChannelDetailPage messages effect),含耗时 ms; + * ③ page_load — 频道页进页 → 首屏消息渲染完成,含耗时 ms。 + */ +import { api } from '../api'; + +export type ClientPerfEventType = + | 'client.perf.send_click' + | 'client.perf.receipt_render' + | 'client.perf.page_load'; + +export interface ClientPerfPayload { + channelId?: string; + messageId?: string; + workUnitId?: string | null; + replyToId?: string | null; + /** 耗时毫秒(send_click 为点事件,无此字段) */ + ms?: number; +} + +export type ClientPerfSink = (type: ClientPerfEventType, payload: ClientPerfPayload) => void; + +/** 默认 HTTP sink:经既有事件端点落 studio-events 事件流(fire-and-forget,失败静默)。 */ +export function createHttpSink(): ClientPerfSink { + return (type, payload) => { + void api.post('/events', { type, source: 'web-client', payload: { ...payload } }).catch(() => {}); + }; +} + +/** 默认 sink:生产/开发 = HTTP sink(常驻开启);测试模式 = null(spec §4:测试可整体关闭)。 */ +function defaultSink(): ClientPerfSink | null { + return import.meta.env.MODE === 'test' ? null : createHttpSink(); +} + +let sink: ClientPerfSink | null = defaultSink(); + +/** 设置/关闭测量 sink(null = 整体关闭)。 */ +export function setClientPerfSink(next: ClientPerfSink | null): void { + sink = next; +} + +/** 恢复默认 sink(测试装配 mock 后复原用)。 */ +export function resetClientPerfSink(): void { + sink = defaultSink(); +} + +/** 测量绝不影响业务:sink 抛异常一律吞掉。 */ +function safeEmit(type: ClientPerfEventType, payload: ClientPerfPayload): void { + const s = sink; + if (s === null) return; + try { + s(type, payload); + } catch { /* 测量路径永不外泄 */ } +} + +// ── 起点台账(模块级;emit 消费后即删,每条/每进页至多发一次)── +const marks = new Map(); +/** 台账上限:长期会话防膨胀(测量专用数据,超限整体丢弃可接受) */ +const MARKS_CAP = 1000; + +const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +function perfMark(key: string): void { + if (sink === null) return; // 关闭期间不记起点——重开后补渲染不误发 + if (marks.size >= MARKS_CAP) marks.clear(); + marks.set(key, now()); +} + +/** 取走起点(取后即删);无起点(未标记/已消费)→ null。 */ +function takeMark(key: string): number | null { + const t0 = marks.get(key); + if (t0 === undefined) return null; + marks.delete(key); + return t0; +} + +/** 埋点①:composer 提交动作瞬间(ChannelInput.handleSend 守卫通过后调用)。 */ +export function emitSendClick(input: { channelId?: string; replyToId?: string | null }): void { + if (sink === null) return; + safeEmit('client.perf.send_click', { channelId: input.channelId, replyToId: input.replyToId ?? null }); +} + +/** 埋点②起点:回执消息 SSE 到达(useChannelMessages 接线;已在列表的回声不记)。 */ +export function markReceiptArrived(messageId: string): void { + perfMark(`receipt:${messageId}`); +} + +/** 埋点②终点:消息渲染完成(ChannelDetailPage messages effect 逐条调用; + * 仅 SSE 标记过的消息发事件,每条至多一次)。 */ +export function emitReceiptRendered(input: { messageId: string; channelId?: string; workUnitId?: string | null }): void { + if (sink === null) return; + const t0 = takeMark(`receipt:${input.messageId}`); + if (t0 === null) return; + safeEmit('client.perf.receipt_render', { + channelId: input.channelId, + messageId: input.messageId, + workUnitId: input.workUnitId ?? null, + ms: Math.max(0, now() - t0), + }); +} + +/** 埋点③起点:频道页进页(ChannelDetailPage 挂载/切换频道)。 */ +export function markPageEntry(channelId: string): void { + perfMark(`page:${channelId}`); +} + +/** 埋点③终点:首屏消息渲染完成(起点消费后不再发——每进页至多一次; + * 空频道无首屏消息属正常,不发事件)。 */ +export function emitPageFirstRender(channelId: string): void { + if (sink === null) return; + const t0 = takeMark(`page:${channelId}`); + if (t0 === null) return; + safeEmit('client.perf.page_load', { channelId, ms: Math.max(0, now() - t0) }); +} diff --git a/bench-msg-intake.mts b/bench-msg-intake.mts new file mode 100644 index 00000000..89867181 --- /dev/null +++ b/bench-msg-intake.mts @@ -0,0 +1,154 @@ +/** + * #506 走查①「消息进」bench:POST /:id/messages 请求路径各嫌疑点量化。 + * + * 用法(主 checkout 只读,数据写 tmp): + * /root/projects/studio/node_modules/.bin/tsx bench-msg-intake.ts + * + * 测量分区: + * A. git ls-files 子进程成本(嫌疑点⑧核心,file-ref-vocabulary.ts:130-160) + * B. validateFileRefs 端到端(真实 ~/.studio 只读;候选集 = #研发 defaultPath=/root/projects/studio) + * C. appendMessage 写路径(tmp FileStore:flock + append + 压实评估) + * D. commitSnapshot 建单成本(tmp,index 规模对齐真实 49 快照) + * E. 附件上传同步 CPU(8MB JSON.parse + 6.7MB base64 decode,事件循环阻塞) + * F. getMessageById 全频道扫描(reply 路径,真实数据只读) + * G. queryMessages(合并窗口路径,真实数据只读) + * H. 鉴权 readJson sessions/users(真实文件只读;sessions.json 592KB/966 条) + */ +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +process.env.STUDIO_DATA_DIR = process.env.STUDIO_DATA_DIR ?? path.join(os.homedir(), '.studio', 'data'); + +const SHARED = '/root/projects/studio/packages/studio-shared/src'; +const CHANNELS = '/root/projects/studio/apps/api/src/modules/channels'; + +const { FileStore } = await import(`${SHARED}/file-store.ts`); +const { validateFileRefs, computeCandidateRepos, invalidateFileRefVocabularyCache } = + await import(`${CHANNELS}/file-ref-vocabulary.ts`); + +function stats(name: string, samples: number[]): void { + const s = [...samples].sort((a, b) => a - b); + const sum = s.reduce((a, b) => a + b, 0); + const p = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))]; + console.log( + `${name} n=${s.length} min=${s[0].toFixed(2)}ms p50=${p(0.5).toFixed(2)}ms ` + + `p95=${p(0.95).toFixed(2)}ms max=${s[s.length - 1].toFixed(2)}ms mean=${(sum / s.length).toFixed(2)}ms`, + ); +} + +async function timeIt(name: string, fn: () => Promise | unknown, n: number): Promise { + const samples: number[] = []; + for (let i = 0; i < n; i++) { + const t0 = performance.now(); + await fn(); + samples.push(performance.now() - t0); + } + stats(name, samples); +} + +function gitLsFiles(repo: string): Promise { + return new Promise((resolve, reject) => { + execFile('git', ['ls-files'], { cwd: repo, maxBuffer: 64 * 1024 * 1024 }, (err, stdout) => { + if (err) return reject(err); + resolve(stdout.split('\n').map(l => l.trim()).filter(Boolean)); + }); + }); +} + +// ─── A. git ls-files 子进程(嫌疑点⑧) ─── +console.log('\n=== A. git ls-files(repo=/root/projects/studio)==='); +const files = await gitLsFiles('/root/projects/studio'); +console.log(`ls-files 条目数: ${files.length}`); +await timeIt('A git-ls-files warm(OS page cache 热)', () => gitLsFiles('/root/projects/studio'), 30); + +// ─── B. validateFileRefs 端到端(真实数据只读) ─── +console.log('\n=== B. file-ref 校验(#研发频道,真实 ~/.studio 只读)==='); +const CHANNEL_ID = '70668707-e9bc-4da7-8443-c35bd3d84646'; // #研发,defaultPath=/root/projects/studio +invalidateFileRefVocabularyCache(); +{ + const t0 = performance.now(); + const repos = await computeCandidateRepos(CHANNEL_ID); + console.log(`B computeCandidateRepos cold: ${(performance.now() - t0).toFixed(2)}ms repos=${JSON.stringify(repos)}`); +} +await timeIt('B computeCandidateRepos warm', () => computeCandidateRepos(CHANNEL_ID), 20); +const REF = [{ repo: '/root/projects/studio', path: 'package.json' }]; +invalidateFileRefVocabularyCache(); +{ + const t0 = performance.now(); + const r = await validateFileRefs(CHANNEL_ID, REF); + console.log(`B validateFileRefs cold(cache miss → git ls-files 在请求路径): ${(performance.now() - t0).toFixed(2)}ms kept=${r.kept.length} dropped=${r.dropped.length}`); +} +await timeIt('B validateFileRefs warm(60s TTL 内)', () => validateFileRefs(CHANNEL_ID, REF), 20); + +// ─── C. appendMessage 写路径(tmp) ─── +console.log('\n=== C. appendMessage(tmp FileStore)==='); +const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'msg-intake-c-')); +{ + const fsC = new FileStore(tmpC); + await fsC.createChannel({ id: 'ch1', name: '#t', type: 'rnd' } as never); + const mkMsg = (i: number) => ({ + id: `m${i}`, channelId: 'ch1', authorType: 'human', agentName: null, + content: `bench message ${i}`, replyToId: null, meta: '{}', workUnitId: null, + createdAt: new Date().toISOString(), + }); + await timeIt('C appendMessage(flock+append+压实评估)', () => fsC.appendMessage('ch1', mkMsg(Math.random())) as Promise, 200); +} + +// ─── D. commitSnapshot 建单成本(index 49 快照对齐真实) ─── +console.log('\n=== D. commitSnapshot(tmp,index=49 快照对齐真实 ~/.studio)==='); +const tmpD = fs.mkdtempSync(path.join(os.tmpdir(), 'msg-intake-d-')); +{ + const fsD = new FileStore(tmpD); + const now = new Date().toISOString(); + const seed = Array.from({ length: 49 }, (_, i) => ({ + id: `wu-seed-${i}`, scope: `seed ${i}`, channelId: 'ch1', type: 'task', + status: 'active', assigneeId: 'a1', reqId: null, metadata: '{}', + createdAt: now, updatedAt: now, + })); + fs.mkdirSync(path.join(tmpD, 'workunits'), { recursive: true }); + fs.writeFileSync(path.join(tmpD, 'workunits', 'index.json'), JSON.stringify(seed, null, 2)); + await timeIt('D commitSnapshot(flock+appendEvent+全量 index 重写+fsync)', async () => { + const id = `wu-${Math.random()}`; + const snap = { id, scope: 'bench', channelId: 'ch1', type: 'task', status: 'unassigned', assigneeId: null, reqId: null, metadata: '{}', createdAt: now, updatedAt: now }; + await fsD.commitSnapshot({ type: 'created', wuId: id, timestamp: now, data: snap as never }, snap as never); + }, 50); +} + +// ─── E. 附件上传同步 CPU(事件循环阻塞量) ─── +console.log('\n=== E. 附件上传同步 CPU(5MB 图上界)==='); +{ + const raw = Buffer.alloc(5 * 1024 * 1024, 0xab); + const b64 = raw.toString('base64'); + const body = JSON.stringify({ mime: 'image/png', dataBase64: b64 }); + console.log(`body 大小: ${(body.length / 1024 / 1024).toFixed(2)}MB(json limit 8mb)`); + await timeIt('E JSON.parse(8MB body)', () => JSON.parse(body), 20); + await timeIt('E Buffer.from base64 decode(6.7MB→5MB)', () => Buffer.from(b64, 'base64'), 20); +} + +// ─── F/G/H. 真实数据只读 ─── +console.log('\n=== F/G/H. 真实 ~/.studio 只读 ==='); +{ + const fsR = new FileStore(); // STUDIO_DATA_DIR=~/.studio/data + const bigChannel = 'sys-1785722554417-mnre'; // messages.jsonl 492 行 / 272KB,本机最大频道 + const lastId = '94b05bf9-0d1a-490c-b0da-924d906360f0'; + { + const t0 = performance.now(); + const r = await fsR.getMessageById(lastId); + console.log(`F getMessageById cold(扫全部频道 messages.jsonl): ${(performance.now() - t0).toFixed(2)}ms found=${!!r}`); + } + await timeIt('F getMessageById warm(jsonlCache 命中)', () => fsR.getMessageById(lastId), 20); + await timeIt('G queryMessages(human,limit=20) 合并窗口读', () => fsR.queryMessages(bigChannel, { authorType: 'human', limit: 20 }), 20); + const SESSIONS = path.join(os.homedir(), '.studio', 'sessions.json'); + const USERS = path.join(os.homedir(), '.studio', 'users.json'); + { + const t0 = performance.now(); + await fsR.readJson(SESSIONS); + console.log(`H readJson sessions.json cold(592KB/966 条): ${(performance.now() - t0).toFixed(2)}ms`); + } + await timeIt('H readJson sessions.json warm(含 structuredClone)', () => fsR.readJson(SESSIONS), 50); + await timeIt('H readJson users.json warm', () => fsR.readJson(USERS), 50); +} + +console.log(`\ntmp roots kept: ${tmpC} ${tmpD}(手工清理)`); diff --git a/docs/adr/2026-09-12-channel-mainline-optimization.md b/docs/adr/2026-09-12-channel-mainline-optimization.md new file mode 100644 index 00000000..f66df559 --- /dev/null +++ b/docs/adr/2026-09-12-channel-mainline-optimization.md @@ -0,0 +1,118 @@ +# 频道主链路流程与性能优化决策集 + 分级清单(2026-09-12) + +> 来源:wayfinder 地图 #504「频道主链路的流程与性能优化」。六段走查 #506-#512 + 测量元决策 #505 + 裁决票 #514-#517,汇总定稿票 #513。 +> 状态:**accepted**。实现不在本文——按 P 档打包开实现票,另起 effort 排期。 + +## 分档口径 + +- **先行项**:测量体系。不是优化,是尺子——先装上,其他项修完才知道有没有效。 +- **P0**:人会注意到的错或等。断链静默、消息发出去 agent 十几秒才动、人闸死等没人催。 +- **P1**:现在没事、数据多了会变慢的结构性隐患。随消息量/WU 数线性变差的读写口。 +- **P2**:常量级小浪费与打磨。修不修都不影响体感,顺手清掉。 + +## 先行项:测量体系(#505 定案) + +最小常驻版,三件事: + +1. traceId 补齐三条路由路径(现状只有 @mention 路径写 WU metadata,线程回复与默认角色路径断档)。 +2. 离线对齐工具:零新服务端埋点,四段(派单/等认领/执行/回执落库)事后从现有数据对齐出分布。 +3. 前端 3 个埋点:点击发送、回执渲染、页面加载,复用 read-metrics 的 sink 模式落事件流。 + +不建 metrics 端点和观测面板(开发阶段无消费场景)。分段口径沿用「消息的旅程」六段,与六份走查报告同词汇表。 + +## P0:人会注意到的错或等 + +### P0-1 认领真唤醒(#515 定案) + +- **修什么**:`workunit.created` / `status_changed`(按 claimable 过滤)改成真唤醒,复用现有 pendingWake 闩锁机制;auditor 旁路建单收口走 `WorkUnitService.create`;步间 sleep 改可中断。15s 轮询地板保留作纯兜底。 +- **为什么**:实测建单到认领 8.5-12s,全是等轮询;而唤醒机制本身只要 4ms。 +- **怎么算修好**:等认领 p95 < 1s;步间响应 p95 < 1s;唤醒整体失灵时最坏仍 ≤15s(复测走先行项的离线对齐工具)。 + +### P0-2 频道出声:评审建成与断链自愈(#516 项①②) + +- **修什么**:review 子单建成即在父 WU 线程补一条系统消息「已派评审」;对账自愈补建/重派后在本频道补一条「断链已自愈」。**#159 决议 5「频道不出声」作废**(补建的单子是频道可见内容,系统自作主张改了就该说);实现时同步更新根 CONTEXT.md「修复哲学」条目。 +- **为什么**:这是走查⑥找到的两个频道静默断点——流程在推进,频道里完全没声音,人以为丢了。 +- **怎么算修好**:两条 happy path 各有一条频道消息,建单与出声同一动作。 + +### P0-3 人闸催办与认领滞留(#516 项③⑥) + +- **修什么**:in_review 待确认 30 分钟 → Web 铃铛 + 浏览器原生通知(Notification API,零配置保底);4 小时仍未确认 → 推微信通道。WU unassigned 超 15 分钟 → 同顺序外推。**不自动确认**,人闸语义不动。 +- **为什么**:人闸是主链唯一无界延迟——没有任何超时和催办,确认卡可以永远等下去。 +- **怎么算修好**:两层催办按时到达,行动中心常驻兜底不受影响。 + +## P1:数据多了会变慢的结构性隐患 + +### P1-1 消息读口全量扫描改造(#514 定案,含汇总票并入的第 4 子项) + +- **修什么**:统一改「指定频道 + 尾部倒扫」(复用现成 `iterateJsonlLinesBackward`,不建索引)。四个子项: + 1. `findMergeTargetWorkUnit` 全热文件读 → 倒扫; + 2. `getMessageById` 全频道扇出 → 按频道直查 + 调用点迁移; + 3. `linkWorkUnit` 已知 channelId 仍全频道扫 → 签名加 channelId; + 4. `queryMessages` 加尾部快径(limit 且无过滤时走倒扫切片),顺带覆盖分页读口(#510 嫌疑①,走查②候选 5,汇总票 #513 定案并入)。 +- **为什么**:实测随热层线性恶化——222ms@2.46 万行 vs 倒扫 0.4ms,约 700 倍;分页读口 2 万行 90ms。两个坑已坐实:倒扫必须复刻去重/tombstone 口径;不可按 createdAt 时间早停(更新-append 使文件序 ≠ 时间序)。 +- **怎么算修好**:2.46 万行测试数据重跑走查②基准,读口 ≤1ms 量级。 + +### P1-2 建单写侧 index 改 append-only(#517 项 1) + +- **修什么**:`commitSnapshot` 的 index.json 全量重写改 append-only upsert 行 + 定期压实;fsync 取消,崩溃走 `rebuildIndex` 重建(events 是正本,index 只是派生物)。 +- **为什么**:每次建单/状态迁移重写全量 index,随 WU 数线性变差(现在 p50 7.75ms@49 条)。 +- **怎么算修好**:写侧耗时随 WU 数不再线性增长。 + +### P1-3 SSE replay buffer 踢出 stream chunk(#516 项⑥) + +- **修什么**:500 条共享 replay buffer 不再入 stream chunk(一处过滤),关键事件独占补发窗口;stream 重连走全量 refetch 兜底。 +- **为什么**:高 stream 负载下 chunk 把重连补发窗口挤没,#491 补发机制失效——重连后丢关键事件。 +- **怎么算修好**:高负载下重连,关键事件补发齐全。 + +## P2:小浪费与打磨 + +| 项 | 修什么 | 来源 | +|---|---|---| +| P2-1 | notificationStore 补取数纪律(fetchDiscipline 底座 + 触发侧防抖) | #517 项 2 / #512 | +| P2-2 | 路由层 channel 传参进 routeMessage,消重复 `getChannel` | #517 项 3 | +| P2-3 | sessions.json 写路径顺带 prune 过期条目 | #517 项 4 | +| P2-4 | `countColdLines` 加 `includeTotal` 开关、默认跳过 | #517 项 5 | +| P2-5 | workunit-crud「未配置也提示」挂进 1h 冷却闸 | #516 项④ | +| P2-6 | /settings 新增「通知渠道」配置区:企微 webhook URL 收编(env 保留作部署默认)+ ClawBot 扫码绑定(内嵌直连,不部署独立网关) | #516 项⑦⑧ | + +## 与各裁决票局部定级的差异(全局重排说明,#513 定案) + +- **#517 项 2 notificationStore 局部定 P1 → 全局 P2**:它是取数纪律补齐(无 TTL/single-flight + SSE 逐条全量重拉无防抖),既非人可感知的错,也非主链结构性隐患,量级随通知数温和增长。 +- **#516 未分档 → 按内容拆档**:出声两项(断链静默)与催办滞留(人可感知的等)进 P0;replay buffer(正确性隐患、触发条件窄)进 P1;冷却闸与通知渠道配置区(打磨与功能建设)进 P2。 +- **#515 未分档 → P0**:等认领 8.5-12s 是主链上最刺眼的体感延迟。 +- **#514 增加第 4 子项**:分页读口线性隐患(#510 嫌疑①)当时无票认领,汇总票按 #514 自己「不以当前数据量裁决」的原则并入同一项,同一套倒扫机制覆盖。 +- **#505 不占 P 档**:测量体系是验收尺子,单列先行项。 + +## 不修(已裁决,勿再提) + +每项一句话理由;候选手段全文在各走查报告(`.studio/research/2026-09-12-walk*.md`,research/* 分支)。 + +**#517 裁决**: +- 每消息双发 eventBus——双平面设计内行为,全内存微秒级,收敛收益≈0。 +- `git ls-files` 缓存 miss——composer 打开 @ 选择器时已预热同一缓存,正常使用必命中;单仓 miss 仅 +4.4ms。`files.includes` 改 Set 同判(2099 条词表下无感)。 + +**走查①候选**: +- 多仓串行改并行 / 发送路径缓存预热——同属 ⑧,量级个位数 ms,放大场景(多候选仓全 miss、十万级文件大仓)当前不存在。 +- @mention 链路并行化——各环节均个位数 ms,并行收益 <10ms,复杂度不值。 +- 附件 8MB JSON.parse 改流式——单用户场景个位数 ms 同步段,可接受。 +- 动态 import 上提模块级——ESM 缓存后仅微任务开销,非瓶颈。 + +**走查②候选(#514 定案时已否决)**: +- 倒扫按 createdAt 时间早停——更新-append 使文件序 ≠ 时间序,会漏。 +- 合并目标指针化 / WU 索引推导 / append 侧索引——「不建索引」已定案;WU 索引语义不等价(锚点是消息时间不是 WU 活动时间)。 + +**走查③候选(#515 定案时已否决或覆盖)**: +- 缩短 15s idle 地板——真唤醒后地板是纯兜底,不缩。 +- FileStore mtime watch 跨进程兜底——跨进程写者当前不存在,YAGNI。 +- unassigned 指名单的消息唤醒扩展——主路径已被真唤醒覆盖,剩余场景边缘。 + +**走查④候选(嫌疑⑥不成立,扇出成本微秒级)**: +- stream chunk 合并帧——实测 1 chunk/行,收益近零。 +- 服务端时间窗批处理 / chunk 负载裁剪 / 无订阅者不扇出 / 前端 rAF 合批——均属锦上添花,当前帧成本 <1ms,无修的必要。 + +**走查⑤**: +- 消息端点挂 apiCache——现实规模省个位数 ms 无收益,且与兜底轮询语义冲突(TTL 内喂陈旧首页)。正确修点 = P1-1 第 4 子项的尾部快径。 + +## 下游消费方式 + +按 P 档打包开实现票(P0 一包、P1 一包、P2 一包 + 先行项单独一包),不逐条开 17 张票。每条目的验收口径已写在上面,实现票开工时再细化完整 AC。