Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
31e3dc0
docs: 审查台账补 R25/R26 两节(harness 借鉴与插件治理、文案纪律与 MCP 落地)
Sep 5, 2026
e8c0cb5
chore(release): 桌面端版本号升至 0.2.0(五处单一事实源同步)
Sep 5, 2026
b587e80
feat(contracts,api): MCP 连不上时给稳定错误码,界面不再解析英文诊断
Sep 5, 2026
39108ba
feat(b6): 重复动作提醒补 agent_run.reminded 事件(两套引擎同点同形状)
Sep 5, 2026
cc22376
feat(api,ui): 网页设置页新增只读「MCP 服务器」清单(仅管理员)
Sep 5, 2026
ae7f266
fix(ui): 设置页「系统诊断」分区改为仅管理员渲染(A2-45)
Sep 5, 2026
7dc8c1a
fix(api): 五个 service 里剩余的「事项 / 工单」按词表收口成「任务」
Sep 5, 2026
eec3560
feat(b6): web 时间线补「换个做法」提醒行(实时页 + 回放页)
Sep 5, 2026
bb683f2
docs: README 补「MCP 服务器」小节
Sep 5, 2026
bb3f195
docs: 工包 M8 的 Agent Note(稳定错误码/网页只读清单/三条文案遗留)
Sep 5, 2026
df32314
feat(b6): 桌面回放时间线补「换个做法」提醒行并订上新事件名
Sep 5, 2026
d85509b
chore(b6): openapi 同步运行 VM 的 reminders 数组
Sep 5, 2026
2c351f0
Merge branch 'r26/m8-web-mcp-readonly' into r26/integration-9
Sep 5, 2026
76fe9a3
docs(b6): Agent Note 记「重复动作提醒观测面」的取舍与剩余一跳
Sep 5, 2026
1eee76d
Merge branch 'r26/b6-reminder-events' into r26/integration-9
Sep 5, 2026
d131891
fix(api): 路由层剩余的「事项 / 工单」按词表收口成「任务」
Sep 5, 2026
cab4dcd
feat(b6b): agent_runs 加 reminders_json,重复动作提醒进库
Sep 5, 2026
f00ab08
feat(desktop): MCP 分区按稳定错误码说清连不上的原因,管理员门换成 mcp_servers
Sep 5, 2026
e5d5730
docs: 工包 F3 的 Agent Note(路由层术语收口 / 桌面端按码出话 / 两条遗留)
Sep 5, 2026
ea391bc
feat(b6b): agent_run.reminded 接进运行记录与实时/回放两个 VM
Sep 5, 2026
c454e66
fix(api): 变更预览两条消息里的「工作项」也收成「任务」
Sep 5, 2026
84c11cc
docs: F3 Agent Note 补「工作项」这一条与最终基线数
Sep 5, 2026
aa84e16
test(b6b): r1 真 PG 门断言 0075 的 reminders_json 落在库里
Sep 5, 2026
8533ef4
Merge branch 'r26/f3-routes-copy-and-desktop-codes' into r26/integrat…
Sep 5, 2026
4d02107
docs(b6b): Agent Note 记「提醒进运行记录与库」的取舍与落盘节奏
Sep 5, 2026
b05eb13
fix(api): 网盘与会议路由的 404 标签「事项」按词表收成「任务」
Sep 5, 2026
99ec500
Merge branch 'r26/b6b-reminders-persist' into r26/integration-9
Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .agents/notes/implemented/2026-09-06-b6-reminder-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 重复动作提醒的观测面(agent_run.reminded + 两端时间线)

- Status: implemented
- Date: 2026-09-06
- Owner: claude-code

## Problem

B6「重复动作先劝再断」(`.agents/notes/implemented/2026-09-05-repeat-tool-reminder-tiers.md`)
落地后,前两档提醒只存在于**模型可见的对话线**里:运行环境往对话里追加了一句「你已经连续 N 步…」,
模型看得见,人看不见。界面上唯一的迹象是这次运行多烧了几步,然后要么自己走出来、要么在第八步
突然升级成 `agent_run.escalated`——中间那两次「劝过」既没有事件、也没有任何一行界面。

