Skip to content

R20 wave5: Phase 3 一致性/工程质量六线合流 - #16

Merged
mycyg merged 22 commits into
mainfrom
r20/wave5
Jul 18, 2026
Merged

R20 wave5: Phase 3 一致性/工程质量六线合流#16
mycyg merged 22 commits into
mainfrom
r20/wave5

Conversation

@mycyg

@mycyg mycyg commented Jul 18, 2026

Copy link
Copy Markdown
Owner

内容(SSOT: r19-iteration-review/02-ssot-2026-07-17.md)

六条代码线 + 一条调查线,各自隔离 worktree 施工,负责人逐单审查 + 独立红绿复验(含真 PG)后合流:

  • P2-01(W5-1): 会话消息事务性 outbox——event_outbox 表(迁移 0069,additive)与消息行同事务入队,提交后即席 drain + 启动补扫 + 30s 周期重放;event_id 幂等键;真 PG crash 窗口复现(commit 后 publish 前崩溃→重启补发)红/绿佐证。其余 publish 点清单已列册留作后续。
  • P2-02(W5-2): 账号停用善后可重入——幂等清理例程逐步捕获,未完成回 500 offboard_cleanup_incomplete(不再吞错伪装成功),同端点重发即重试,墓碑判据分辨已停用/不存在;无迁移。
  • P2-04(W5-3): conversation.title.updated 领域事件——rename 落库后投会话私有流;桌面就地改左栏树叶,web 镜像订阅面追加;R17 G2 枚举只增不删。
  • P2-09/10/11 + R19-27(W5-4): 语言偏好同步失败可见可重试;头像裁剪弹窗焦点圈闭/Esc/还原;launchChrome 失败带根因(spawn 错误码/早退/stderr 尾部);工作项跨 run 审计时间线渲染(403≠空态,失败可见+重试)。
  • P3-01(W5-5): cargo fmt/clippy 清零(60 处机械修复,零 allow 压制)+ 进 CI(rust-system-i18n job 增 fmt --check + clippy -D warnings,Linux cfg 覆盖面已注明)。
  • P3-02 + R19-29/30 + P1-08 尾巴(W5-6): 预算续租 0 行/异常结构化可观测;三个死读端点(handoff/ai-worklog·today/list-work-item-proposals)核实零消费后删除(数据已由 page VM 交付);桌面 replay 增量 trace 走 after 游标轮询;web 会话镜像 loader 弃全局 /api/users 改工作区花名册——apps/web 非头像的全局目录消费清零。

孤儿表处置(R19-7/34-37)为调查报告交付,drop/复活待产品拍板,本 PR 不含迁移之外的表变更。

验证

  • 每线基线红/修复后绿由负责人独立复现(W5-1 含自建一次性 PG 容器真库复现)。
  • 合流树:pnpm -r test / -r typecheck / lint 全绿;audit:migrations 过;集成期修复一处:packages/db journal 守卫测试同步 0068→0069。
  • 六线自动合并零冲突,合流后亲核 services/conversations.ts 双线共存、mock 档注一致。

🤖 Generated with Claude Code

mycyg and others added 22 commits July 18, 2026 16:39
deep_link.rs/windows.rs 两处既存漂移(P3-01 工单已知);重排 test 断言换行
与 vec! 字面量换行,无字符串/逻辑变化。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 55 处 assert_eq!(x, true/false) -> assert!(x)/assert!(!x)(纯断言写法,零语义)
- events.rs: event_channel_name 挪到 #[cfg(test)] mod tests 之前(items_after_test_module,纯移位无内容改动)
- main.rs: workhub_env_flag_value 的 match 改写为 matches! 宏(match_like_matches_macro)
- main.rs: 三处 &app.handle() 去掉多余取址(needless_borrow,app.handle() 本身返回引用)

均为 clippy --fix 自动应用 + 1 处手工同构改写(match_like_matches_macro 无自动修复),
cargo test 121/121 全绿,无需任何 #[allow] 压制。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…i18n job

复用既有 job 的 Linux Tauri 系统依赖 + Swatinem/rust-cache,新增两步:
fmt --check(rustfmt 纯语法排版,不评估 cfg,覆盖全部源码包括 mac/windows 专属分支)、
clippy --all-targets -D warnings(真编译再 lint,Linux target 编译期会剔除
`#[cfg(target_os = "macos"/"windows")]` 门下的分支,故只覆盖 Linux 生效路径——
与该 job 里 cargo test 的覆盖范围一致,mac/windows 专属分支仍需本机验)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e (P2-02)

