feat(dashboard): 会话状态重构——运行态×任务态正交(P1命名收敛 + P2任务态TODO) - #798
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
Review 结论:Request changes
我在 c54cf25b2(master) + c93b5d6fb(PR head) 的合并树上核对。两处冲突解法是正确的:
stalled仍优先进入needs-you;- TODO 徽标已织入新版表格的
renderCell(row, 'status')。
但合并树仍有以下阻塞项:
1. [P0] publicReadOnly 会泄露 TODO 明文
openTodos.items[].text 来自 transcript 中的计划/任务正文。master #793 的 src/dashboard/public-redact.ts 没有处理这个新字段,因此匿名 /api/sessions、session.spawned 和未来补上的 session.update 都会原样返回任务正文。我用 REST、spawn SSE、update SSE 三条投影均复现出明文。
请在公开投影中移除 items(可按产品需要只保留计数),并补 REST + 两类 SSE 的回归测试。
2. [P1] 新会话的任务态没有进入实时 dashboard 数据流
src/core/worker-pool.ts 的 screen_update 状态切换会调用 composeRowFromActive(ds),但发布的 patch 只取 status/token/preview,没有 openTodos。新会话的初始 session.spawned 通常发生在 transcript/TODO 产生前;之后即使 daemon 算出了任务态也被丢掉,中央 aggregator 中的行仍无 openTodos,所以会话停到 idle 后仍进「空闲」而不是「待办」。
请在 settle/status patch 中显式发送 openTodos(清空时也要用 null 覆盖旧值),并用真实 store.applySse 路径锁住 working → idle + unfinished TODO → todo。
另:Codex rollout 首次 lookup miss 会被 resolver 缓存 30 秒;readSessionOpenTodos 没有 fresh 语义。实测 rollout 在首次 miss 后创建,立即再读仍为 null,reset resolver cache 后才读到。修实时链路时需一并处理首轮 miss。
3. [P1] sandbox-on 的受支持 CLI 定位不到 transcript
sessionOpenTodos() 没把 session 的 larkAppId 传给 readSessionOpenTodos,后者也没传给 resolveSessionTranscriptPath。sandbox 的 Claude/Codex/Seed/Relay/Aiden transcript 位于 BOT_HOME;resolver 明确依赖 larkAppId 才启用该 fallback。现有 resolver 回归测试已证明“只有 BOT_HOME 文件、无 larkAppId”必为 null。
请把 app id 贯穿查询类型/调用链,并补 Claude 与 Codex BOT_HOME 的 todo-state 测试。
4. [P1] 同步全量解析位于 daemon 主线程热路径,性能应视为 blocker
这里不只是按需 HTTP:每次 worker screen_update 的状态切换都会同步走 composeRowFromActive → readSessionOpenTodos。transcript 在一轮内增长后,mtime/size 必变,缓存 miss 就执行 readFileSync、split、逐行 JSON.parse、保留完整 entries 数组;当前算出的 openTodos 还会被上述 patch 丢弃。
本机最小基准:31MB、约 44.5 万条短 JSONL,单次冷读约 398ms,额外 heap 约 117MiB;整个期间阻塞 daemon 事件循环。建议复用已有 JSONL cursor/增量 fold,或移出主线程,不能以 32MB 全量同步解析作为保护。
同时,文件越过 32MB 后当前代码返回 cached?.todos:我已复现末尾从 pending 更新为 completed 后仍永久返回旧 pending。这违反“未知不硬猜”,至少不能把越界前快照冒充当前状态;增量方案可自然解决。
5. [P2] 空快照不满足 last-write-wins
TodoWrite([]) / update_plan({plan: []}) 被折叠成 null,外层又用 if (snap) latest = snap,导致先前 pending 快照继续保留。两种方言均已最小复现。需要区分“没看到快照/快照损坏”和“看到了合法空快照”,后者必须清掉旧状态。
6. 类型需补齐
SessionRow.openTodos 少了运行时实际返回的 items,导致公开投影、SSE patch 与 UI 无法获得完整的静态约束。
正向核对
- Claude TaskCreate/TaskUpdate 的关联、更新、deleted、metadata-only、顺序,以及 TodoWrite 优先逻辑,在非空快照范围内未发现其它错误;
stalled合并与新版renderCell(status)合并处理正确;- 状态列/偏好迁移的现有测试通过。
实际验证
pnpm build:通过(tsc + dashboard bundle + dist audit)。- 相关测试:145 passed(todo-state / sessions UI / preferences / attention / token row / BOT_HOME resolver / public redaction)。
pnpm test:14337 passed、25 skipped;另有 1 个 suite hook timeout 与 3 个既有 integration 失败。隔离重跑时 group-join suite 通过,worker argv 仍有 2 个时序断言失败,codex-app-runner 有 1 个随机落在不同 case 的超时;这些文件不在本 PR diff 中,未计入上述 blocker,但全量树当前不能写成全绿。
本次未合并。
砍掉「待确认」这个名字,与状态板的「需要你」统一(看板 in_review 列 id 不变, 仅重命名标签为「需要你」/「Needs You」)。「启动中」并入「进行中」: 状态板去掉 starting 独立列,starting/working/analyzing/active 同归 working 列, 标签由「干活中」改为「进行中」。 - BoardColumnId 去掉 'starting';deriveSessionBoardColumn 折叠 starting→working - DEFAULT_BOARD_ORDER 改为三列;normalizeBoardOrder 迁移旧四列存值(剔除 starting) - i18n zh/en 同步:board.working=进行中/In Progress、kanban.inReview=需要你/Needs You, 删除 board.starting / board.startingHint - 补测:deriveSessionBoardColumn 折叠用例、board-order 旧值迁移用例 方案见「botmux会话状态管理_重设计方案v1」P1。纯命名与列收敛,不改运行态判定逻辑。 Co-Authored-By: Claude <noreply@anthropic.com>
会话状态重设计 P2:把「运行态(进程忙/闲)」与「任务态(活干完没)」拆成正交 两维。运行态沿用 worker 探测;任务态新增,从 CLI 自己维护的待办列表读: - Claude 家族(claude-code/seed/relay/aiden):TodoWrite 工具调用 input.todos[] - Codex 家族(codex/traex):update_plan 函数调用 arguments.plan[] 两者都是整表快照后写覆盖,取 transcript 最后一次即当前状态。 - 新增 services/todo-state.ts:纯解析 parseOpenTodos + 带 mtime 失效的缓存读取 readSessionOpenTodos(对齐 cost-calculator 思路,32MB 上限/512 条 LRU)。 不支持的 CLI、无 transcript、从未建 todo 一律返回 null,绝不硬猜完成度。 - dashboard-rows:SessionRow 增 openTodos 字段,仅活跃行填充。 - 状态板新增「待办(真)」列:运行态=空闲但有未完成 TODO → 待办,不再拿 idle 冒充;needs-you / 运行态仍优先于任务态。preferences 迁移旧存值补 todo 列。 - traex rollout 与 codex 逐字节同构,dialect 映射到 codex(含回归用例)。 Co-Authored-By: Claude <noreply@anthropic.com>
验证 P2 时发现:本 botmux 环境的 Claude 系 CLI(relay/seed/claude-code)用的是
内建 Task* 工具(TaskCreate/TaskUpdate),而非开源版的 TodoWrite——全盘无一条真实
TodoWrite 调用,原 P2 的 Claude 分支抓不到任务态。
Task* 是增量模型(不同于 TodoWrite 整表快照):
- TaskCreate 无 taskId,分配号在紧邻 tool_result 文本「Task #N created successfully」
- TaskUpdate {taskId,status} 改状态,status ∈ pending/in_progress/completed/deleted
新增 replayClaudeTaskState:按 tool_use.id 关联 Create 与其结果回填 taskId,重放出
Map<taskId,status> 末态再折叠成 OpenTodos。TodoWrite 快照优先,缺失时回退 Task* 重放。
纯元数据 TaskUpdate(无 status)保留任务默认 pending;deleted 移出清单。
真实数据验证:本会话 4 任务(2完成2待办) 重放得 {total:4,done:2,remaining:2},精确匹配。
补 5 个重放单测(含 deleted、metadata-only、TodoWrite 优先、空清单返 null)。
Co-Authored-By: Claude <noreply@anthropic.com>
待办列此前只显示运行态徽标(空闲/休眠),看不出「为什么这张卡在待办列」—— 运行态(机器闲)与任务态(还有未完成 TODO)正交,单看运行态解释不了列归属。 在卡片/详情抽屉/表格的 status 组里加 TodoBadge:显示 done/total,hasInProgress 时带脉冲圆点,title 展开「剩 N 项(共 M 项,已完成 K 项)」。openTodos 缺失或 已全部完成时不渲染。琥珀色区别于运行态 status 的灰/蓝/绿。 Co-Authored-By: Claude <noreply@anthropic.com>
此前 TODO 徽标只显示计数(done/total),悬浮/点击看不到具体是哪几条。
用户反馈:想看到 TODO 列表。
数据层:OpenTodos 加 items:{status,text}[]。文字随方言取——TodoWrite 的
content、Task* 的 subject、Codex update_plan 的 step;Task* 增量重放改用
Map<id,{status,text}> 保序累积;文字清洗(trim/合并空白/限长 120)。
前端:TodoBadge 悬浮/聚焦时弹浮层,列出每条(✓完成删除线灰 / ▶进行中高亮 /
○待办)+ 文字,顶部带「还有 N 项未完成」摘要。卡片和列都是 overflow:hidden,
故浮层 portal 到 body + fixed 定位逃出裁剪,按徽标位置对齐。
测试:补 items 顺序与文字断言(TodoWrite content / Codex step / Task* subject
三方言各一),原计数断言改 toMatchObject 不锁 items。19 passed。
Co-Authored-By: Claude <noreply@anthropic.com>
反馈:悬浮浮层过一会就消失,想复制里面的内容。原实现是 tooltip,鼠标一移开 就 setPos(null) 关掉,根本选不中文字。 改成两态: - 悬浮=预览(移开即关),顶部提示「点击固定以复制」; - 点击=固定(pinned),不自动消失,浮层文字可选中(user-select:text), 右上出现「复制清单」按钮;点浮层/徽标之外或按 Esc 关闭。 复制走 navigator.clipboard(不可用时回退 textarea+execCommand),写入纯文本 清单:顶部摘要 + 每行「[x]/[>]/[ ] 文字」,方便贴到别处。徽标固定态高亮。 document 级 mousedown/keydown 监听用 globalThis.MouseEvent/KeyboardEvent 类型(避开 React 合成事件类型),pinned 时才挂、卸载时移除。 Co-Authored-By: Claude <noreply@anthropic.com>
反馈:浅色模式下浮层文字太浅几乎看不清;且完成/未完成深浅关系反了。 根因:浮层用了 var(--text)/var(--text-secondary),但本主题从未定义这两个 token, 全部回退到近白色 #e6e8ee——浅色底上几乎不可见。改用主题真实存在的 --fg/--faint。 配色语义修正:完成项用 --faint(浅灰)+删除线弱化,待办/进行中用 --fg(深色主文字), 进行中额外加粗——「还没做的」在视觉上更突出,与用户预期一致。 Co-Authored-By: Claude <noreply@anthropic.com>
原来的黄橙(#f5a623)偏"信息"感,行动号召弱。换成偏红的橙红 (#ea580c),保留柔和描边+透明底的克制样式,但更有"该处理"的 提示感,又不像纯红报错。「需要你」列的红色信号保持不变。 颜色/按钮配色后续可再灵活微调。 Co-Authored-By: Claude <noreply@anthropic.com>
board-status-columns.png:四列状态板(需要你/进行中/待办/空闲), 展示橙红 TODO 徽标与红色「需要你」信号。 todo-popover-pinned.png:TODO 徽标点击固定后的浮层,列出完整清单 (完成项浅色划线、进行中/待办深色),带复制清单按钮。 浅色模式合成数据演示,用于 PR 描述可视化验证。 Co-Authored-By: Claude <noreply@anthropic.com>
审核在最新 master 合并树上发现并坐实: - P0 隐私泄露:openTodos.items[].text 是从 CLI transcript 读出的任务明文, 与 preview 内容等价,但未进 PRIVATE_SESSION_FIELDS 黑名单,会透传给匿名 只读看板。整字段加入黑名单——匿名侧不显示 TODO 徽标,列归属靠运行态即可。 REST 投影与 SSE patch 两条路径都覆盖,补单测坐实。 - P1 实时性:worker-pool 的 screen_update 边沿 patch 未带 openTodos, working→idle 时看板「待办」列停在旧值。补 openTodos: ?? null(支持清空)。 - P1 sandbox:sessionOpenTodos / readSessionOpenTodos 未透传 larkAppId, bot-home 沙箱下 transcript resolver 定位不到文件。补齐。 - 类型:SessionRow.openTodos 补 items 字段,与 OpenTodos 结构一致。 Co-Authored-By: Claude <noreply@anthropic.com>
c93b5d6 to
a42f706
Compare
三处合并前必修(双评审 Request Changes 共识):
① fresh 贯穿:readSessionOpenTodos 已透传 fresh 到 resolver,绕过 30s miss
负缓存;worker-pool 状态边沿以 {fresh:true} 读,首轮徽标不再等 30s。补回归测试。
② 主线程全量解析改增量:记住 (dev,ino)+字节前沿 offset+折叠状态,size 未变直接
返缓存、size 增长只 fold [offset,size 新行;全量与增量共用同一套 fold 语义。
③ >32MiB 一律 fail-closed 返 null——护栏上移到选路之前,增量路径(同 inode 被整体
覆盖成超大文件)也覆盖,绝不返旧 cache 冒充当前。
顺带修复 dashboard-ipc-server 的 .map(composeRowFromActive) 把 map 的 index 当
opts 传入的类型/语义 bug(新增 opts? 参数后暴露)。
自测:todo-state(25) / public-redact(19) / sessions-ui(38) / preferences(23) 全绿;
tsc 触及文件零新增错误;dashboard bundle 构建通过。
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)
三轮双审的两处测试修复(均只碰测试,非生产代码 bug): ① fresh 回归假绿:claude-code 分支每次调用现算(newerFile 直读盘),无 30s miss 负缓存,fresh 是 no-op——删掉生产代码 fresh 透传后 claude 用例照样过(假护栏)。 改用 codex 分支(cachedTranscriptPathLookup + retryMiss:q.fresh 真有负缓存): 种下 miss → 落盘 → 非 fresh 仍 null / fresh 当场解析成功。mutation 已坐实: 把 fresh:q.fresh 改成 false,该用例即失败。 ② 测试污染真实 ~/.claude:上一版 fixture 写进 join(homedir(),'.claude','projects', <projectKey>) 且 afterEach 只删 .jsonl、不删 projectKey 目录,历次留下空目录 (PR deepcoldy#792 同类:单测污染开发者真实数据目录)。改为 CODEX_HOME 定向到临时目录, beforeAll 设置 / afterAll 还原并整体清理,绝不触碰真实 ~/.claude 或 ~/.codex。 已清理本机历史残留的 -*-todo-cwd-* 空目录。 Claude 的 Task* 增量重放仍由纯 parseOpenTodos 单测覆盖;增量游标/fail-closed/文件 替换机制与方言无关,codex 落盘用例同样验证。todo-state 24 全绿,无 home 目录污染。 Co-Authored-By: Claude <noreply@anthropic.com>
|
🚀 Released in v3.12.1 |
一句话结论
把 dashboard「会话控制」的状态体系从一台运行状态机的可视化,重构为一套状态、两个正交维度(运行态 + 任务态)。本 PR 落地了设计方案的 P1(命名收敛)+ P2(任务态 TODO 提取与可视化);P3(「需要你」自动触发源)、P4(看板去拖拽 + 与监控室融合)为后续。全部 54 个相关单测通过,dashboard bundle 构建通过。
本 PR 实际改了什么(落地范围)
sessions.ts/preferences.ts/i18n.tsopenTodos: {total, done, remaining, hasInProgress, items[]},由 transcript 解析器顺带提取,读不到的 CLI 标"未知/不支持"、不硬猜services/todo-state.ts/core/dashboard-rows.tsTodoWrite、本环境内建Task*工具增量重放、Codex/traexupdate_planservices/todo-state.tsidle冒充sessions.tssessions-page.tsx/style.css关键:运行态与任务态正交。 会话可以「运行态=空闲 + 任务态=有未完成 TODO」(机器停了但活没干完 ← 正是要抓的),也可以「空闲 + 已交付」(真没事)。旧设计把两者混成一个
idle/待办,所以没用。因此 TODO 徽标只要"还有未完成项"就贴,与它在哪列无关——列告诉你"现在轮到谁动",TODO 告诉你"这会话手上还压着几项活"。可视化验证
浅色模式下用合成数据铺满四列(覆盖 Claude + Codex 的 TODO 展示,及「需要你」三种触发),确认徽标与信号渲染正确。样式改的是真实代码,非 mock 页面。(真机此刻无"正在等你操作"的会话,「需要你」列无法用真机触发,故用合成数据演示样式。)
四列状态板(需要你 / 进行中 / 待办 / 空闲,橙红 TODO 徽标 + 红色「需要你」信号):
TODO 徽标点击固定后的浮层(列出完整清单:完成项浅色划线、进行中/待办深色,带复制清单按钮):
测试
test/todo-state.test.ts:19 通过(三方言解析、items 顺序、计数)test/dashboard-sessions-ui.test.ts:17 通过test/dashboard-preferences.test.ts:18 通过node scripts/build-dashboard.mjs通过仍未做(后续 PR)
for-you=action/fyi)📐 完整设计方案 v1(送评审原文)
botmux 会话状态管理 · 重设计方案 v1
0. 一页结论
idle冒充"待办"、手动拖拽会永久钉死且不自更新、「需要你」靠 agent 自觉上报易漏。1. 现状(问题定位,均已核对源码)
1.1 底层信号只有一份,但被两视图各起各名
status(src/types.ts:113):starting / working / idle / limited / analyzing(遗留) / dormant+closed。src/worker.ts):不理解 CLI 在做什么,每 2 秒扫终端屏幕,用每个 CLI 适配器的readyPattern/busyPattern正则判忙/闲。非语义,CLI 换措辞就可能误判。pendingRepo/tuiPromptActive/agentAttention/status===limited。其中agentAttention靠 agent 主动botmux send --attention(daemon.ts:4776),daemon 不自动检测。1.2 两视图映射(同源、异名)
sessions.ts:332,实时只读)kanban-model.ts:24,可拖)1.3 五个结构性问题
deriveKanbanColumn判定顺序closed > 手动 kanbanColumn > 自动,手动值只在三处被写(session-manager.ts:2303/2381、dashboard-ipc-server.ts:933),没有任何逻辑因运行态变化去更新它。拖过一次就停在那列,底层 working→idle→dormant 看板纹丝不动。kanbanColumn一行、不停 CLI(dashboard-ipc-server.ts:920),导致"真空壳(未启动)"与"被拖进来的活会话"长一样。列位置 ≠ 真实状态。2. 重设计:一套状态、两个正交维度
2.1 维度 A|运行态(进程/CLI 层,沿用现有)
进行中(starting+working 合并)/空闲(idle)/已完成(closed)。回答"机器在不在跑"。2.2 维度 B|任务态(内容/语义层,新增)
有未完成TODO/已交付/无任务。回答"这件事做完没、要不要人管"。关键:A、B 正交。 一个会话可以是"运行态=空闲 + 任务态=有未完成TODO"(机器停了但活没干完 ← 正是要抓的),也可以"空闲 + 已交付"(真没事)。现状把这俩混成一个"待办/idle",所以没用。
2.3 统一状态列(状态板 & 看板共用同名,只起一次)
改动点:① 砍掉"待确认"这个名字;② 「待办」重定义为任务态;③ "启动中"并入"进行中"。
3. 「需要你」的自动触发源(补覆盖缺口)
现状只有 4 个信号且核心靠自觉。新增两个自动/半自动源,与现有互补:
--mention真人时必带意图,如for-you=action(等你处理,进"需要你") /fyi(完成通知,不进或只进弱提醒)兜底策略:只要@了真人 → 至少进"待你看";agent 用意图参数把它升级为"需要你处理"或降级为"仅通知"。即使模型忘带参数也不漏。
4. 看板去拖拽 + 与监控室融合为"关注集"
4.1 看板拖拽:建议砍掉"拖动改列"
4.2 监控室现状(已核对源码)
monitor-room-store.ts用 localStorage(monitorRoom.sessions.v1),上次添加的会话会记住,不是每次进来重选。monitor-room.ts按会话数自动算行列),多路 Web Terminal 平铺;但每格只是原始终端画面,没有状态/todo/需要你等结构化信息。4.3 融合设计:一份"关注集",两种视图密度
看板与监控室本质是同一东西的两极:看板有结构化状态但没实时画面,监控室有实时画面但没状态。
→ 统一为一个"我关注的会话"集合,一键切换两种密度:
5. L2(读 transcript 拿 todo)落地范围
5.1 可行性:地基已具备
botmux 支持 24 个 CLI,已有 10 个 transcript 解析器。定位+读+解析 jsonl 是现成的,L2 是在已有解析器里多提取一个字段。
5.2 分三档
TodoWrite(每项status = pending/in_progress/completed);Codexupdate_plan事件(plan[]每步{step, status})。5.3 建议
先只对 Claude + Codex 做 A 档(覆盖绝大多数会话、零 LLM 成本、实时准确)。会话状态加字段
openTodos,由解析器顺带填;读不到的 CLI 标"未知/不支持",不硬猜。不做 LLM 节选判断(太重)。6. 改动点清单
sessions.ts/kanban-model.ts/ i18nopenTodos,Claude/Codex 解析器提取 todo/planclaude-transcript.ts/codex-transcript.ts/ 会话模型--mention ... for-you=action/fyi🤖 Generated with Claude Code