那份落地档案自己把这件事记成了否决项(「为提醒新增一个事件类型:要动 contracts 的事件枚举与
openapi,范围外。提醒本身在对话线里,需要时再补事件」)。这一批就是来补的:Cuu 被劝了几次、
劝的是什么,得能在时间线上读到。

## Decision

**新增事件类型 `agent_run.reminded`,不复用 `agent_run.step` 加 `kind`。** 理由是形状:
`agent_run.compacting`(压缩)与 `agent_run.escalated`(升级)都是「运行环境介入了」这一类,
各自有独立事件类型;`agent_run.step` 的各种 `kind` 全是「模型这一步产出了什么」(thinking /
text / tool_call / stream_event / provider_retry)。提醒是前者不是后者——它恰好是升级的前两档,
和 `agent_run.escalated` 是同一条判定链上的三档,做成兄弟事件类型最贴既有形状。附带的好处是
消费方能按事件名过滤(桌面 per-run SSE 就是显式事件名订阅,见下方 Consequences)。

**payload 只带事实,不带句子。** `agentRunReminderFactsSchema`(`contracts/domain/agent.ts`):
`step_no` / `tier`(1|2) / `repeats` / `shape`(identical|alternating) / `tool_id?` / `tool_ids?`。
事件 data 在此之上加 `run_id`(+ 运行器补的 `work_item_id`)。中英文句子由两端各自按 locale 组装:
事件里塞中文句子会让英文界面无法本地化,也会把**模型可见**的提醒正文和**用户可见**的界面文案
锁死在一起——那两份文本的读者不同,措辞标准也不同(一个要让模型改行为,一个要让人看懂发生了什么)。

`tool_ids` 只在一次重复涉及不止一个工具(交替形态两边工具不同)时出现,单工具只留 `tool_id`,
同一事实不存两份。两者都允许缺席(重复的那一步理论上可以没有工具调用),渲染层要能退回不提工具的说法。

**两套引擎同点同形状。** `loop.ts` 在提醒真正被 push 进 messages 的那一行发;
`loop2/config-builder.ts` 在 `shouldStopAfterTurn` 里暂存提醒的同一行发。两处都紧跟在这一步的
`agent_run.step(control)` 之后(loop2 的 `shouldStopAfterTurn` 跑在 `turn_end` sink 之后),
因此在事件序列里落在同一个位置。`equivalence.test.ts` 的事件投影补上 `tier/repeats/shape/tool_ids`
逐条 deepEqual,另加两条专项用例(全同链路两条提醒 + 第三档只发 escalated;交替形态带两个工具名)。

**第三档不重复发。** tier 3 在两套引擎里都提前 return 到升级路径,走不到发事件这一行。

**时间线渲染:事实进 VM,句子进词典。** `AgentRunLiveVM` / `ReplayTraceVM` / `AgentRunTraceVM`
各加一个 additive optional 的 `reminders` 数组(缺席与空数组同义,存量客户端零回归)。
`packages/ui`(实时页 + 回放页)与桌面 Spotlight 回放视图各自把提醒渲成**独立一行**,插在它所属
那一步之后——提醒不是模型的一步,不占步号、不冒充 `AgentStep`,`stepCount` 也不变。
对不上任何已渲染步骤的提醒补在时间线末尾:劝过就不许在界面上看不见。

文案是八条完整句子(档位 × 重复形态 × 有没有工具名)× 中英,不是拼半句——中英文的标点与语序
不一样,拆成碎片拼装迟早拼出病句。工具名先过 `humanizeAgentToolId`(取 `__` 最后一段、下划线换
空格)再按语言加引号(中文直角引号 + 顿号,英文弯引号 + 逗号),界面上不再可能出现 `run_command`、
`mcp__gh__list_issues` 这类原始 id。仓内没有既有的「工具 id → 人话名」对照表,所以不做任何猜词映射;
将来有了对照表,由调用方先查表、查不到再退到这个去下划线版本。

## Alternatives considered