账号停用(POST /api/auth/users/:id/deactivate)的善后清理(撤会话/设备/凭据、
工作交接、清在线态)此前是 best-effort:中途失败被静默吞掉、仍回 200 ok:true,
留下半清理态(残留会话/设备可能仍可访问),且重发请求因 softDelete 落空一律 404,
无可重入的重试路径。

- 把善后步骤收进 runOffboardCleanup 幂等例程:逐步捕获、失败写结构化日志并记入
  cleanup.steps,返回 complete 标志(不吞错伪装成功)。
- 清理未完成 → 回 500 + { code: offboard_cleanup_incomplete, deactivated, cleanup },
  调用方可感知失败步并重试。
- softDelete 落空时用既有 findRefsByIds 分辨墓碑 vs 不存在:已停用则重跑幂等清理
  收敛到全清理(重试入口),从不存在才 404。首次停用才写 auth.user_deactivated 审计。
- 无迁移:复用软删墓碑 + 既有 findRefsByIds 作为可重入的任务状态。
- 边界:区别于 SEC-1(工作区移出的同事务撤 token);本单是账号级停用,且含非库内的
  presence.forgetUser,无法整体入库事务,故取幂等重入而非单事务。

Make account deactivation cleanup re-entrant: idempotent steps, structured
failure logging, and a retry entrypoint via the soft-delete tombstone. No
migration. Root-cause tests reproduce the half-cleaned state and prove
convergence on retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
重命名协同会话此前只有发起端本地就地改名,不发领域事件——其他端/其他人
要等下一次全量轮询才看到新名字,web 会话镜像页则完全不知道。

- contracts: 新增 conversation.title.updated 事件(enums + events schema),
  极简 payload {conversation_id, title},topic 与 conversation_id 绑定;不删改既有枚举(R17 G2)。
- api: renameConversation 落库后 best-effort 广播到会话私有流 conversation:<id>
  (仅参与者可订,不广播全工作区),收口历史遗留的「不发事件」取舍。
- desktop: 会话流分发表新增 parseIncomingConversationTitleUpdated 分支,
  经 onConversationTitleUpdated 回调复用 renameCollabConversationInVm 就地改左栏树叶,不整页刷。
- web: conversation.title.updated 纳入 CONVERSATION_MIRROR_LIVE_EVENT_TYPES
  (EventSource 按事件名订阅,漏登记即静默丢弃),到达触发既有全量重渲对齐标题。
- tests: api 发布断言(修复前红)、桌面 parser、契约 schema、web 订阅面各一。

Rename collab conversations now emit a domain event on the conversation-private
SSE topic so other ends update their rail leaf / mirror-page title in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…20 P2-01)

修「会话消息 DB commit 与 publish 之间无 outbox/replay」丢投裂缝:消息先落库提交、再
best-effort bus.publish,两步之间进程崩溃或 publish 抛错则 conversation.message.created
永久丢失(SSE resume_mode='fresh' 不重放,只有重连才全量补拉)。

- 0069 event_outbox 表(additive):事件与消息写同事务落 outbox,drain 提交后 publish、
  成功才置 published;event_id 幂等键,消费端按全量重拉对账容忍重复。
- createUserMessage 增可选 enqueueOutbox 钩子,事务内原子写 outbox 行。
- createConversationService 接 outboxDrain:提交后即席 drain;崩溃残留由 event-outbox-drain
  调度器启动补扫 + 周期重放(drain 失败结构化日志,禁空 catch)。
- 范围仅会话消息(人到人 DM/协同/主区人类消息);其它 publish 点留后续。
- 根因测试含 crash 窗口复现(纯内存 + 真 PG,opt-in),修复前稳定红。

Closes the commit→publish gap where a crash between the message DB commit and the
SSE/event-bus publish permanently dropped conversation.message.created. Events now land
in an outbox table within the same transaction as the message row; a drain loop publishes
committed rows and replays anything left pending after a crash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Onboarding used to fire-and-forget `updatePreferences({ locale })` with
`.catch(() => undefined)` right after a successful sign-in — a failed sync
was invisible, so the user believed their language preference was saved
server-side when it was not (it would silently revert on a fresh device or
cleared cache). Extract the retry orchestration into a small, DOM-free,
unit-testable module (onboarding-locale-sync.ts, mirroring the existing
confirm-button.ts/avatar-crop-modal.ts pattern, since browser.ts's top-level
`document` access makes it untestable directly): a failed sync now shows a
persistent notice with a working retry action, and a successful retry shows
a confirmation — it no longer proceeds without ever telling the user.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The avatar crop modal had no keyboard focus lifecycle at all: opening it
never moved focus in, Tab could leak out to the page behind it (the modal
is appended to body as a sibling, not a native <dialog>), Escape did not
close it, and closing never restored focus to the button that opened it.

