Skip to content

Latest commit

 

History

History
442 lines (255 loc) · 107 KB

pitfalls.md

File metadata and controls

442 lines (255 loc) · 107 KB

Recurring Pitfalls

反复出现过的事故复盘:症状、根因、规则和机器守卫。改动相关区域或修 Bug 前,先检索下面的分类索引,再按编号或症状关键词读取完整条目。

编号是稳定 ID,代码注释里的 pitfall #N 指向这里。新增条目追加到末尾,不重排、不复用已有编号。

分类索引

会话、状态与响应式

  • #1 列会话的界面都要在共享查询层过滤自动化会话,只留 purpose === "conversation"。
  • #2 legacy $: 不追踪其他模块的 runes;新组件用 runes,旧界面经 view store 读状态;随 props 变化的动态组件用 $derived。
  • #3 列表 / 详情请求带 generation 和 owner ID,写可见状态前校验;交互操作直接消费自己请求的结果。
  • #13 设置变更发同文档事件;刷新中的消费者保留一次待刷新;互斥设置区每次进入都刷新。
  • #29 上下文重置只清它拥有的状态(Mini App 跨会话保留);一个概念只能有一个宿主分支。
  • #47 本地镜像状态由一个响应式块双向同步,不靠各调用点手动 sync。
  • #56 会话归属从索引解析,不从调用方推导;重连恢复用户原视图。
  • #57 未提交 final 的 assistant 条目都是过程文本,不按位置当结果。
  • #64 导航和编辑意图一起更新时,编辑意图附着在该次加载完成路径。
  • #67 会话身份不等于执行归属:断连 / 恢复只取消自己的 runId;历史成果按执行读取,不借用当前会话。

布局、样式与主题

  • #4 亮度看 data-resolved-appearance;主题族用语义 token,族级形态只挂 data-theme-region。
  • #5 未定义的 var(--x) / keyframe 静默失效;先确认 token 存在,尺寸贴 Geist 刻度。
  • #16 面板内禁用 vw/vh;网格用 minmax 下限,窄屏面板保持 in-flow;可滚动弹窗链路都要 min-height: 0。
  • #17 第三方组件通过它自己的变量换主题(覆盖所有 scheme),用真实 DOM 验证。
  • #24 字号行高用成对的 --fs-* / --lh-*,图标用 --icon-*;不手写 px。
  • #38 .messages 只纵向滚动,不换行的渲染器自己 overflow-x: auto。
  • #48 玻璃材质只用 tint + blur + edge + shadow;不 filter 材质层,不在 backdrop-filter 里引用 SVG。
  • #51 共享默认样式用 :where() 零特异性,让变体可以覆盖。
  • #69 侧栏菜单和遮罩 Portal 到 body。
  • #76 行级键盘操作只处理行本身的事件,不拦截子按钮与复选框。
  • #80 搜索结果替换文件浏览区,与下方预览并存;打开文件不退出搜索。

复用与渲染

  • #7 复用 ConversationRow、ChatInputArea、共享 transcript renderer、ConversationController;共享组件不写项目 / 渠道分支。
  • #8 分词、打分、token 估算要显式处理中文(moryTokenize.ts,CJK ≈ 1 token / 字)。
  • #28 静态列表的 keyed each 用 index;CSP 每类资源都要放行 loopback;dotfile 没有扩展名;源码视图复用 CodeViewer。
  • #39 持久化的 [$skill](…/SKILL.md) 引用在共享分类边界解析,回到同一渲染器。
  • #40 Mermaid 一律 suppressErrorRendering: true;第三方渲染器失败后检查外部宿主节点。
  • #45 reicon-svelte 只用子路径导入。

桌面端与传输

  • #18 窗口拖拽先查 core:window:allow-start-dragging 授权,再查层级。
  • #25 WebView 源集中在 csrf-trusted-origins.mjs;先确认服务端自身 origin。
  • #44 跨 WebView → 原生 → 服务的能力每层都要接受同一域;新增 scope 测原生 upstream_url。
  • #54 桌面经 tauriFetch 调的 /api/settings/... 必须进 Tauri HTTP scope 白名单。
  • #74 桌面组件禁止原生 fetch,一律走 apps/desktop/src/lib/api.ts。
  • 原生 capability、CSP、协议改动都需要 Rust 重建 + 重启 App,WebView 热更新不生效。

Agent 运行时

  • #12 带外执行用 runner 同一套 helper 解析 cwd / 项目 / 模型;Host Bash 与 Broker 存储分离,按请求 ID 匹配。
  • #14 取消靠意图标记判断;Stop 不清队列;预算守卫收尾而不是 abort;运行时纠正用 steer。
  • #19 调用前写出的伪工具调用可安全重试一次;调用后的禁止重试,从最后一次成功结果修复。
  • #20 @[file](path) 由共享 Runtime 解析校验;"已修改"需要 write / edit 回执。
  • #33 外层重试要重放已接受的 steer,不持久化成普通消息。
  • #37 运行时自建的 assistant 消息带 zeroAssistantUsage(),进入上下文前统一规范化。
  • #41 能力是否注册不能靠解读用户消息;MCP 控件与提示词同源于 hasConfiguredMcpServers。
  • #43 审批等待是挂起 / 恢复:30s 握手后挂起释放租约;挂起必须终止循环;无人值守运行直接拒绝。
  • #60 工具检查点不授权整步重放;不可幂等操作先核对外部状态或转人工。
  • #68 编排容器(Codemode / subagent / mcpInvoke)不持有叶子操作的执行锁。
  • #70 日志停在 tool_start + 固定超时 fetch failed,先查待审批请求,不是服务崩溃。
  • #71 请求级覆盖值缺失时保持 undefined,不伪造默认值。
  • #72 上下文预算守在最终 Provider 边界(含 system prompt 和工具 schema);只裁剪送模副本。
  • #75 父 Agent 失败重试只在失败后计数;子任务预算 / 超时作为失败工具结果交回。
  • #78 Chord Context 用官方派生 API 保留取消、遥测与值,不用对象展开复制。
  • #79 服务商连接测试也要提供会话标识;Header 守卫覆盖真实探测入口。

输入、工具与载荷

  • #6 渠道 ID 合法含 @ : + %;同一路径字符串在所有工具里含义一致;Skill 写到 cwd 外要同步路径守卫。
  • #26 未知入站消息类型要有带日志的 default;工具先校验参数再产生副作用;图片识别走 read(path, prompt)。
  • #27 外部载荷(MCP 结果等)受共享预算约束;单条超长消息做内容级截断;单行载荷用 sliceToBytes。
  • #31 定向操作看目标 id 的结果,不看总数;显式重连不继承后台吞错。
  • #32 飞书按钮两段式:先回处理中卡片,再按 open_message_id 更新结果,失败发文本回执。
  • #35 transcript 存在、未来上下文、会话搜索、记忆资格是四件事,所有投影都查共享保留策略。
  • #77 同一记忆抽取流程的多种触发共用增量进度;分批游标只覆盖实际处理的连续范围。
  • #36 文档导出读回校验后再改名;delivery=text 语义统一;离线传输必须 reject。
  • #42 复制回调先核对每个自由变量;tsc 看新增错误。
  • #49 scratch 产物的 relativePath 从 scratchBase 计算(含日期段)。
  • #65 列表预览先筛选、分页,再读当前页正文。
  • #66 批量文件与运行历史查询先建一次映射;历史扫描异步读取,只投影返回记录。

服务生命周期、所有权与持久化

  • #11 settings round-trip 覆盖数据库和每个手写回读投影。
  • #15 沙箱启用后任何失败都 fail closed,不回退到宿主执行。
  • #21 存活探针走真实运行时;supervisor 不关闭自己的控制通道;第三方 Mini App / Pi 扩展保持独立进程。
  • #23 存活即所有权:租约带 owner_id;检查卡住记录的每个分支都落到终态;展示状态从执行记录派生;写日志不能杀进程。
  • #30 单实例归运行时(ensureServiceOwnership());DATA_DIR 隔离覆盖全树;一次性实例设 MOLIBOT_DISABLE_EXTERNAL_CHANNELS=1。
  • #34 <dataDir>/runtime 与 tooling 永不合并;不可变代际要有回收。
  • #46 沙箱内连不上 localhost 先查 allowLocalBinding;判断服务在线用宿主机 lsof。
  • #55 租约冲突(exit 73)是重新发现信号:收养兼容服务,不杀外部服务。
  • #58 测试模式(liveServicesDisabled())不启动 Durable worker 和后台补投。
  • #59 恢复审核从真实 Room HTTP 入口验证。
  • #61 租约维护独立于业务等待;回收不抢活跃 lease。
  • #62 强制迁移按项隔离失败记录,异常数据先备份;完成状态持久化。
  • #63 迁移完成后数据库归新运行时,不用旧映射推翻完成状态。
  • #73 长任务的 HTTP 传输超时 ≥ 任务超时;固定约 300s fetch failed 先判 Undici 超时。

构建与发布

  • #22 运行时模块按 glob 打包;诊断模块可选加载,不能成为启动依赖。
  • #50 随包内容清单与发布脚本同源;缺失要告警。
  • #52 构建不能在 adapter 原子发布前删除活动 build/。

流程类:#9 验证、#10 冷启动走查、#53 生效验收,见 开发与验证指南。

1. Session-list leakage

(fixed 4+ times): any surface that lists conversations (sidebar, browser dialog, external contexts/ projection, more-conversations dialog) MUST filter out automation sessions — origin: "automation", current t-* or legacy task-* session keys, legacy [EVENT:...] first-message sessions — and keep only purpose === "conversation". Do the filtering in the shared query layer (src/lib/server/app/desktopConversations.ts / externalSessionsFromContexts.ts), never per-channel or in UI.

2. Svelte 5 reactivity

(fixed 3+ times): a legacy $: derivation does NOT subscribe to runes $state from another module — it runs once and goes stale (symptom: blank on first open, works after a round-trip). New components use runes ($props/$state/$derived); legacy surfaces read live controller state only through its view store ($conversationView pattern). Never call a no-arg helper from a template and expect its internal state reads to be tracked — pass deps explicitly. Never trigger the same async load from both onMount and a $: block (they race on init). Corollary (fixed 2+ times, symptom: the composer advertises the global default model while the runtime uses the session's own): a $: reading a toStore projection re-runs on EVERY store tick because the projection returns a fresh object — gate such loads on the value actually changing (if (x && x !== lastX)), and never let a refresh handler reset per-session state (model/thinking selection) to a global default unconditionally. Guarded by apps/desktop/src/chat-ui.test.mjs. Corollary (2026-09-15, symptom: the project folder icon never switched to folder-open): a component variable derived via legacy $: and mounted as a dynamic component never swaps after mount — Svelte 5 compiles it to a mutable_source whose component mount keeps the first glyph while sibling bindings (aria-expanded, class) update fine, so the bug hides from markup checks. Icon/component picks that must change with props go through runes $derived in a $props() component. Guarded by the dynamic-component assertion in apps/desktop/src/reactive-statement-guard.test.mjs.

3. Stale async responses

(fixed 2+ times): list/transcript/detail fetches must carry a request generation plus owner IDs (projectId/sessionId) and be validated before mutating visible state; a late response must never replace the currently selected session. A generation guard governs shared visible state, not the value returned to the caller: an interactive operation that awaits its own refresh must consume that request's result directly, or a simultaneous background refresh can suppress the state write and make the caller observe a false empty list. Guarded by the Artifact turn-file refresh race assertion in apps/desktop/src/chat-ui.test.mjs.

4. Theme through the resolved appearance and family attributes

(whole-surface silent failure): Desktop stores brightness (light / dark / system) separately from the family (macos / rose-pine / catppuccin / midnight). App.svelte writes data-appearance, the actual data-resolved-appearance (light or dark), and data-theme-family; it no longer uses a single data-theme value. Rules that depend on real brightness must use data-resolved-appearance, while family tokens belong under the family + resolved-appearance combination. Style ordinary surfaces through semantic tokens (--card-bg, --label-primary/secondary/tertiary, --separator, --chrome-border, --fill, --accent, --online, --danger, --warning) so every combination follows automatically. Reach for a per-family declaration only for a value no shared token can express (e.g. --agent-city-sky, which every family pins to var(--header-bg); AgentStudioPane reads the computed value off the document and hands it to the WebGL scene, so the shell and the canvas clear colour share one source and cannot leave a seam). When a family must change the form of the existing chrome (gradients, frames, section bars), it may only target the documented data-theme-region hooks (window, header, sidebar, session-list, chat, composer, file-panel) and must not change layout; no other per-component family selector is allowed (the Windows 98 bevel is the one separate exception). Both halves are enforced by the family-selector assertion in apps/desktop/src/chat-ui.test.mjs. Status tints use color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent), never a baked light hex. Sidebar glass is a shared layering contract: normal paths keep the translucent --sidebar-material-tint and --sidebar-material-filter, while reduced transparency, increased contrast, and low-performance paths alone disable the filter; dark family tints become transparent under system-dark. Guarded by the token/appearance-family and sidebar-material assertions in apps/desktop/src/chat-ui.test.mjs.