- **复用 `agent_run.step` + `kind: "reminded"`,preview_text 直接写中文句子**:能白捡桌面 Cuu 卡片
的现成渲染(`cardFromAgentRunEvent` 拿 `preview_text` 当卡片正文)。否决两条理由:一是
`packages/agent` 也在 `audit:ui-i18n` 的扫描面内,loop 里新写中文字面量过不了门(既有的
`previewText` 要么是机器串如 `doom_loop`,要么已在基线里);二是英文界面会直接看到中文句子。
改成结构化 payload + 两端组词后,`previewText` 只留机器串 `repeat_reminder tier=N`。
- **把提醒落成一条 `agent_run_steps` 行**,这样现有时间线(渲的是 `AgentStep[]`)不用改就能看见:
要给 `agentStepPhases` 加枚举值 + 数据库迁移 + 运行器 recorder 改动,而且语义是错的——提醒不是
模型的一步,混进步号会让「跑了几步」这个数字开始撒谎。
- **只加事件、不动 VM**,让前端从 SSE 流里自己攒:仓内没有任何一个前端保留 run 事件用于展示
(web 回放页收到事件是触发整页重拉,桌面 run 卡是触发 `getAgentRun` 重取,Spotlight 回放视图走
trace 游标轮询)。只发事件等于观测面停在「有一条 SSE,没人渲」。
- **在事件 payload 里直接给渲染好的句子**:见 Decision——两端无法本地化,且会把模型可见文本与
界面文案绑死。

## Consequences

- **事件枚举增一项**:`eventTypeSchema` 由 `Object.values(eventTypes)` 生成,新类型自动进契约面。
`toCuuState` 映到 `thinking`(不是 `worried`)——前两档只是自救提示,运行仍在继续;`worried`
是「需要人介入」的信号,那由第三档的 `agent_run.escalated` 负责。
- **EventSource 是按事件名订阅的**:桌面 per-run 流的 `desktopCuuRunStreamEventNames` 必须显式登记
新事件名,漏登记就被静默丢弃(web 的 `live-stream-targets.ts` 早有同款教训)。已登记。
- **openapi 的 `agentRunLive` / `replayTrace` 两个响应 schema 是 `additionalProperties: false`**,
所以 `reminders` 必须同步声明,否则等于把这一列声明成非法字段。已同步。事件本身不进 openapi:
`/api/push/stream/*` 一律只声明 `text/event-stream` 字符串,从不逐类枚举事件形状。
- **模型可见文本零变化**:`buildDoomLoopReminder` 一个字没动,`pnpm gen:expected` 跑完
`packages/agent/expected/doom-loop-reminder.*` 无 diff。
- **还差最后一跳(本批范围外)**:事件已发、VM 字段已开、两端渲染已就位,但把
`agent_run.reminded` 攒进运行记录再喂进 VM 的那一段在 `apps/api` 的业务代码里
(运行器的 run 记录 + `apps/api/src/pages/replay.ts` 的 `toAgentRunLiveVm` / replay VM 组装 +
trace 持久化)。补上之前,`reminders` 恒为缺席,时间线表现与改动前完全一致(additive optional
的既有取舍)。这一段要连持久化一起做,否则 worker 重启后已发生的提醒会丢。
106 changes: 106 additions & 0 deletions .agents/notes/implemented/2026-09-06-b6b-reminders-persist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# 重复动作提醒进运行记录与库(agent_runs.reminders_json)

- Status: implemented
- Date: 2026-09-06
- Owner: claude-code

## Problem

B6(`.agents/notes/implemented/2026-09-06-b6-reminder-events.md`)把「先劝再断」的前两档做成了
`agent_run.reminded` 事件,契约面开了 `agentRunReminderFactsSchema`,三个运行 VM 各留了一个
additive optional 的 `reminders`,`packages/ui` 与桌面 Spotlight 也都写好了渲染。唯独中间那段没接:
**没有人把事件攒进运行记录**,于是 `reminders` 恒为缺席,两端时间线的表现与改动前一字不差。

这段缺口有两个后果,第二个更要命:

1. 回放页读的是库,不是 SSE 流。运行结束之后再打开回放,提醒那一行永远不存在——「劝过」这件事
在人能回头看的地方从来没发生过。
2. 事件是转瞬即逝的。worker 换人接手(租约回收/重排)、或进程重启之后,已经发生的提醒既不在
内存里也不在库里,连「这次运行已经被劝过一档了」都无从得知。

B6 那份档案自己把这一跳记成了遗留(「这一段要连持久化一起做,否则 worker 重启后已发生的提醒会丢」)。
这一批就是来补的。

## Decision

**存成 `agent_runs` 上的一列 jsonb(迁移 0075 `reminders_json`),不新开表、不塞 trace。**
形状与同表的 `handoff_json` / `budget_decision_json` 完全同款:一列 jsonb 装一个数组,每个元素就是
contracts 的 `agentRunReminderFactsSchema`(`step_no` / `tier` / `repeats` / `shape` / `tool_id?` / `tool_ids?`)。
提醒天然是 run 的附属事实、条数以个位数计、永远随 run 一起读写,够不上一张表。

**不落 `agent_steps`**——这条是 B6 已经拍过的否决项,这批照旧执行:提醒不是模型的一步,给它发一个
步号会让「跑了几步」这个数字开始撒谎;而且 `AgentRunTraceStepRecord` 只有六个标量列,塞提醒得先给
`agentStepPhases` 加枚举值再加迁移,语义和成本双输。渲染层按 `step_no` 把提醒插在**所属步骤之后**,
它自己不占号。

**列可空、无默认值;`null` 与空数组同义。** 既有行不用回填,新行没被劝过就是 `null`。写入侧空数组
不落列(`toPersistenceRun` 里 `run.reminders?.length` 才带这个键),读取侧空数组读回缺席
(`queueReminders` 空则 `undefined`)。理由是别让「这一列有没有内容」多出一个没有语义差别的第三态:
`undefined` / `null` / `[]` 三者在界面上都是「时间线不渲提醒行」,那就只保留一种表达。
省略键还有一个附带好处:drizzle 在 UPDATE 里跳过 `undefined`,一次不带提醒的进度写不会把已存的
`reminders_json` 清成 `null`。

**在事件出门的那一处累加,不额外发一次写。** 运行器把 `emit` 从 `(event) => emitRunEvent(event, current)`
改成先认一下 `agent_run.reminded`、用 `readAgentRunReminderFacts` 解析(事件专属的 `run_id` /
`work_item_id` 由它自动剥掉)再追加进 `current.reminders`,然后照旧发事件。累加**不触发**任何新的
DB 写:它随这次运行既有的落盘路径(终态/失败的 `persistRunWithTrace`)一起进库,与 `usage`、`handoff`
的落盘节奏完全一致。

这条取舍值得写清楚,因为它决定了持久化的粒度:`agent_runs` 这一行在一次执行里只被写三次
(enqueue 建行、起跑转 running、终态),中途每步只写 `agent_steps`(`replaceTrace`)和心跳。为了让提醒
在**每一步之后**就进库,要么给每条提醒多发一次 run 行的 UPDATE,要么把它搭到 `replaceTrace` 的事务里
去顺手改另一张表。前者是为一年也见不到几次的事实加一条常驻写放大,后者让 `replaceTrace` 名不副实。
两条都不划算:中途崩掉的 run 会被 `requeueExpiredClaims` 重排并**从头重跑**,trace 整个被 `replaceTrace`
换掉——上一轮的提醒本来就是过期数据,特意为它保命反而会让时间线出现两轮混在一起的提醒。所以「随
既有落盘路径」既是省一次写,也是对的语义。

**累加带守卫,且封顶 32 条。** 守卫与 `recordStep` 同款:从 `runs` map 取 live 记录、`status !== "running"`
(被取消/租约被回收)就不写;spread `live` 时显式带上 `current.usage`,否则 `recordUsage` 只写 `current`
不写 map 的最新用量会被盖掉(这个坑 `recordStep` 里已经踩过一次并留了注释)。
`AGENT_RUN_REMINDER_CAP = 32` 纯属防御——正常一次运行最多两条(第一档一次、第二档一次,第三档改走
`agent_run.escalated`),真出现病态序列时别让一列 jsonb 无限长。到顶后**丢新的留旧的**:最早那两条才是
解释这次运行为什么会升级的证据,后面的重复只是噪声。读取侧用同一个常量、同一个方向截断。

**读回宽容到条,不宽容到列。** `queueReminders` 逐条走 `readAgentRunReminderFacts`,解析不出来的整条
丢掉(宁可少一行,也绝不把半截数据编成一句话);整列不是数组(历史脏数据/手改)则整列当缺席,不猜、
不半读。两端渲染层本来就共用同一份宽容读取,服务端这层是同一条纪律往前挪了一站。