Adds, following the R10 accessibility-batch precedent for this codebase:
- open -> focus moves to the first operable control (the zoom slider)
- Tab / Shift+Tab traps focus in a loop across the three controls
  (slider -> cancel -> confirm -> slider), never leaking to the background
- Escape closes like Cancel (no confirm/upload)
- any close path (cancel/confirm/Escape) restores focus to whatever had
  focus before the modal opened

AvatarCropDeps gains an optional `getActiveElement` (defaults to
document.activeElement) and AvatarCropElement now requires `focus()`, so the
existing fake-DOM test harness can observe and drive this deterministically
without a real browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
launchChrome() only ever threw "Timed out waiting for Chrome CDP target:
<fetch error>" on failure — the fetch error is almost always a generic
ECONNREFUSED, which says nothing about whether Chrome actually spawned, died
immediately, or why (missing shared library, sandbox/permission issue,
corrupt profile...). It also never attached an 'error' listener to the
spawned child, so a bad chromePath (ENOENT) crashed the whole process
uncaught instead of rejecting.

Now captures: the spawn error (code/message), an early process exit
(code/signal) detected without waiting out the full timeout, and a captured
stderr tail — all folded into one structured error message alongside
chromePath/debugPort/userDataDir. This is qa-infra-adjacent launcher code,
not a smoke suite; verified with two new unit tests that inject a fake
"chrome" executable (a controlled shell script), not by running any smoke
suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eline