5. CSS token drift

referencing an undefined var(--token) or keyframe fails silently (no error, just broken styling — e.g. --shadow-card, animation: spin). Desktop app uses Geist tokens per DESIGN.md (dark theme: design.dark.md); verify a token exists before using it, and snap sizes/spacing to the Geist scale (no 13.5px, no ad-hoc shadows outside the 3 elevation tiers).

6. Path validation vs. external IDs

external channel identifiers legitimately contain @ : + % (e.g. WeChat ...@im.wechat). Don't tighten segment/path validation without covering these; conversely, never expose absolute host paths or secrets in ordinary WebView projections — return opaque IDs and replace/clear semantics for credentials. Provider API keys may be returned only after the user explicitly clicks reveal, through a no-store request; keep the temporary value separate from the edit draft and discard it on hide, provider/page switch, or disconnect. Guarded by tests/ui/provider-key-reveal.spec.ts (saved-key display, draft isolation, stale-response rejection, retry, and summary redaction). Corollary (fixed 2+ times): one path string must mean one thing in every tool. bash goes through a shell and expands ~/$HOME; resolveToolPath used path.resolve, so the same ~/x became <scratch>/~/x and failed with a "Path not found" pointing at a path that never existed. Equally, an allowed-roots list is a product decision, not a security default: miniapp-creator scaffolds into <dataRoot>/miniapps/apps and then asks the agent to edit the result, so a guard that omits that root makes the whole workflow read-only through bash cat. Whenever a Skill or scaffolder writes outside cwd/Workspace, check the guard in the same change. Guarded by the home-expansion and Mini-App-root assertions in src/lib/server/agent/tools/path.test.ts.

7. Reuse shared modules, no forked copies

session rows use ConversationRow, composers use ChatInputArea, completed messages use the shared transcript renderer, turn logic goes through ConversationController with a host adapter. Generic shared components must not contain project/channel conditionals — callers inject differences.

8. CJK handling

any tokenizer, relevance scorer, or token estimator must handle Chinese explicitly (whitespace splitting collapses CJK to one token; chars/4 under-counts CJK ~3-4x and silently disabled compaction). Use package/mory/src/moryTokenize.ts for keyword scoring; weight CJK chars ≈ 1 token when estimating.

9. Verification convention

已并入 AGENTS.md「测试与验证」:按改动类型执行直接相关的最低检查,全量回归由用户在整体验收时执行;验证结果写在交付说明里,不写进 CHANGELOG.md。

10. Cold-start smoke walk

(many shipped bugs were "first open blank / first click dead / reset after restart" — tests and builds passed): after UI/desktop changes, additionally exercise the real cold path — restart the service, first-open/first-click every affected pane, switch sessions, and recover from a service interruption — before claiming done.

11. Settings round-trip

(fixed 3+ times): every new/changed settings field needs a save → fresh store → load round-trip regression against a temporary database; narrow serialization silently resets fields on restart. The round trip does not end at the database: every hand-written projection on the way back — providerItemToUpdateRequest() rebuilding the inline editor draft from the saved record, a page's loadAll() mapping into its form, that page's own save serializer — must copy the new field too. Any one of them omitting it empties the control the instant the save succeeds, and the next save writes the loss back (issue #28, the model alias). Prefer spreading the source over enumerating fields; where enumeration is required, assert the new field in that projection's unit test.

12. Out-of-band execution must reuse the in-run context

an approved Host Bash command can execute from two places — inside the run (bash.ts, which holds the agent's real ctx.cwd) or from an approval handler after the run ended (/api/chat _handleWebHostToolsCommand, baseRuntime). The out-of-band callers must resolve cwd/project/model through the same helpers the runner uses (resolveSessionWorkingDir, buildRunnerProjectContext), never re-derive them; picking the chat scratch dir made a project session's git push fail with "not a git repository". Guarded by src/lib/server/agent/hostBashExecContext.test.ts. Shared approval storage does not imply shared execution ownership: bash:* requests belong to HostBashStore, and Broker request lookup/listing must exclude them. Match suspended Host Bash results by approval request ID, never by a command string with potentially different arguments. Guard this boundary with both stores on the same temporary database (hostApprovalDispatch.test.ts). Background continuation callbacks must honor respond(..., false) as progress and replaceMessage as replacement; persist one sourced answer plus structured activities. Clients must observe run state, never assistant-row count, to detect completion (backgroundConversation.test.ts, approvalContinuation.test.ts). Corollary: when the out-of-band path fails, it must still rewrite the suspended toolResult and resume — a failure that only exists in an HTTP response body reaches neither the user nor the agent, and reads as "the button did nothing".

13. Desktop settings invalidation

(fixed 3+ times): Settings and Chat share one WebView, so a successful Provider/Agent/Profile/model mutation must publish the shared same-document settings event; do not rely on BroadcastChannel for local cache freshness. A consumer that is connecting or already refreshing must retain one pending refresh and replay it after settlement instead of dropping the event. An event only reaches mounted consumers: mutually exclusive Settings sections cannot depend on hearing each other's save event, so a section that projects another section's mutable records must refresh on every entry instead of trusting an endpoint-keyed cache. Guard both the real save → Chat selector seam and Provider save → re-enter Models seam whenever a settings mutation changes model options.

首页轮询恢复也属于数据失效:一次失败后重新读到活动状态,必须立即刷新交接投影,不能继续等待正常的 30 秒节流,否则已恢复的服务仍显示旧内容并禁用审批或记忆确认。守卫:tests/ui/agent-home.spec.ts 的记忆审核用例模拟中断和恢复,要求不切换页面即可重新操作。

14. User cancellation is not an error, and Stop is not a queue reset

(Issue #24): an aborted request's rejection shape is transport-specific — Tauri's HTTP plugin throws a plain Error("Request cancelled"), fetch throws a DOMException("AbortError"), and the server may emit an SSE error frame before the client aborts at all. Decide "was this cancelled" from the intent flag plus the shared apps/desktop/src/lib/chat/turnAbort.ts helper, never from one error class. Equally, Stop ends the current turn only: it must not silently discard queued follow-ups (drain them once the aborted turn has unwound), and a running turn must always offer a way to inject a queued message (/api/stream/steer → shared RunnerPool.steer) rather than only wait. Guarded by apps/desktop/src/lib/chat/turnAbort.test.ts, src/routes/api/stream/steer/server.test.ts, and the queue/steer structural guard in apps/desktop/src/chat-ui.test.mjs. Second occurrence, same family (a run killed by the tool-failure budget), so four rules are now mandatory. (a) A guard winds a turn down; it does not kill it. agent.abort() on budget exhaustion killed the in-flight request, so the run ended on a transport string (Request aborted) and lost the answer that turn had already produced. Withdraw the tool list and hand the model a runtime notice instead — the tool-call budget always did this and the two paths are now one. (b) An abort message is a symptom; report the cause. Whatever ended the run must own errorMessage, and the user-facing sentence must be written for a person (the budget's exceededReason is an instruction addressed to the model). (c) Status is never content. conversationProjection collapses tool-loop progress; using content || errorMessage per entry let a trailing content-less aborted entry overwrite the real reply. The error may stand in as the body only when the turn produced no text at all, and the transcript renders it as a separate note. (d) One user turn has one answer container, and a corrective control is steering. Multiple textual terminal assistants in one run must all remain visible inside that answer in emission order; non-terminal progress cannot overwrite them or manufacture another answer row. Runtime-authored corrections raised during tools use steer, not followUp, or they can arrive after completion and manufacture a redundant closing reply. Guarded by src/lib/server/agent/core/runtimeBudget.test.ts, the multi-terminal and aborted-turn cases in src/lib/server/app/conversationProjection.test.ts, the Runner notice-queue contract in src/lib/server/agent/core/runner.test.ts, and the interrupted-turn structural guard in apps/desktop/src/chat-ui.test.mjs.

15. Enabled sandbox must fail closed

unsupported platforms, missing dependencies, and provider initialization errors must never return the original Bash command or inherit the host environment. The shared Agent/Subagent sandbox boundary either produces a sandboxed command or a structured blocked result. Host execution is legal only after explicit sandbox disable or through the Host Bash approval path. Persisted legacy warn-disable values must normalize to block; guard provider-unavailable execution and settings round-trip behavior with machine tests.

16. A resizable column is not the viewport, and a flexible track needs a floor

(one root cause, three shipped symptoms: header title overlapping the action buttons, composer controls spilling past the pane edge, transcript crushed to a sliver when the file panel opened). Two rules: (a) inside any pane whose width is set by a sibling panel — Chat's transcript column, Project Chat, the file panel — never size with vw/vh. max-width: 46vw on the header title and clamp(20px, 5vw, 56px) gutters on .messages/.composer-wrap kept their full-window value after the file panel narrowed the column. Use column-relative % (padding and max-width percentages resolve against the containing block) and let flex do the truncating: the title block takes the slack (flex: 1 1 auto, min-width: 0) while the action row keeps its natural width. (b) a multi-panel grid needs minmax(floor, preferred) on the panels and minmax(--chat-min-w, 1fr) on the content, so the panels give width back first; when the floors stop fitting, drop the SIDEBAR and keep every remaining panel in flow. Never resolve a narrow layout by making a panel position: fixed over the content: the overlay needs a z-index, that z-index makes the panel a stacking context, and the panel's own head (z-index: 31, deliberately above the z-index: 30 window drag mask) gets trapped under the mask — its close/refresh buttons go dead with nothing in the console — while the content pane's action row, still laid out full-width underneath, paints straight through the panel. Any absolutely positioned resize handle is driven by the stored width, so the stored value must be clamped to the same budget or the handle drifts off the track edge it drags. Only one media tier may own a given with-files split; a second full re-declaration in a lower tier fought the first and left an empty panel track. (c) A flex item's size can silently collapse to zero, and a flex chain breaks at the first item that won't shrink (issue #28 & Memory advanced modal, recurring symptom): container-type: inline-size applies inline-size containment, which sizes the box as if it had no contents — on a content-sized flex item that is 0 width, and with overflow: hidden the control's text disappears with nothing in the console (this erased the composer's model name). Only make a query container out of a box whose inline size comes from outside its contents. Conversely, a scrollable modal body needs every ancestor between the overflow: hidden dialog and the body to pass the height budget down (display: flex; flex-direction: column; min-height: 0); one plain <form> wrapper or .modal-body without min-height: 0 retains its automatic minimum content size, refuses to shrink, and the body silently never scrolls (Project settings and Memory advanced modal clipped their tails). In WebKit (macOS WKWebView), scroll containers must also be display: flex; flex-direction: column; overflow-y: auto; rather than display: grid; align-content: start; (which swallows mouse-wheel events), and dialogs must set an explicit height: min(...) budget. Enforced in the shared .modal-body rule with flex: 1 1 auto; min-height: 0; overflow-y: auto;. A list-owned editor must also portal explicitly to body; relying on an implicit portal target lets its placement regress back to the list's scroll position. Guarded by the floor / clamp / gutter / in-flow / single-tier assertions, the no-containment pill assertion, and the project-settings/editor-dialog/modal-body scroll and portal assertions in apps/desktop/src/chat-ui.test.mjs. Third variant of the family (task-session modal, 2026-09-12): a flex-column modal body plus margin: 0 auto on the row defeats align-items: stretch, so a width: auto row shrink-wraps to content and a scrollable pre (min-width: 0) inside it collapses into a one-character sliver — the assistant reply read as an empty bubble with a vertical line. Rows that must fill a flex-column transcript keep width: 100%; only rows that genuinely shrink-wrap (the user's own) may stay auto. Guarded by the task-session modal assertion in apps/desktop/src/chat-ui.test.mjs.

17. Theme a third-party widget through its own variables, and verify against its real markup

diff2html (and anything like it) ships a stylesheet with its own --d2h-* token set and multiple colour-scheme classes. Overriding a couple of its rules (.d2h-ins, .d2h-del) recolours only what you named and silently leaves the library's default palette on everything you didn't — hunk row, d2h-change, borders, gutter, stat chips — which is what reads as "half-restyled". Remap the library's variables instead, and cover every scheme set it defines (--d2h-* and --d2h-dark-*), since the renderer picks the class (diff2html defaults to light regardless of app theme). Equally important: a CSS probe written from hand-authored markup proves nothing about the cascade — it omits the wrapper classes, ancestor selectors and competing rules the library actually emits. Reproduce the vendor's real DOM (or render through the library) before claiming a restyle works. Guarded by the --d2h-* remap assertions in apps/desktop/src/chat-ui.test.mjs.

18. Window drag is an ACL problem before it is a CSS problem

(fixed 2+ times, symptom: "the window only drags from its edges" / "the title bar is dead"): both drag paths — Tauri's built-in data-tauri-drag-region handler and WindowDragMask's startDragging() — invoke plugin:window|start_dragging, and core:window:default does not grant it; the capability must list core:window:allow-start-dragging explicitly or every drag is denied at the IPC layer with no console output. Diagnose in that order: check src-tauri/capabilities/default.json first (a denied drag that still double-click-maximizes is the tell, since allow-internal-toggle-maximize is in the default set), and never let a startDragging() call swallow its rejection. Only then look at layering: the top 60px is a single .window-drag-mask (z-index: 30), and a container lifted above it so its buttons stay clickable (.header-actions: flex: 1 + z-index: 31) turns its empty area into an invisible drag blocker — lift such a container with pointer-events: none on itself plus pointer-events: auto on its direct children. Note the built-in handler only fires when the element carrying a bare data-tauri-drag-region is the direct event target (use deep for a subtree), so the attribute on elements underneath the mask does nothing. Capability edits need a Rust rebuild, not a WebView reload. Guarded by the top-chrome drag test in apps/desktop/src/chat-ui.test.mjs. The shared window header inherits the parent grid tracks with subgrid; a second sidebar-width calculation diverges at responsive breakpoints. Keep the title flex basis at zero and secondary labels shrinkable so action buttons receive their width first. chat-agent-header.spec.ts measures actual alignment and button/text bounds across narrow widths, sidebar states and Inspector splits.

19. Tool syntax in prose is never an answer, on either side of the call

(fixed 2+ times, same root-cause class): weak models on an @app turn write the invocation as text. That habit produces two failures with opposite gates, and fixing only one leaves the other shipping. (a) Before the call — nothing ran, the reply reads like a success, the user's task was silently dropped. Retry once with an execution-only runtime instruction; the zero-tool gate (!attemptExecutedTools) is what makes that retry safe. (b) After the call — the work happened and the model closed by writing the call out again (run tool miniapp__expense-tracker__add with amount is 20 ...), so the user sees internal syntax and cannot tell whether anything was saved. Here retrying is forbidden: it would replay the side effect. Repair in place instead, by recovering the reply from the last successful Mini App tool result — those results are already written for a human reader, which is what lets the runtime fix this without knowing the app's domain. Detection must differ per side: after a real call, naming a tool can be legitimate, so require invocation-shaped text rather than a bare id mention. Corollary: any runtime instruction about the shape of a Mini App reply is shared by every installed app — never name one app's fields (amount/category/date) in it, or it silently misdirects the next app. Guarded by src/lib/server/miniapps/toolCallIntent.test.ts and the pseudo-call recovery / domain-agnostic-instruction structural guards in src/lib/server/agent/core/runner.test.ts.

20. A UI reference marker is not a filesystem path, and prose is not a mutation receipt

(second occurrence in the Project @ feature): the composer may render a readable token such as @[file](path), but the shared Runtime must parse it, validate it against the registered Project root, and hand tools only the canonical path value — never the leading @, display label, or raw Markdown. Preserve the display syntax in the transcript and keep the validated mapping transient. Equally, git diff written by a model proves nothing: claims that a file was created, edited, or saved require a successful machine-owned write/edit receipt from that run; a failed tool or empty real diff must remain a failure. Guarded by src/lib/shared/projectFileReference.test.ts, src/lib/server/projects/fileReferences.test.ts, and src/lib/server/agent/core/runnerRetryState.test.ts.

21. A liveness probe must exercise the real runtime, and a supervisor must never close its own control channel

(root cause of "service 503 behind a green light" + "crashed, had to restart the whole app" + "upgrade left {workspace}/miniapps uncreated"): (a) /api/desktop/handshake and /health are static literals — they answer 200 from a process whose getRuntime() throws on every request, and because getRuntime() caches nothing on failure the 503 is permanent. Any "is it alive" check the supervisor trusts must call a route that actually builds the runtime (/api/desktop/health?deep=1), gated behind a handshake capability so an older adopted build answering 404 is not read as unhealthy. (b) Adoption without version identity runs the old build's process after an upgrade, so none of the update's migrations or new directories run — compare the running version against the bundled molibot-runtime.version and replace on mismatch. (c) A guard winds a run down; a supervisor must not give up: returning after N failures drops the Receiver, and every later request_restart's let _ = send() then fails silently — the "Restart service" button goes dead with nothing in any console. Report Error but keep retrying with capped backoff, and park on the channel (never return) after an unrecoverable setup failure. (d) Mini App / Pi 扩展必须保持进程故障域:第三方运行模块不得重新动态 import() 回服务进程;Mini App 维持每 App 子进程,Pi 扩展维持独立扩展进程,调用必须带 deadline、abort 与进程组清理。主进程 crash handler 只负责服务自身故障报告,不得再被当作第三方隔离方案。(e) Directory bootstrap is data-driven with per-dir try/catch; a test asserts every *Dir in storagePaths is in the bootstrap list. Guarded by the supervisor Rust suite (should_replace_adopted, health classification, backoff cap, supports_deep_health), scripts/runtime/crash-report.test.mjs, src/lib/server/infra/db/storage.test.ts, src/lib/server/app/runtimeHealth.test.ts, Mini App process-isolation tests, and Pi extension load tests.

**Mini App 安装链补充守卫(pitfall 21(d)):**候选包的 validate / smoke / install 同样属于第三方进程故障域,不得通过测试 seam 把候选模块动态 import() 回服务进程。机器守卫必须从 Agent miniAppManage 入口注入 process.exit();只测已安装 App Host 不算覆盖安装链。

22. A hand-written packaging manifest drifts from the import graph, and diagnostics must never be a boot dependency

(second occurrence: bin/molibot-release.sh first omitted skills/, then omitted scripts/runtime/crash-report.mjs + file-logger.mjs, which made v2.9.0 die at boot with ERR_MODULE_NOT_FOUND and restart-loop forever — issue #30). The release script copies runtime modules by glob (scripts/runtime/*.mjs, minus *.test.mjs), never file-by-file: adding a module next to start-server.mjs must not require remembering a second edit in a shell script no test exercises. Equally, a module whose only job is observability (crash reports, file logging) is loaded through loadOptionalRuntimeModule() and used with ?. — a missing diagnostic degrades to one stderr line, it does not prevent the service from starting, because the supervisor's restart loop turns any startup-fatal error into a permanently dead app. Guarded by scripts/runtime/release-bundle.test.mjs, which resolves every relative *.mjs specifier reachable from scripts/start-server.mjs and asserts each one is inside the globbed directory.

23. Liveness is ownership, a lock is not a status, and a guard must never have a silent give-up branch

(root cause of "两个任务一直转圈,而且以后再也不执行了"): (a) age is not evidence that something is alive. recoverStaleRunning reclaimed only leases older than their timeout, but after a restart no running lease can still have an in-process owner — a crash inside the timeout window (38s into a 10min budget, in production) left it running forever, which pinned the UI and made hasActiveForTask suppress every future run of that task. Leases carry an owner_id; anything owned by another process identity is reclaimed as interrupted. (b) a recovery must not count the thing it is recovering — hasActiveForTask saw the very retry_wait lease being taken over and skipped it as task_already_running, so pass excludeLeaseId; equally a guard keyed on runId (releasePeriodicRunLock) rejects the recovery path by construction, because the file holds the crashed attempt's runId while the caller carries a fresh one — key on the slot. (c) every branch that inspects a stuck record must leave it in a terminal state. resumeRecoveredLease returned false when the latest lease was a skipped bookkeeping row, repairing nothing; a skipped row records that a dispatch was declined, never the fate of an attempt, so outcome lookups must exclude it and no path may quietly do nothing. (d) a run lock cannot report a result: a periodic event file's status is written back to pending on success and left at running after a crash, so any UI reading it shows finished and dead tasks identically. Derive the displayed state once, in the shared projection (resolveDesktopTaskStatus), from the execution record; "running" means an active lease, not a file field. (e) automatic replay of a side-effecting task needs a time bound — catch up only within a window, otherwise report the interruption and wait for the next slot. (f) writing a log line must never be able to end the process: a closed supervisor stdout makes console.log throw EPIPE synchronously, and a fire-and-forget render promise (void flushQueuedRender()) turns one network blip into an unhandled rejection — both killed a healthy runtime mid-run. Guarded by src/lib/server/agent/eventsLeaseStore.test.ts, src/lib/server/agent/events.test.ts, src/lib/server/app/desktopTasks.test.ts, scripts/runtime/crash-report.test.mjs, and the liveness/single-surface assertions in apps/desktop/src/chat-ui.test.mjs.

24. A design system that exists only in a doc is not a system, and an inline pill's two axes have different budgets

(root cause of "字体不和谐,同一个区域有的大有的小,说不出哪里不好"): (a) a rule that says "use the tokens" needs the tokens to exist. DESIGN.md §Typography defines a full type scale and says in as many words to apply it instead of hand-setting sizes — but styles.css never declared a single size or line-height variable (it had them for radii, durations, tracking and every colour), so 583 hand-written font-size: Npx drifted into 17 sizes and 21 line-heights with no error anywhere. Type is now --fs-*/--lh-* declared in pairs; setting a size without its line-height is how a rank drifts back apart. (b) an icon size is not a text size. Phosphor renders glyphs at font-size, so 51 icon rules sat inside the text scale and "13px" meant two unrelated things — icons live in --icon-xs/sm/md/lg. (c) one number doing two semantic jobs is the drift. 12px was both label (the model selector) and meta (the transcript divider), which is what makes same-rank neighbours look randomly sized; Chat carries exactly three text ranks (body 14/22, label 13/18, meta 11/16) and 12px is gone from it. The tell that a surface never had a scale: the session title was 11px while its own subtitle was 12px. (d) an element that sets no font-size is not neutral — with nothing declaring a document size it inherits the UA's 16px, a rank the design does not contain; anchor the surface root on a real rank. (e) the composer pill is an overlay mirroring the textarea character for character, so no inset may change the advance — but vertical and horizontal are not symmetric. An inline box's vertical padding never enters the line box (CSS 2.1 §10.6.1), so it is free and needs no cancelling margin; the old rule spent 1px there and 5px horizontally, exactly backwards. Horizontal must be cancelled by an equal negative margin and the real budget is the inter-word space (3.8px at 14px) — 5px was painted under the following glyph, which reads as "no right padding" even though it is painting. Any change here is proven by measuring glyph-advance drift in a live render, never by eye. Guarded by the type-scale, no-raw-px, and pill two-axis assertions in apps/desktop/src/chat-ui.test.mjs.

25. The desktop WebView is never same-origin with the service, and there is more than one WebView origin

(fixed 2+ times, symptom: Cross-site POST form submissions are forbidden when sending an attachment): any multipart/form-data POST from the desktop app (/api/chat with files) is a cross-site form submission to SvelteKit's CSRF check. The first fix trusted only the origin in front of it at the time — tauri://localhost, the packaged custom protocol — leaving pnpm desktop:dev's http://127.0.0.1:1420 (the Tauri dev server in apps/desktop/vite.config.ts) rejected, so the identical failure shipped again in dev. Enumerate every WebView origin in one shared place (scripts/runtime/csrf-trusted-origins.mjs: packaged protocol, both host spellings of the dev port, $TAURI_DEV_HOST) and let svelte.config.js only wire it in; never add origins ad hoc at the call site. Third occurrence, and the one the first two hid (symptom: attachment upload fails from the plain Web UI, works in the packaged app): the server's idea of its own origin is also a scheme. adapter-node's get_origin defaults the protocol to https when neither ORIGIN nor PROTOCOL_HEADER is set, so a service listening on plain HTTP computes https://127.0.0.1:<port> for itself and rejects http://localhost:3000 — a legitimate same-origin request — as cross-site. Adding a trusted origin cannot fix this and would be the wrong instinct twice over: the origin was never untrusted, the server was wrong about itself. start-server.mjs declares the real origin through resolveServiceOrigin() (loopback binds only; never when the operator set ORIGIN or PROTOCOL_HEADER). Diagnose in this order: curl the endpoint with -H "origin: <the real one>" and with tauri://localhost; if the trusted origin passes and the real one fails, the problem is the server's self-origin, not the list. Do NOT try to gate the list on NODE_ENV — svelte.config.js is loaded by the sveltekit() plugin factory before Vite resolves the mode, so the flag is unreliable there; the loopback/tauri:// origins are unforgeable by a remote page, so trusting them unconditionally keeps the web deployment's CSRF protection intact. The config only takes effect through a server build, so a dev repro needs a restart, not a WebView reload. Guarded by scripts/runtime/csrf-trusted-origins.test.mjs (in npm run test:service-bootstrap).

26. An unrecognized inbound message type must never become the user's text, and a tool must validate before it produces a side effect

(root cause of "配了图片解析模型,AI 却编造了图片内容,还乱调 skill、往群里发了张不相干的图"): (a) a switch over platform message types needs a labelled, logged default. Feishu parseFeishuContent handled text/image/audio|media|video/file and let everything else fall through to String(payload.text ?? message.content) — so a post (rich text = image + text, the normal way a person sends a picture with a question) turned into the raw event JSON as the user's message, with the image_key nested at content[0][0] never downloaded. (b) a capability gated on a populated array fails silent when the array is empty for the wrong reason. Intake must preserve every saved image as both an attachment and normalized imageContents; any "no image" branch needs the intake side to warn when it drops input on the floor. (c) one message carries N attachments, not one. A single-fileKey parse shape cannot represent a multi-image post; model it as resources[] from the start. And when a container type mixes element kinds (post holds img, media and file), never infer the resource kind from the message type — only the element's own tag is evidence. (d) a side effect before validation is worse than a crash. imageGenerate declared prompt: Type.String() but nothing enforced it at runtime; a model with no image reached for the only image-shaped tool, passed an object, String() produced the truthy "[object Object]", and the tool generated an unrelated image and auto-uploaded it into the user's chat, which the model then described as if it were the user's picture. Validate every param the schema promises before the first irreversible step, and make the error tell the model what the tool actually does. Guarded by the post-parsing, JSON-leak, multi-image and unsupported-type cases in src/lib/server/channels/feishu/message-intake.test.ts and the non-string-prompt zero-side-effect case in src/lib/server/agent/imageGenerate/imageGenerateTool.test.ts. Second occurrence, Web/desktop intake (symptom: 上传截图后模型说「没有 OCR 工具」): (e) File.type is not evidence. Classify through the shared resolveWebInboundFileMeta() (declared MIME, else extension) and derive image content from the saved attachment through imageContentFromSavedAttachment(), the same boundary used by Channel intakes. (f) image recognition is a repeatable file read, not an intake transform or a second public tool. Preserve original images. A vision-capable active model receives them natively; a text-only model uses read(path, prompt), which invokes the ordered image-recognition engines and may be called repeatedly with different prompts. Channel code must never select providers, call recognition APIs, or splice one-time recognition text into the user message. Guarded by runnerInputEnricher.test.ts, read.test.ts, imageRecognition.test.ts, Channel attachment tests, and the image-read instruction guard in runner.test.ts.

27. A third-party payload's size is not the third party's decision, and compaction cannot shrink what it must keep

(root cause of a provider 400 reporting ~2.88M tokens of input against a 1M-token endpoint — ~11 MB arriving in ONE tool step, not gradual growth): (a) every path that puts foreign bytes into the context needs the shared budget. read and bash truncate to DEFAULT_MAX_BYTES/DEFAULT_MAX_LINES and spill the rest to disk, but MCP results were inlined verbatim — item.text, resource.text and structuredContent (pretty-printed, so larger than the wire payload). An MCP server is code we do not control; the cap goes on our side of the boundary (capMcpToolContent), shared across all text parts of one result so splitting a payload into 50 parts is bounded exactly like one blob, and image parts pass through (dropping them silently kills a vision turn). (b) compaction always keeps the newest message, so a single oversized message is unrecoverable without content-level truncation. findFirstKeptIndex seeds the kept slice with the last message unconditionally — it must, or a toolCall/toolResult pair is severed — so when one message exceeds the whole window, every compaction returns changed: false or stays oversized, the post-overflow retry gives up, and the session is permanently dead: the message is inherited by every later turn. capOversizedMessages rewrites it, and because appendCompaction persists the compacted list, the blob leaves the live context for good. Never truncate a toolCall block's arguments (the matching toolResult is validated against them) or an image block. (c) truncateHead/truncateTail never split a line, so a one-line payload truncates to nothing at all — minified JSON is exactly this shape, and the failure is silent: the model gets an empty result and hallucinates around it. Any cap over foreign text needs the byte-safe sliceToBytes fallback (step back over UTF-8 continuation bytes, or a cut character decodes to U+FFFD). (d) a token budget converted to bytes must hold for CJK: 2 bytes/token stays under the estimator's real cost for both a 3-byte Han character (1 token) and ASCII (4 chars/token) — see pitfall 8. (e) diagnose in this order, because the two look identical from the error: compare the reported input size against one turn's worth of content first; gradual growth is a threshold/contextWindow problem, a step change is an uncapped tool result. Note the model's contextWindow being unset falls back to compaction.defaultContextWindow (200k), and setting it to a routed gateway's advertised 1M makes the threshold 750k. Guarded by the oversized-single-message / no-history / tool-call-untouched / CJK-budget cases in src/lib/server/agent/session/compaction.test.ts and the shared-budget / single-line / image-survival / spill round-trip cases in src/lib/server/agent/tools/mcp.test.ts.

28. A keyed {#each} is a uniqueness assertion, a CSP directive is per-resource-type, and a dotfile is not its own extension

(root cause of the four Artifact Panel preview bugs in issue #31 - CSV blank, images blank, .gitignore a system card, Markdown source without line numbers): (a) Svelte 5 throws each_key_duplicate in production, not only dev. CsvTable keyed its rows by row.join("\0"), its cells by cell, and its headers by header - so a row of yes,yes,yes,yes, two identical rows, or a repeated column name threw during render and left the tab blank. A key is a claim that the value is unique across the iterable; for a static list (CSV, JSON tree, log lines) the only guaranteed-unique key is the index, and index keys are safe there because appending adds new indices and a reload updates each index in place. Reserve value/compound keys for lists that reorder. The dev-mode warning is not the prod failure: svelte/src/internal/client/errors.js's else branch throws unconditionally. (b) <img src> and <video src> answer to different CSP directives, and tauriFetch answers to neither. fetchFromDesktop uses the Tauri HTTP plugin (Rust-level, allow-listed in capabilities/default.json), so JSON/blob fetches reach the service; but <img src={httpServiceUrl}> is a WebView resource load governed by app.security.csp's img-src. media-src had http://127.0.0.1:* so video/audio streamed, img-src did not so images were blocked - and only images were reported broken. When the service streams a resource type over loopback, every CSP directive that governs that element (img-src, media-src, frame-src, connect-src) must list the loopback origin, or the silent asymmetry reads as "video works, images don't". The CSP is baked into the Tauri build, so a CSP fix needs a Rust rebuild, not a WebView reload (pitfall 18). (c) extensionOf(".gitignore") returns .gitignore, not "". lastIndexOf(".") finds the leading dot, so a dotfile with no real extension is treated as having the whole filename as its extension, matches no set, and classifyFilePreview returns "binary" -> matchViewer routes to "system" -> the panel shows a system-open card instead of the file (the server already read it as text via detectTextEncoding; the bug is purely client-side classification). Maintain an explicit TEXT_DOTFILES set (.gitignore/.gitattributes/.dockerignore/.editorconfig/.npmrc/...) rather than a heuristic - .DS_Store is a dotfile too and must stay binary. (d) a "source view" that diverges from the code viewer is a consistency bug, not a styling choice. Markdown/CSV/SVG each had a bespoke bare <pre> for their source/raw view with no line numbers; reusing the shared CodeViewer is the only way to keep them aligned with every other text file as the viewer gains features (find, wrap, load-more). Guarded by the CsvTable index-key / no-raw-NUL / source-view-CodeViewer / name-prop / CSP img-src loopback assertions in apps/desktop/src/chat-ui.test.mjs, .gitignore -> code in apps/desktop/src/lib/artifacts/viewerRegistry.test.ts, and the dotfile cases in src/lib/shared/filePreview.test.ts.

29. A context reset may only clear what belongs to the context, and one concept may have only one host branch

(root cause of "打开小程序后切换 Session 小程序没了 / 会话里切到文件面板是空的" — two symptoms, one seam): (a) connect()-style resets are scoped, not total. ArtifactTabsStore.connect() cleared every tab whenever endpoint/project/profile/session changed, which destroyed a running Mini App's iframe along with the file tabs that genuinely belonged to the old session. Before clearing on a context change, ask of each piece of state whether the context owns it; a workspace-like surface (a Mini App, a scratch draft, a pinned panel) outlives the conversation it was opened beside. Keep the {#each} key stable when carrying such state over, or the DOM — and an iframe's document — is torn down anyway. (b) two mutually exclusive host branches for the same concept guarantee one of them is unreachable. The session artifact list lived in a ChatView aside rendered instead of the Artifact Panel (sessionFilesAsideVisible = … && !inspector?.miniApp), so opening a Mini App made the list unreachable and the panel's own Files side was empty by construction — the empty state read as a rendering bug when it was a routing one. A surface belongs inside the component that owns its slot (pitfall 7); if a host has to choose between two components for one slot, the second is a fork. (c) conversation ownership and artifact ownership are separate axes. A Project conversation may produce a Session-owned scratch file; opening it must preserve the selected artifact's scope while project/profile/session identities continue to come from the live pane. Re-deriving file scope from projectPaneActive routes generated images back through the Project tree and makes their Session viewer unreachable. Visibility and the selected artifact scope must agree; only live identities should be refreshed. (d) a hard-coded identity in a shared loader (listDesktopSessionFiles(endpoint, "personal", …)) returns an empty list with no error for anything owned by another profile — indistinguishable from "no files". Take the identity from the host. (e) a generated Session file can have two valid identities. imageGenerate reports a scratch path such as mono_color_poster.png, while automatic upload persists an attachment path such as attachments/<timestamp>_mono_color_poster.png plus the authorized fileId. Reconcile the receipt with its attachment before filename de-duplication; discarding the attachment without copying its locator leaves a visible row that can never open. Guarded by the exact generated-image fixture in apps/desktop/src/lib/chat/turnFiles.test.ts and the connect() keeps-Mini-Apps / mode-preservation, Session artifact-list surface, Project-hosted Session artifact scope, single-inspector and collapse-selector assertions in apps/desktop/src/chat-ui.test.mjs.

30. A lease held by the launcher is not single-instance, and an isolation variable that only covers part of the tree isolates nothing

(root cause of "微信里一个问题收到 5 条回复,而且这些 Session 我在 App 里根本找不到"): five node build/index.js processes left over from smoke and upgrade-probe runs long-polled the production WeChat bot for twelve days — no listening port, no lease, no UI trace, visible only in ps. (a) ownership belongs to the runtime, not the wrapper. acquireServiceLease() lived only in scripts/start-server.mjs, so a process started one level below it skipped the lease, the signal handlers and the forced process.exit(0) all at once; its long-poll loop then kept the event loop alive forever. Assert ownership where the side effect starts (ensureServiceOwnership() in applyChannelPlugins), adopt the launcher's lease when its published MOLIBOT_SERVICE_OWNER_ID matches the lock, and fail closed on conflict and on a lock that cannot be read — an unevaluable lock is not evidence of ownership. Gate every channel through one shared list-instances call with the exemption declared by the plugin (requiresServiceOwnership, defaulting to required so a third-party channel cannot opt out by omission), and drive teardown by returning an empty instance list into the existing reconcile loop rather than writing a second shutdown path (pitfall 7). Acquiring once is not enough: a 30s watchdog re-reads the lock, because a /tmp data dir swept by the OS or a takeover silently revokes it. Same family as pitfall 23 — liveness is ownership. (b) DATA_DIR isolation is a claim about the whole tree, and dotenv erases the layer that would falsify it. DB_DIR resolved independently of DATA_DIR, and the repo .env pinned it to ~/.molibot/db; dotenv merges that into process.env before anything resolves a path, so DATA_DIR=/tmp/smoke sent sessions and workspaces to /tmp while settings.sqlite — and the live bot token in it — was opened read-write on the owner's real data directory. The three configuration layers are not interchangeable: OS env (explicit), cwd .env (repository-wide, says nothing about this run), <dataDir>/.env (in scope by construction). An override present only in the cwd layer is dropped when DATA_DIR comes from the OS layer, and a non-default DATA_DIR whose data still escapes it refuses to boot. Snapshot Object.keys(process.env) before dotenv.config() — after it, the layer is unrecoverable. And the snapshot has to be taken in the process's first module, not merely in the module that uses it: env.ts snapshotted correctly and was still wrong, because scripts/start-server.mjs had already merged the repository .env one level up (it must — it needs DATA_DIR and the port before it can take the lease). A launcher that merges an env layer owns the snapshot and hands it over (MOLIBOT_OS_ENV_KEYS); a malformed hand-off falls back to the local snapshot, never to an empty layer-0, which would look like "everything came from the repository" and silently drop the operator's real overrides. The tell that the layering is dead: a scoped DATA_DIR run refuses to start citing an override the caller never set. Guarded by the hand-off and source-order cases in src/lib/server/app/dataDirScope.test.ts. (c) ownership is the wrong question for a deliberate throwaway run: an eval or smoke instance seeded from a real data directory carries real bot tokens and legitimately owns its own temp directory, so the ownership gate correctly says yes. MOLIBOT_DISABLE_EXTERNAL_CHANNELS=1 outranks ownership for every plugin that has not declared requiresServiceOwnership: false, and drives teardown through the existing reconcile loop. Any harness that copies settings must set it and assert it, never assume it. (c) the tell that two instances are sharing one identity is a sequence number that is continuous across supposedly isolated stores: queueId ran 1248→1266 straight through three different DATA_DIRs. Diagnose a duplicate-reply report by asking which store issued the ids before asking which process replied. Guarded by src/lib/server/app/dataDirScope.test.ts and src/lib/server/app/serviceOwnership.test.ts (in npm run test:service-bootstrap).

31. A targeted operation cannot be judged by an aggregate health count, and an explicit retry cannot inherit background error swallowing

(second MCP lifecycle failure after Issue #25, symptom: “MCP loaded/reconnected” but the requested server is still unavailable until restart): (a) loadMcp added server B to the Session selection, refreshed all selected servers, then treated connectedCount > 0 as success — healthy server A masked failed target B. Any command naming an id must inspect the result for that exact id in the same workspace/scope; totals are display data, never mutation receipts. (b) Background reconcile is intentionally failure-tolerant so saving settings does not fail because an external service is down. Explicit Reconnect is different: after forcing a new client it must require the target state to be connected and propagate its already-redacted error. Do not reuse a swallowed background outcome as an operator-action success. Guarded by the explicit reconnect failure case in src/lib/server/agent/tools/mcp.test.ts and the multi-server target failure case in src/lib/server/agent/tools/loadMcp.test.ts.

32. An interactive callback return is an acknowledgement path, not a durable user-visible receipt

(third occurrence after Feishu Host Bash approval and memory review, symptom: Stop/Steer executed but the card appeared unchanged): returning a replacement card from a Feishu HTTP/WebSocket callback can complete the runtime action while the client never applies that visual replacement. Every state-changing Feishu button follows one two-phase delivery contract: return a button-free processing card within the callback window, execute the action exactly once, then update the original message by open_message_id to an explicit success/stale/failure card. If that update fails, send the same human-readable result as a text receipt. Log both HTTP and WebSocket callback arrival plus the final edit/fallback outcome so “callback absent”, “action failed”, and “receipt failed” are distinguishable. Guarded by the queued-control processing/update and update-fallback cases in src/lib/server/channels/feishu/runtime.test.ts.

33. A queue accepted by a nested runtime is not durable across an outer retry

(root cause of “Steer 回执成功,但最终回答没有响应插入消息”): pi Agent drains its steering queue into the failed attempt's transcript before a model call; MomRunner then rolls that transcript back on a whole-attempt retry, but the inner queue is already empty. The next attempt therefore silently loses an action that the shared runtime already acknowledged. Any externally accepted live control must have one outer-run-owned, runtime-only replay record: on rollback clear the inner queue first, then restore the accepted controls exactly once and in order; on run completion clear both. Never repair this by persisting the injected control as an ordinary Session message. Guarded by the three-attempt timeout/Steer regression in src/lib/server/agent/core/runner.test.ts.

34. <dataDir>/runtime and <dataDir>/tooling look like the same thing and must never be merged

(a prevented bug, recorded because the merge is an appealing tidy-up that will be proposed again): they are both "machine-generated, not user data", and every other property is opposite. runtime/ is supervisor-owned, 0700, and replaced per version (supervisor.rs: "Runtime generations are immutable"); it holds service.lock, the state file the supervisor reads, crash reports, and the running service's own code. tooling/ is the Agent's working directory — wrapCommandWithVenv puts it on the Agent's PATH and points TMPDIR, PIP_CACHE_DIR and UV_CACHE_DIR at it — so a Skill may create and delete anything inside it, and it must survive upgrades (a 900 MB venv is not re-installable per release). Nesting them puts the live service one rm -rf "$TMPDIR/../.." away from a Skill, in the opposite direction to pitfalls 6 and 15. Guarded by the disjoint-trees assertion in src/lib/server/infra/db/storage.test.ts. Two corollaries from the same clean-up: (a) an immutable-generation scheme needs a reaper, or it is just a leak with good comments — the supervisor kept every desktop-runtime-<version> ever extracted (a v2.6.3 tree on a v2.9.12 install); keep current + 1, because an adopted sidecar from the previous build may still lazy-load its chunks, and always delete marker-less desktop-runtime-<uuid> staging leftovers. (b) isolation that is conditional on an env var is not the default behaviour: GOPATH/GOCACHE were only redirected when MOLIBOT_TOOLING_DIR was set, so the default install let go install write into the owner's ~/go — the one thing the directory exists to prevent.

35. Transcript existence, future context, conversation search, and memory eligibility are four different facts

(second occurrence after namespace isolation, symptom: “不要记住/仅本轮”的内容换 Session 后仍被 conversation_search 找回): never infer one from another and never implement privacy wording as a prompt-only instruction. Persist one shared turn-retention policy on both UI metadata and Agent entries, and make every projection consult it: context rebuild, incremental/reconciled search, automatic flush, reflection, trace/skill extraction, and explicit memory writes. Stronger privacy implies weaker restrictions (turn_only ⇒ no future context/search/memory; not_searchable ⇒ no search/memory), while delete remains a target-specific operation with tombstones. Durable personal facts default to owner: (or project:), not the channel-shaped chat: namespace. Guarded by retentionPolicy.test.ts, session.test.ts, store.test.ts, reflection.test.ts, memory.test.ts, and moryCore.plan.test.ts.

36. A file write is not a document-delivery receipt, and an explicit delivery mode cannot change meaning by task type or channel

(root cause of two P1 acceptance failures): (a) DOCX/XLSX/PDF generation may only report success after reading the bytes back from disk with an independent parser and checking requested text, sheet names, and typed cells; write to a sibling temporary file and rename only after verification, so a corrupt artifact never replaces the destination or reaches the channel. (b) delivery=text means direct notification for one-shot, periodic, and manual triggers alike. Do not repeat type-specific conditions in Channels; the decision belongs to the shared event layer. (c) a missing/offline transport must reject so the execution lease records failure—returning from a no-bot branch silently converts “not sent” into completed. (d) short-missed one-shots use the stable trigger slot and catch-up window; completed file/lease state must suppress a second send. Guarded by documentExport.test.ts, events.test.ts, and the Telegram/Feishu offline cases.

37. A message we author into the Agent Context must satisfy the provider SDK's contract, not just ours

(root cause of "所有模型都报 Cannot read properties of undefined (reading 'totalTokens')"): the failure looks exactly like a provider outage — every candidate in the fallback chain fails instantly with the same type=request_error, across different providers and base URLs — but no HTTP request is ever sent. pi-ai sizes each request on a path shared by every API (buildBaseOptions → clampMaxTokensToContext → estimateContextTokens → calculateContextTokens(assistant.usage)) and dereferences usage.totalTokens with no null guard; pi-agent-core's copy of the same function does guard, so the discrepancy is invisible in review. appendDirectEventContextMessage persists a delivery=text automation (a fired reminder) as an assistant message that never came from a provider, so it had no usage — and because that message is re-read on every later turn, the Session is permanently dead, not just that turn (same shape as pitfall 27(b)). Three rules: (a) any runtime-authored assistant message carries zeroAssistantUsage(); (b) fixing the write site is not enough — history already on disk cannot heal itself, so prepareMessagesForModelContext (the single funnel into agent.state.messages) normalizes on the way in; (c) a guard for a vendor contract must exercise the vendor module, not a local re-implementation of its logic — the test imports @earendil-works/pi-ai/api/simple-options and asserts both the throw on the unrepaired message and the number on the prepared one, so a pi bump that changes the contract fails in the suite. Diagnose in this order: identical failures across unrelated providers with zero latency and no serviceExit means the throw is on our side of the dispatch boundary, not the network (contrast pitfall: a fixed ~300s fetch failed is an Undici transport timeout). Guarded by the pi-ai seam and normalization cases in src/lib/server/agent/core/runnerHelpers.test.ts and the persisted-usage assertion in src/lib/server/channels/shared/directEventPersistence.test.ts.

38. A transcript is not the horizontal-scroll owner for its children

(second message-width failure in the pitfall 16 family): max-width: 100% on tables and code is insufficient when one later renderer (for example KaTeX's non-wrapping display box) exposes a larger intrinsic width and .messages still uses overflow: auto; the entire Chat pane then gains a horizontal scrollbar even though the 720px message row itself looks bounded. Keep .messages vertical-only, carry min-width: 0 through every message flex/grid item, wrap prose/paths/inline code, and give each intentionally non-wrapping renderer its own max-width: 100%; overflow-x: auto. Guarded by the shared long-message/content-owner assertions in apps/desktop/src/chat-ui.test.mjs and a browser measurement that compares transcript clientWidth with scrollWidth.

39. A persisted semantic reference must re-enter the same renderer as its input token

(second Skill-transcript regression after the full-SKILL.md leak): the Runtime intentionally rewrites /skill-name to [$skill-name](.../SKILL.md) so the Agent receives an authoritative file without inline Skill contents, but the transcript classifier recognized only the original slash token. The semantic reference then fell through to ordinary Markdown, exposed a local path, and could widen the message. Parse the canonical persisted form at the shared invocation-classification boundary, consume the whole reference before rendering remaining prose, and never infer Skill meaning from arbitrary Markdown links. Guarded by composerSuggestionCatalog.test.ts plus the transcript consumedLength structural assertion.

40. Catching a renderer error does not prove the renderer left the DOM unchanged

(Issue #32, symptom: clicking an answer outline near a malformed Mermaid diagram displaced the whole Desktop window until restart): Mermaid 11.16.1 renders through a temporary direct child of document.body; with its default error rendering, a parse failure replaces that child with a 2412×512 syntax-error SVG and throws before cleanup. Molibot's catch correctly showed a local source fallback and still left the foreign body child alive, so the visible failure state and the layout side effect disagreed. Every Mermaid entry point must set suppressErrorRendering: true and let Molibot own the user-facing fallback. For any third-party renderer that stages work outside the supplied component, the regression loop must inspect the external host before and after failure, not only assert what the component renders. Guarded across every Svelte Mermaid importer in apps/desktop/src/chat-ui.test.mjs and by a real-browser body-height/node probe.

41. Capability availability must never be gated on interpreting the user's message

(Session s-20260817-ztfk, symptom: 「使用 open-connector 查询…」点名了启用的 MCP server,prompt 的 <mcp-access> 也教模型用 loadMcp,但注册门控 hasExplicitMcpInvocation 只认字面 "mcp" token,工具从未注册,模型 toolSearch 落空后误报「环境缺 MCP 支持」): deciding whether a tool exists by guessing intent from natural language puts a fragile NLU matcher between what the prompt advertises and what the registry provides - two vocabularies that can never stay aligned, and the failure mode is "user explicitly asked, model physically cannot" (a correctness failure), not "model did something unnecessary" (a cost failure). The fix is not a bigger vocabulary (the first patch added server ids/names - same family): delete the gate. loadMcp/mcpInvoke and the <mcp-access> prompt section now derive from ONE predicate, hasConfiguredMcpServers(settings) (src/lib/server/settings/openConnector.ts) - configured servers (enabled or disabled) ⟹ controls always registered; zero configured ⟹ neither side appears. Whether to actually load a server is the model's call via loadMcp; prompt wording about when to bother is cost advice only, never the sole thing standing between availability and absence. Same family as pitfalls 25/31: two surfaces describing one capability must read from one source. Guarded by the prompt⟺registration invariant test (enabled/disabled/zero fixtures) plus the structural assertion in src/lib/server/agent/prompts/prompt.test.ts that runner.ts gates on hasConfiguredMcpServers(settings) and never references hasExplicitMcpInvocation.

42. A short-circuited && chain hides an undefined name until the rare branch runs, and a noisy tsc baseline hides the error that would have caught it

(symptom: 微信里触发模型降级后只收到 "Internal error.",真实回复被吞;session 与 run-detail 都显示成功): weixin/qq processEvent 的 onRunComplete 闭包里写了未定义的 scopeId,但它在 stopReason === "stop" && threadEventCount > 0 && runId && 之后才求值--threadEventCount > 0 目前唯一的来源是模型降级通知走 respondInThread,所以 ReferenceError 只在降级 run 的收尾抛出:runner 已正常返回、回答已生成并落盘,异常却在 channel 收尾阶段冒泡,触发 InboundTaskCoordinator 的兜底 catch 给用户补发 "Internal error.",缓冲区里的真实回复从未发出。两条教训:(a) 短路链后面的标识符、闭包里捕获的环境名,只有真实跑到那条分支才会炸--「平时能跑」对这类 bug 零证明力;跨渠道复制粘贴一段回调时,先确认每个自由变量在新作用域里真的存在(feishu/telegram 同款代码各自定义了 scopeId,只有 weixin/qq 漏了)。(b) tsc 全量其实一直在报这两个 TS2304: Cannot find name 'scopeId',但项目存量 400+ tsc 噪音把新错误淹没了--触碰文件的 tsc 检查必须看「新增」错误而不是总错误数,长期目标应是清零存量噪音。修复模式:把这类「谓词 + 上下文」的回调抽成共享 helper,让 scope 作为必填参数传入(createRunArchiveNoticeOnComplete(),channels/shared/runArchiveNotice.ts),类型与单测在构造时就锁死该分支。Guarded by src/lib/server/channels/shared/runArchiveNotice.test.ts(降级形态 + 四个反例)。

43. Approval wait is a suspend/resume lifecycle, never an inline timeout budget

(Session s-20260818-vtjv, symptom: 「已审批通过但仍卡在 Waiting for user approval: Aggregated approval request for mcp:OpenConnector...」): (a) waitForDecision blocking inline counts against enclosing tool timeouts. When mcpInvoke wraps an inner MCP tool, waiting 5 minutes inline for an approval card swallowed the outer 300s tool budget; the run was killed by timeout while the card remained pending. An approval arriving afterwards was resolved into the DB but had no active consumer to wake up. Tool approval waiting uses a short handshake window (BROKER_APPROVAL_INLINE_WINDOW_MS = 30s): within the window approval proceeds inline; past it the run terminates cleanly with waiting_for_approval and releases its lease/connection. The out-of-band resolve (Web /hosttools, Desktop resolve_approval, Channel /approve) rewrites the suspended toolResult in the persisted transcript and resumes execution via brokerApprovalResume.ts - the user can take 10 minutes or 10 days. (b) An aborted run must leave a terminal approval state. If an in-flight wait is aborted by Stop/timeout, expireRequest marks the pending broker request expired so stale cards do not promise resurrection. (c) Aggregated approval grants must match by capability, not batch fingerprint. Debounce batches create an aggregate fingerprint {fingerprints:[...]} that never matches single future calls; ApprovalBroker.resolveRequest creates capability+actor+scope grants for non-write tools so session/persistent approvals actually unlock subsequent turns. Guarded by toolRuntime.test.ts, approvalBroker.test.ts, and brokerApprovalResume.test.ts. Second occurrence of the family (issue #48, 2026-09-10) — three more mandatory rules. (d) A suspension must terminate the loop in every shape. The Host Bash wait's timeout result omitted terminate, and in a mixed batch a sibling tool's normal result diluted the one suspension (shouldTerminateToolBatch is all-and), so the loop kept making model rounds and each round raised a fresh approval; every waiting result goes through buildApprovalSuspensionResult (suspendedResult.ts, shared window constant included) and the runner installs a before-tool barrier + shouldStopAfterTurn so nothing — steering included — starts while a wait is open. (e) An empty finalText is not an empty response. The attempt loop's rollback machinery read a suspended attempt as "empty" and re-prompted it (duplicate cards, run row never waiting_for_approval, resume dead on arrival); the runner now parks on waiting_for_approval with the attempt intact, gated by a run-scoped suspension flag tied to the persisted request id. (f) A wait is accountable to a real request. Subagent end events carry the child's approvalRequestId; rejections/expiries report their real status; broker pending lists expire on a shared TTL; a Stop expires the pending Host Bash request; the Web/Desktop handler claims execution atomically. Guarded by approvalSuspension.test.ts (real runner + agent loop + broker on a temp data dir), bashApprovalWait.test.ts, and the controller/structural suites. Third occurrence of the family (unattended automations, 2026-09-12) — two more rules. (g) An unattended run must never park on a card. A scheduled event run has no human to answer: the pending request expired unread after the shared TTL and the run sat parked forever. On event runs (isEvent + fresh session), Host Bash denies approval-gated commands outright (unattendedDenials, inherited by subagent children) and the generic broker path honors the new "deny" disposition from onApprovalRequest (durable attempts keep defer); the tool call fails without terminate, so the model finishes and reports the skipped step instead of hanging. (h) The event lease must not translate a suspension into success. runLeasedEvent recorded any resolved run as completed, so a parked run reported 已完成 with no output; suspensions now settle the lease as waiting_approval (markWaitingApproval) while still releasing the periodic run-lock, and the desktop renders it as 等待审批. Guarded by bashApprovalWait.test.ts, toolRuntime.test.ts, eventsLeaseStore.test.ts, and events.test.ts.

44. A capability that crosses WebView → native transport → service is delivered only when every layer accepts the same domain

(second HTML Artifact failure, symptom: ordinary Session HTML shows {"error":"Invalid artifact request."} although the service route and token codec both support Session scope): adding session to the server route is insufficient when artifact_protocol.rs still rejects every scope except project; copying the Project token limit is also wrong because the Session identity is a base64url JSON payload and an ordinary token already exceeds 64 bytes. Whenever an Artifact/Mini App/custom-protocol scope is added, test the exact realistic URL at the native upstream_url seam as well as the service resolver, including scope-specific size limits and rejection of unknown scopes. Native protocol changes require a Rust rebuild and app restart; a WebView reload cannot verify them. Guarded by the realistic Session-token mapping and rejection cases in apps/desktop/src-tauri/src/artifact_protocol.rs.

45. reicon-svelte 的 barrel 不能进 Rollup 构建

(被拦截的构建失败,根因在已发布的包里):reicon-svelte@1.0.102 的 index.js 把 Icon 导出了两次(基类 Icon.svelte 与 icons/Icon.svelte),任何 from "reicon-svelte" 的 barrel 导入都会让 vite build 以 Duplicate export "Icon" 失败。规则:reicon 图标一律用子路径导入(reicon-svelte/icons/Check)——子路径组件只依赖 ../Icon.svelte,永远不会碰 barrel;reicon-react 无此冲突(Mini Apps 经 esbuild 打包)。生产构建会大声报错,不是静默失败,守卫就是 build 本身;若未来版本修复 barrel,子路径仍是唯一允许的写法。

46. 沙箱内访问 localhost 的失败可能不是服务没起,而是 NO_PROXY 绕过代理 + seatbelt 默认拒绝的死锁

(Session s-20260905-bqqq,症状:curl: (7) Failed to connect to localhost port 5040 after 0 ms: Couldn't connect to server,服务实际在线):@anthropic-ai/sandbox-runtime 无条件注入 NO_PROXY=localhost,127.0.0.1,::1,10/8,172.16/12,192.168/16,…,HTTP 客户端因此直连本机端口;而受限网络的 seatbelt profile 只放行它自己的过滤代理端口,直连一律默认拒绝。两个上游设计叠在一起,意味着 allowedDomains 里配 localhost/127.0.0.1 是安慰剂(那只配置代理的 Host 过滤,localhost 流量根本到不了代理),Auto 模式的网络全放行也一样救不了(放行回调也在代理层)。守卫:effective sandbox config 必须带 allowLocalBinding: true(buildEffectiveSandboxConfig,由 sandbox.test.ts 断言),它生成的 (allow network-outbound (local ip "localhost:*")) 系列规则让直连 loopback 放行。注意这个报错与服务真没启动的输出完全一致,沙箱内无法区分;判断服务是否在线要在宿主机上 lsof -iTCP:<port>。

47. 本地镜像状态不能靠各调用点「记得手动 sync」

(根因:会话切换后输入框内容丢失,2026-09-09):桌面聊天输入草稿过去靠每条切换路径在切换前后手动调 syncDraftOut/loadDraftIn——常规点击记得调,服务连接时的默认选座(selectDefaultSession,含侧边栏导航触发的 connect())不记得,loadDraftIn 直接覆盖 messageInput,未保存输入永久丢失。规则:本地状态是 store 的镜像时,由一个响应式块双向负责——key/身份变化时载入,其余任何变化即时写回(ChatView 草稿 $: 镜像块、ProjectChat sessionThinkingOverrides 连续镜像都是此模式);绝不把 sync 调用散落在「碰巧会改状态」的函数里,因为被漏掉的恰恰是最少走到的路径。共享的 key/身份规则提炼为纯函数(composerDraftKey),镜像块与 store 共用一份,避免两边对「当前在操作哪份数据」产生分歧。守卫:apps/desktop/src/chat-ui.test.mjs 结构性断言(镜像块存在、全文件禁止手动 sync 回潮、key 规则只在共享层定义)。

48. 材质层上的滤镜会连同面板直边一起扭曲,且 WebKit 对含 SVG 引用的 backdrop-filter 丢弃整条链

(根因:顶栏底边、底部输入卡与玻璃卡片边缘呈波浪线,2026-09-12):液态玻璃位移(feTurbulence+feDisplacementMap)经 filter: var(--liquid-glass-distortion) 套在材质 ::before 上——CSS filter 作用于图层自身渲染的全部内容,包括边缘,低频噪声把每条直边推成 ±12px 波浪。标准修法「位移进 backdrop-filter、只扭曲透过的背景」在 WKWebView 不可用:WebKit 解析通过(CSS.supports('backdrop-filter','blur(2px) url(#f)') 为 true,@supports 探测不出)但渲染时丢弃整条链,连 blur 一起失效(bugs.webkit.org/245510;离屏 WKWebView 四样张实测:组合链=完全无模糊、纯 blur=正常、元素层 filter=波浪、裸 url=无效果;Chromium 是唯一支持方)。规则:玻璃材质只由 tint+blur+edge+shadow 构成(DESIGN.md §Chat glass materials);「给玻璃加折射/扭曲」必须走向内缩进、边缘被 mask 的独立内层(直边由未滤镜层承担),不许 filter 材质层或在 backdrop-filter 里引用 SVG。排障通用化:改材质前先在与壳同引擎的离屏 WKWebView 截对照样张实测,CSS.supports/@supports 通过不等于渲染生效。守卫:apps/desktop/src/chat-ui.test.mjs 的玻璃材质/密度/降级/backdrop-root/turn-process 结构断言(本次问题正是改动违反这批既有守卫所致)。

49. 一个「文件路径」在每个消费者眼里必须是同一把尺子——尤其是带日期的产物目录

(复发症状:聊天里出现「创建 xxx.txt」的卡片,右侧文件面板点开却报「文件当前不可用」,而文件其实已落盘):write/edit/documentExport 把文件写进带日期的 artifact 目录(<scratch>/2026/09/14/),但 receipt 的 relativePath 是相对日期目录算的(只剩 plan.txt),而 /api/web/files 与客户端 matchesSessionOutputPath 都按 <scratch>/<path> 解析——路径少了日期段,于是永远打不开。根修:RunOutputLayout 增加 scratchBase(非项目=run cwd,项目=project.scratchDir),所有 scratch receipt 的 relativePath 由 outputReportBase 从 scratchBase 计算(日期段随之包含),写入与读取用同一把尺子;项目产物仍相对 projectRoot。凡新增「默认产物目录」或改动某工具的输出根,必须同时确认 receipt 路径与 Session 文件列表的解析根一致。守卫:src/lib/server/agent/tools/outputLayout.test.ts(dated-inclusive / project-relative / 无 scratchBase 回退)与 write.test.ts。

50. 发布包内容不允许第二份手写清单——凡是「启动时从 app 根暂存/加载」的随包内容,发布脚本必须与代码内的清单同源

(症状:内置插件 trace-viewer 本机可见、其它机器的新版本没有——builtinBootstrap.ts 的 BUILTIN_PACKAGES 与 bin/molibot-release.sh 的拷贝白名单各自维护,后者漏了 trace-viewer;bootstrap 对缺失源目录又静默 continue,于是发布包永远不带该插件且无任何日志)。规则:给 BUILTIN_PACKAGES(或同类随包清单)新增/删减条目时,发布脚本同步是同一笔改动的一部分;消费端对清单缺失必须告警而不是静默跳过。同类根因第二次出现(#30 v2.9.0 runtime 模块手写清单遗漏 → 启动崩溃),机器守卫现在是硬要求。守卫:scripts/runtime/release-bundle.test.mjs 的 BUILTIN_PACKAGES ↔ 发布脚本双向同步断言(回放旧清单会失败)。

51. 共享默认值的「同特异性 + 声明顺序」会无声反压变体,必须用零特异性默认根修,而不是让调用方逐个叠选择器

(根因:D2/Mermaid 放大弹窗、artifact lightbox、实体编辑器等一批弹窗的尺寸全被静默压回 default,2026-09-16 owner 反馈 D2 弹窗太小且无法放大):基础规则 .desktop-dialog-content / .desktop-dialog-overlay 与 contentClass/overlayClass 变体都是单类选择器(同特异性),而基础规则声明在 styles.css 更靠后,于是所有更早声明的变体都输给 source order——不报错、不警告,width 退回 560px 默认,只表现为「弹窗/面板尺寸怎么调都不生效」。规则:共享默认值若要允许变体覆盖,默认值必须低于单类特异性(本仓用 :where(.desktop-dialog-content) 造零特异性基础),不要用“变体记得再叠一层 .desktop-dialog-content”的调用方约定——这正是同一根因被逐个打补丁三次的来源(.entity-editor-dialog、.markdown-artifact-dialog 各打一次,第三次即本 pitfall)。排障通用化:变体(弹窗 / 面板尺寸、主题覆盖)不生效时,先查基础规则与变体的特异性 + 声明顺序,以 getComputedStyle 的最终值为准,而不是先怀疑变体本身。守卫:apps/desktop/src/chat-ui.test.mjs 断言基础规则为 :where(...) 零特异性、不存在带尺寸的裸 .desktop-dialog-content 规则、且恢复后的变体尺寸仍在。

52. 生产构建不能在 adapter 原子发布前删除活动 build/

(复发症状:服务进程已加载旧 server/manifest.js,之后第一次访问延迟加载路由时报 ERR_MODULE_NOT_FOUND)。自定义 adapter 会在安全暂存区生成产物,先发布 hashed chunks、最后替换 manifest;顶层构建脚本若先 rm -rf build,就绕过这层保护并删掉旧进程仍要导入的 chunk。工作区构建必须保留活动输出,让 adapter 完成发布;若发布包要清理产物,应在独立的新目录处理。守卫:scripts/svelte-adapter-node-sqlite.test.mjs 覆盖旧 manifest 的真实动态导入,并检查 package.json 的 build 脚本没有预先删除活动输出。

53. 前端热更新与后端生效分别验收

开发模式的 UI 热更新不会刷新已运行 Node 进程的模块缓存。报告“当前使用中已生效”前,核对实际服务的构建来源、进程启动时间与深度运行时健康状态;隔离测试只证明该测试实例。执行路由的回归同时检查发送目标、真实 Runner 的模型输入及持久化边界,避免组件结构守卫替代端到端证据。

54. 桌面调用经由 tauriFetch 的每个 /api/settings/... 路由都必须在 Tauri HTTP capability scope 白名单里

(症状:图片设置页打不开、一直停在加载态,2026-10-02):Pi 图片模型新增 loadPiImageModels() 请求 /api/settings/image-generate/models,服务端路由正常(curl 200),但 capabilities/default.json 只列了同族的 .../test 与 .../image*,漏了 .../models——Tauri 的 Rust 侧 HTTP 客户端在 URL 不在 scope 时直接拒绝,loadImageGenerate 的 Promise.all 因它 reject 而整体失败并被 catch,imageGenerateEdit 保持 null,组件永远走 loading 分支。capability 是第二份手写白名单(同 pitfall 50 的发布清单族):新增桌面用的 settings 路由必须同一笔改动补白名单,capability 改动需要 Rust 重建 + 重启应用,WebView 热更新不生效。守卫:apps/desktop/src/http-scope.test.mjs 现在扫描 lib/api.ts 里所有 /api/settings/... 路由字面量,要求 127.0.0.1 与 localhost 两个 host 的白名单都覆盖(回放漏掉 .../models 会失败)。

55. 租约冲突是「重新发现」信号,不是崩溃;桌面托管 sidecar 不得活得比 supervisor 久

(症状:App 打开永远起不来,sidecar 日志一小时 exit code 73 循环,2026-10-18):上一个桌面进程被硬杀后,其 start-server.mjs sidecar 成为孤儿(PPID 1),继续持有 service.lock 和端口;新开 App 的收养探测只有启动时一次 2 秒握手窗口,撞上服务事件循环卡顿就永久错过,之后每次重挑端口 spawn、撞租约、exit 73,被当成普通崩溃无限退避——而健康的同胞服务一直在正常服务。规则:(a) 子进程退出码 73 意味着存在活着的所有者,必须重新发现(读 service-state.json → 探握手)——兼容的托管服务收养(supervise_adopted),旧版本构建的托管服务停掉后接管,外部服务(standalone pnpm start)只上报状态、绝不 SIGTERM 不归本 App 管的进程;(b) 启动发现窗口只对「记录的 pid 仍存活」放宽(15s),pid 已死立即回落,冷启动不得被过期状态文件拖慢;(c) 桌面托管 sidecar 安装父进程守望(MOLIBOT_DESKTOP_MANAGED=1 门控 scripts/runtime/parent-watchdog.mjs):被 reparent 到 ppid 1 即自发 SIGTERM 走正常 shutdown 释放租约。同 pitfall 23/30 族——活性即所有权。守卫:apps/desktop/src-tauri/src/supervisor.rs 单元测试(conflicting_owner_action 五分支、adopt_wait 窗口)与 scripts/runtime/parent-watchdog.test.mjs。

56. A caller's derived identity is not a conversation's owner, and a reconnect is not a reason to re-decide what the user is reading

(root cause of "点击已更新的会话却变成 new session"): the Desktop sidebar aggregates every Web owner's conversations (plan §12), so any per-conversation read/write that derives the identity from the caller (web:<profile>:web-anonymous) misses sessions created by other surfaces — the detail read 404'd into an empty "new conversation" pane, and getOrCreateConversation's fallback silently continued or created a different session on send. Every Web conversation access must resolve the owner from the index first (resolveWebConversationIdentity / resolveRunnerChatId in src/lib/server/web/runtimeContext.ts); the recorded owner also keys the runner chatId, so the turn reopens the exact agent context that wrote the history. Corollary (a): a service reconnect must restore the view the user had, never re-run default selection — the disconnect teardown snapshots the live view untrack-ed (runtime-tracked reads would re-run after disposeAll() and capture null) and the next connect() consumes it before selectDefaultSession may run; sessions created by sending (never clicked) must also become restore anchors. Corollary (b): a one-shot attempt is not a gate — auto title summarization keyed on userMessageCount === 1 meant one failed LLM call (timeout, missing key) left a session titled "New Session" forever; gate on the title still being the default so later turns retry and renames stay untouched. Guarded by src/lib/server/web/runtimeContext.test.ts (owner resolution + real-storage end-to-end: a browser-created session opens through the Desktop identity path while the old derived identity still 404s), the retry/rename tests in src/lib/server/sessions/titleSummarizer.test.ts, and the reconnect-restore structural assertions in apps/desktop/src/chat-ui.test.mjs.

57. Process text is never a result by position.

Persist new assistant entries as process until the runtime commits final/partial delivery; an unfinished process-only turn has no answer. SSE tokens and thread diagnostics are not a done response. The transcript must also render committed content when no steps exist. Guard both the shared projection and the real UI: a plain answer stays visible, ten tools share one collapsed process, and pending approval never promotes narration into the reply.

58. 后台执行器也必须遵守测试模式

getRuntime() 可供 API 测试读取设置与存储,但 liveServicesDisabled() 为真时不能启动 Durable worker、租约维护或后台补投。否则批准后的计划会被没有渠道执行器的测试运行时提前消费,后台定时器还会阻止测试退出。守卫:plans/server.test.ts 的启动方法调用断言及原子批准后的排队状态断言;执行验收使用显式启动的隔离服务。

59. 恢复审核从真实用户入口验证

重启后待审核的执行由持久化恢复记录持有,可能没有内存中的活跃 runner。审批入口须校验请求身份与恢复所有者,再交回共享恢复链;普通工具审批仍须有活跃执行器。守卫:piRecovery.roomReview.test.ts 通过 Room HTTP 入口验证批准、拒绝、重复点击及非活跃普通工具审批,不能以直接调用 Broker 的结果代替。

60. 工具检查点不授权整步重放

并行调用的完成时间不能证明另一操作已有回执;即使某操作有回执,重新执行整步仍可能再次产生副作用。当前整步恢复必须检查全部 intent,不可幂等操作先核对外部状态或转人工。守卫:durableHardening.test.ts 的已完成调用与无关并行读取场景。

61. 租约维护不等待业务完成

实例续期、过期孤儿回收和启动冲突重试独立于任务等待;认领只占实际空闲槽,单任务完成即可补位。回收不得抢走活跃 lease 或本 worker 在途认领,后台调度异常必须在所属层处理。守卫:durableHardening.test.ts、wakeupWorker.test.ts、wakeupStore.test.ts。

62. 强制迁移须隔离无法关联的运行记录

不能只放行预检错误。源项失败按项保留并跳过,数据库异常记录必须先备份再移出恢复流程;切换完成状态持久化,重启不能用已跳过的旧数据重新阻断。守卫覆盖未知表的引用歧义、孤立运行、损坏数据库、暂存冲突、无有效会话与冷启动。

63. 迁移完成后数据库归新运行时所有

源数据未新增或变化时,不得用旧会话映射重新校验新运行记录并推翻持久化完成状态。搜索历史不作为任务恢复依据;缺少归属时保留原 ID,待恢复任务仍严格校验。守卫覆盖同名旧会话、迁移后新建会话、搜索变更记录、独立进程重启,以及旧源目录保留和删除。

64. 导航与编辑意图必须一起验证

父组件同时更新目标、导航版本和编辑请求时,将编辑意图附着在该次加载完成路径,不要让两个响应式分支分别设置与清空编辑状态。回归必须复现父组件的整组属性更新;仅切换编辑布尔值会漏掉导航覆盖。守卫:roomSettingsNavigation.test.ts。

65. 列表预览必须在筛选与分页之后读取正文

复用搜索接口展示无关键词的列表时,也必须先枚举元数据、筛选归属与来源、分页,再读取当前页正文。跨渠道集合每个来源只枚举一次。回归应覆盖接口真实调用路径,检查首/次页正文投影数量和其他 Agent 不被投影;只测试普通列表接口无法守住搜索接口复用。

66. 批量文件查询不得逐项重建映射

Agent 共享工作区中的文件列表应先构建一次会话引用匹配器,再过滤候选文件;不要在逐文件谓词中重新读取迁移清单、扫描来源或重建别名。仅验证少量旧工作区文件不足以发现此问题;守卫应覆盖迁移后共享工作区、多文件输入、清单读取次数及会话可见性。

运行历史同样适用:先选出返回记录,再对每个工作区构建一次引用解析器。全量同步读取并逐行解析迁移清单会随历史规模阻塞服务;使用异步流式扫描与有限候选集。reviewData.test.ts 覆盖千条记录下的清单读取次数、事件循环让出与全局排序,run-history/server.test.ts 验证实际接口;旧文件列表守卫没有经过历史摘要读取路径。

67. 会话身份不等于执行归属

自动恢复与传输断连只能取消它们持有的前台 runId,不能按 Session 直接取消当前 Runner。后台事件和长任务由运行时持有;共享 Runner 必须检查执行归属和 runId。传输监听在运行结束时移除,取消日志记录来源。守卫:Runner 后台/过期身份拒绝取消,以及 Desktop 重连绑定身份、实际停止计数回归。

历史成果查询也必须按执行归属读取。缺少 runId 对应消息时,不能沿用运行时恢复上下文的 Session 回退;使用严格的已记录执行读取,缺失正文仅取该执行保存的结果或说明不可用。守卫:storeDelivery.test.ts 覆盖无标签消息、兄弟执行和临时控制消息隔离,desktop/handover/server.test.ts 用同一 Session 的两期结果与后来的无标签回答验证真实任务读取接口。

68. 编排容器不能持有叶子操作的执行锁

Codemode、subagent、mcpInvoke 等容器等待嵌套工具时,副作用由叶子调用独立记录与串行化;否则父子共用 ToolRuntime 会互相等锁。等待锁的取消必须立即返回,但跳过的队列槽只能在前一个持锁者完成后释放,避免后续操作并行越过当前操作。容器免占锁不代表可自动重放,MCP 写操作仍由叶子调用保护。守卫:ToolRuntime 嵌套、真实 mcpInvoke 分发、权限拒绝、取消顺序与 replaySafety 回归,以及 PiRunSession 原生父子实际 shell 回归。

69. 侧栏浮层必须脱离侧栏层级

移除祖先的 backdrop-filter 只能消除一种 fixed containing block;祖先的 stacking context 仍可让菜单被相邻面板覆盖。会话菜单及遮罩统一 Portal 到 body,验证应挂载真实组件并检查超出侧栏部分的 elementFromPoint,不能把测试菜单预先放到页面根部。

70. 审批等待伪装成服务崩溃

如果日志停在 tool_start、客户端在固定超时后 fetch failed、却没有 crash report,先查 ApprovalBroker/Host Bash 两套存储里的 pending 请求和客户端超时,不要把 eval 的清理信号当成服务自杀。统一审批卡的 list 与 resolve 必须覆盖同一组后端并按 Session 校验;eval 如需批准,必须在任务 YAML 显式声明并走正式审批 API,禁止绕过生产策略。

71. 请求覆盖值不得伪造缺省

request-level model/thinking/profile 字段是可选覆盖,不是第二套默认值。输入缺失或非法时必须保持 undefined,让共享 Runtime 按正式优先级解析;禁止在 API parser 里 sanitize 成 off、默认模型等有效值。主 Agent 与 Subagent 的自定义模型兼容元数据必须来自同一份模型声明。

72. 上下文闸门必须守最终 Provider 边界

预算必须包含最终 system prompt、序列化工具 schema、历史和当前消息,并在 role/tool/image patch 之后、网络 dispatch 之前再次 fail closed。压缩只改变模型上下文;当前超长输入只能裁剪送模副本,不能改写持久化原文。机器守卫必须证明超限时 Provider callback 未被调用。

73. 长任务的传输超时不得短于任务超时

eval/客户端允许任务运行数分钟时,HTTP headers/body timeout 必须显式覆盖同等或更长窗口;固定约 300 秒 fetch failed 且 serviceExit=null 首先判定为 Undici 传输超时,而不是服务崩溃。

74. 桌面 UI 的 API 请求必须走共享 transport(api.ts),组件层禁止原生 fetch

webview 源是 Tauri 自定义协议、sidecar API 在 http://127.0.0.1:<port>,原生 fetch 属跨源且 sidecar 不带 CORS 头——带 JSON body 的 PATCH/POST 预检直接失败,GET 响应同样不可读;curl、服务端直测、同源浏览器打开全部正常,唯独桌面 app 内必挂,.catch(() => undefined) 还会把加载失败静默成默认值(症状:"执行与权限"保存永远失败,2026-09-12)。新增接口一律在 apps/desktop/src/lib/api.ts 补 load/save helper(fetchFromDesktop 在 Tauri 内自动切 HTTP plugin,Rust 侧发请求)。守卫:apps/desktop/src/api-transport-guard.test.mjs。首犯即配守卫:此类故障在浏览器/服务端测试里完全不可见。

75. 模型失败重试与成功轮数分开

父 Agent 的失败重试预算只在模型失败后计数,不能在每次生成前扣减;空回复重试与子 Agent 的失败重试预算各自独立。修改原生模型生命周期时,用真实 Runner 验证超过默认重试上限的成功工具循环,并覆盖失败重试、持久化恢复与同一失败去重。子任务预算或超时停止必须作为失败工具结果交回父任务,包含进度与原因;不得调用父任务的恢复失败通道。父模型空回复或静默时仍须给出未完成结论。守卫:真实 Runner 的子任务失败/静默收尾,以及原生子任务超过 12 轮、失败重试耗尽与已完成写入不重放回归。

76. 行级键盘操作不能截获子控件

会话管理的行处理器曾拦截子按钮冒泡的空格键,阻止默认按钮激活并误切换行选择。行级快捷键先核对 event.target === event.currentTarget,子控件保留原生键盘操作。守卫:session-management-polish.spec.ts 在真实页面聚焦“查看”并按空格,确认打开预览且选择不变;纯 store 选择测试没有经过 DOM 事件冒泡路径。

77. 多触发抽取共享进度,游标不能越过未处理范围

空闲抽取和每日反思各自维护进度时,同一段消息会被重复送模;分批只发送前 80 条却把游标推进到整段末尾,还会永久漏掉后续消息。共享入口统一筛选、抽取、写入和进度,触发器只指定范围与调度方式;提交成功后只推进到实际处理批次的末尾,失败保留已有成功游标。守卫:memoryExtraction.test.ts 验证单会话/全部互操作、并发、分批和旧进度迁移;app/memoryRuntime.test.ts 通过正式每日事件入口核对模型调用次数。

78. Chord 调用上下文不能用对象展开复制

Pi 1.4 的 Chord Context.telemetry 由原型 getter 和内部上下文链提供。{ ...context, abortSignal } 不保留这个属性及 value(),会让原生嵌套调用或 Codemode 模型请求在读取 propagation 时失败。局部取消统一使用 withAbortSignal(signal, context);清理和遥测派生也使用相应的官方 API。旧守卫未覆盖新版运行时对遥测的真实读取;piRunSession.test.ts 的隐藏模型流与嵌套超时用例、codemode.integration.test.ts 和 codemode.native.test.ts 直接经过 Pi 1.4 请求与恢复路径,升级后可拦住这类丢失。

79. 连接探测也需要会话标识

(2026-11-02)内置 OpenCode 的设置页测试返回 MissingSessionID:checkProviderConnectivity 没有传 sessionId,Pi 无法生成 x-opencode-session;自定义模型的 testCustomProvider 也没有发送 x-molibot-session。两类测试各生成临时会话标识,自定义模型同次测试的连接、角色和视觉探测共用该标识。聊天请求已有会话头,不能证明测试入口正常。守卫:providerConnectivity.test.ts 经真实 Pi 入口捕获 opencode / opencode-go 出站 Header;customProtocol.test.ts 覆盖两种协议的多次能力探测和独立测试的身份隔离。此前 helper 测试只覆盖已传 sessionId 的请求,未检查连接测试调用方。

80. 加载成功不代表预览可见

搜索结果属于文件浏览区,应与下方预览共用分隔条,不能和整个预览区互斥。打开文件只更新预览,不关闭搜索或切回文件树;加载中与失败态也须保留列表,目录展开与正文加载并行。旧守卫只检查搜索调用了 openFile,没有验证列表和正文同时可见。project-file-search.spec.ts 通过真实文件接口验证上下布局、连续切换、目录延迟、键盘定位、文件删除报错及退出搜索后保留预览。