**两个 VM 各加一行透传,仍然是 additive optional。** `toAgentRunLiveVm`(实时页)与
`buildReplayTracePage`(回放页)都只在 `reminders?.length` 时带这个键。第三个 VM
`agentRunTraceVmSchema` 仓内没有任何生产者(纯契约面),这批不造一个出来。

## Alternatives considered

- **每条提醒多发一次 run 行 UPDATE**,让提醒在下一步之前就落库:见 Decision——为极低频事实加常驻写
放大,而中途崩掉的 run 会从头重跑、上一轮提醒本就是过期数据,保住它反而让时间线更乱。
- **把 `reminders_json` 的写搭进 `replaceTrace` 的事务**(那是每步都会跑的既有写):一次调用改两张表,
`replaceTrace` 这个名字就不再说实话;而且每步都要重写整列,写放大反而更大。
- **另起一张 `agent_run_reminders` 表**:条数以个位数计、永远随 run 一起读写、没有任何独立查询需求,
一张表换来的是一次 join、一份迁移、一套仓储方法。`handoff_json` 当年也是同样的判断。
- **让前端从 SSE 流自己攒**:B6 已经否过一次——仓内没有任何前端保留 run 事件用于展示(回放页收到事件
是触发整页重拉),只发事件等于观测面停在「有一条 SSE,没人渲」。这批只是把结论落到服务端。
- **提醒也塞进 trace 步骤**(复用现成的 `AgentStep[]` 渲染):B6 已否——占步号会让「跑了几步」撒谎。
- **读取侧不做上限**:读回的本来就是写入侧写的,理论上不会超。但脏数据/手改是真实存在的运维面,
一列畸形 JSON 不该能把整页时间线撑爆,所以两侧同一个常量各截一次。

## Consequences

- **迁移 0075**(`0075_agent_run_reminders.sql`,journal `idx:75`、`when: 1783929007000`,严格大于 0074)。
全 additive:`ADD COLUMN IF NOT EXISTS`、可空、无默认,migration-audit 的整链 replay 重跑安全。
- **`AgentRunQueueRecord` 多一个可选字段**。所有 spread 式的记录构造(`finalizeExecutedRun`、
`recordStep`、恢复路径的 `toQueueRun`)天然带着它走,无需逐处补写。
- **落盘节奏 = 与 `usage` / `handoff` 同档**:run 行的写发生在 enqueue / 起跑 / 终态三处。运行**结束后**
的回放页一定看得到提醒;运行**进行中**换 worker 接手则看不到上一轮的提醒——但那一轮本来就要从头重跑。
这是刻意的取舍,不是漏接线。
- **`agent_run.reminded` 事件本身一字未动**,`packages/agent` / `packages/contracts` / `packages/ui` /
桌面端全部零改动(B6 已定型)。openapi 的 `agentRunLive` / `replayTrace` 两个响应 schema 在 B6 就已经
声明了 `reminders`(两者都是 `additionalProperties: false`,漏声明等于把这一列宣布成非法字段),
这批不用再动。
- **模型可见文本零变化**:`buildDoomLoopReminder` 没碰,`pnpm gen:expected` 跑完无 diff。
- **真 PG 门加了一条断言**:`qa:r1-pg-agent-run-smoke` 在迁移跑完后查 `information_schema`,要求
`agent_runs.reminders_json` 是**可空、无默认**的 jsonb。列不在就等于「被劝过几次」只活在 SSE 流里。
本机一次性 `postgres:16` 容器实跑通过;把该列 drop 掉后这条断言按预期报错(验证非空跑)。
- **`agent-run-persistence.ts` 现在从 `workers/agent-runner.js` 取一个值**(`AGENT_RUN_REMINDER_CAP`),
与既有的反向 import(agent-runner → `getDefaultAgentRunPersistence`)构成一个 ESM 环。两边都只在函数体里
用对方的绑定、模块顶层不读,因此没有 TDZ 风险;`apps/api` 全量测试与 typecheck 已覆盖两种加载顺序。
真要拆环,该常量应该搬到一个两边都能引的小模块,那超出本批范围。
Loading
Loading