The backend has long had a cross-run audit timeline endpoint (GET
/api/workitems/:id/audit — snapshots + audit-log facts + manifest checks,
covered by packages/db's audit-repository tests), but no typed client
method ever called it and the web work item detail page never rendered it.

- packages/api-client: add getWorkItemAuditTimeline(workItemId), following
  the existing listWorkItemProposals/listWorkItemConflicts pattern.
- packages/ui/gold-path/route-components.ts: render a new "cross-run audit
  timeline" card on the work item detail page (after the evidence section)
  with a hydration placeholder; renderWorkItemAuditTimelineRows is an
  exported pure function (localizes time + action + actor, marks undone
  entries, truncates to 8 with an honest "N more not shown" note, matching
  the evidenceRows precedent).
- apps/web/src/browser.ts: bindWorkItemAuditTimelinePanel fetches the
  timeline client-side after the workitem route renders and hydrates the
  placeholder; a 403 is shown as "no permission" (distinct from empty), any
  other failure shows a visible error with a working retry — never
  disguised as "no history yet" (matches the P1-07 project-home-plans
  precedent).

This only touches route-components.ts's workitem renderer and browser.ts
(web-only); the desktop work item panel renders through a separate function
(packages/ui/gold-path/render.ts's renderWorkItem) and is unaffected either
way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refreshClaim swallowed both DB errors and 0-row renewals from
reservationRepo.refreshLease() with a bare `.catch(() => {})`, so a lost
budget reservation lease (already expired/released, or never reserved for
this workspace+run) went completely unnoticed. Outstanding-budget
accounting for concurrent runs would silently under-count the still-running
run, risking budget overrun with nothing in the logs to explain it.

Log agent_run_budget_lease_renew_no_rows / ..._failed via the structured
logger on the 0-row and error paths respectively; run status/execution is
unchanged (best-effort, same as before). Root-cause test in
agent-runs.test.ts drives a real heartbeat cycle with a fake reservation
repo that returns 0 and asserts the log line now appears (red before this
fix, green after).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R19 review flagged three read-only endpoints with zero front-end/SDK
consumer beyond their own test stubs, each superseded by data already
delivered through an existing page VM:

- GET /api/agent-runs/{id}/handoff — structured handoff already embedded
  in the /replay page VM (handoff/handoff_md fields, apps/api/src/pages/
  replay.ts). SDK's getAgentRunHandoff had no caller in web/desktop.
- GET /api/ai-worklog/today — same "today's AI worklog" metrics already
  embedded via AiWorklogMetricsService in the attention/agent-army
  dashboard page VMs (apps/api/src/routes/pages.ts, agent-army-dashboard).
  Never had an SDK wrapper at all.
- GET /api/workitems/{id}/proposals (list-work-item-proposals) — proposal
  list already embedded in the work item detail page VM. SDK's
  listWorkItemProposals had no caller; the POST on the same path (create
  proposal from manifest) stays untouched and is still consumed.

Verified via repo-wide grep that no production web/desktop code calls
getAgentRunHandoff / listWorkItemProposals, and no code references
/api/ai-worklog anywhere outside its own route+openapi+service. Deleted
the route handlers, the ai-worklog route module (its metrics service is
still used elsewhere and is untouched), the SDK methods/types, and the
matching OpenAPI path/response-schema entries; updated the tests that
exercised the now-removed paths.

Added a regression test (app.test.ts) asserting all three are gone from
both the OpenAPI document and, for ai-worklog, a live HTTP request (404
instead of the old 401) — red before this change (paths still resolve),
green after. Per R17 G2, no SSE event enum values were touched — this is
HTTP-route-only cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lling (R19-30)

GET /api/agent-runs/{id}/trace has supported an ?after= step_no cursor for
incremental fetching since it was built, but no front end ever passed it —
web's replay page re-fetches trace bundled into one full page-VM request
per SSE event, and desktop's Spotlight "replay" capability (list running
AI runs -> open a trace timeline) only ever did a single one-shot
getAgentRun() fetch with no live refresh at all while the detail view
stayed open.

Wire real incremental polling into the Spotlight replay view: once the
initial full run VM confirms the run is still active (queued/running),
poll GET /api/agent-runs/{id}/trace every 4s with after set to the highest
step_no seen so far, append only the new steps, and re-render just the
timeline. A full getAgentRun() resync (to catch status/usage and decide
whether to stop polling) runs when a "final" step appears or every 5th
poll as a safety net, since the incremental endpoint doesn't carry run
status. First load stays a full fetch; only the live-refresh loop is
incremental. Polling stops on dispose, on navigating back to the run
list, or once the run reaches a terminal status.

Investigated wiring the same pattern into web's /agent-runs/{id}/replay
page and decided against it for this batch: unlike desktop, web's replay
page has no existing client-side incremental-render capability — trace
currently arrives as part of one combined page-VM SSE-triggered reload
(not a separate call), and the shared SSE refresh path
(refreshCurrentRouteFromLiveEvent in browser.ts) is a heavily-hardened,
widely-reused mechanism across every route. Building a parallel
incremental-DOM-patch capability from scratch there is a materially
larger, riskier change for a low-priority finding; flagging as a
follow-up rather than bolting it on here.

Root-cause test in replay.test.ts drives a mocked setTimeout to trigger
one poll tick and asserts getAgentRunTrace is called with after=1 (the
initial trace's max step_no) and that getAgentRun is NOT re-called that
tick — red before this change (the view never calls getAgentRunTrace at
all), green after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pace roster (P1-08 tail)

The read-only web conversation-mirror route (/conversations/:id) still
resolved sender nicknames via client.listUsers() — the global, cross-tenant
/api/users directory that the rest of P1-08 already replaced everywhere
else with the workspace-scoped roster endpoint, precisely because it leaks
across tenants and hard-truncates at 200 users.

Checked the alternative (GET /conversations/:id/participants) before
switching: for "main" conversations — the common case this route mirrors —
listParticipants always returns scope:"workspace" with an empty
participants array by design (main has no conversation_participants rows;
it's workspace-visible, not participant-tracked), so it can't resolve any
nickname at all for the most common case. It only carries real rows for
collab/DM conversations. The workspace roster, by contrast, always has
data and is the same source already used for the approval-delegate picker
in browser.ts, so it was the right fit — full paginate-to-total is already
implemented in workspace-roster.ts.

Swapped the route to fetchWorkspaceRosterMembers(client), mapping
{user_id, nickname} to the {id, nickname} shape the renderer expects.
Confirmed apps/web now has zero non-avatar consumers of the global
/api/users endpoint (grep shows only /api/users/:id/avatar image URLs
remain). Updated routes.test.ts's fake client to serve GET
/api/workspace/roster instead of the removed listUsers stub; all prior
conversation-mirror assertions (sender nicknames, fail-soft on directory
error, not_identified bubbling) still pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mycyg
mycyg merged commit 0ec6715 into main Jul 18, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant