docs(i18n): core-data-structures and postmortem batch — 22 bilingual pairs

core-data-structures 18 篇(core.md 因超长仍在产出、随后补)、
postmortem 3 篇与 RFC 前门 README 配对;流水线 + 二遍校验产出。
生成文件 docs/rfc/INDEX.md(gen-rfc-index 产物)列入排除。中文侧
页内锚点统一指向英文侧锚名,满足配对门禁的链接目标一致规则。
This commit is contained in:
Ziya
2026-07-15 23:11:25 -07:00
parent ec47f2e40f
commit 5270dcd61d
67 changed files with 2512 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
0001-acp-default-export-drops-inject.md: 6a71d8d7ef72e3110a99774b180f3de7115ef622
0001-acp-default-export-drops-inject.zh.md: 12bb3501a56c3cfef8f7a1b0d773be62db8e09ca

View File

@@ -1,5 +1,7 @@
# Post-mortem 0001: ACP server crashed on connect — `export default` dropped the plugin's `inject`
English | [中文](0001-acp-default-export-drops-inject.zh.md)
Status: resolved (fix in PR #41 `feat/acp-2-bridge`)
## Executive summary

View File

@@ -0,0 +1,113 @@
# 事后分析 0001ACP 服务器在连接时崩溃——`export default` 丢弃了插件的 `inject`
[English](0001-acp-default-export-drops-inject.md) | 中文
Status: resolved (fix in PR #41 `feat/acp-2-bridge`)
## 摘要
两个集成错误在单元测试全绿的情况下击溃了 ACP一个 default export 导致 Loader 丢弃 `inject`,一个经过 traceable 代理的可选服务查找在 shadow 边界上失败。手动挂载的测试绕过了这两条路径。修复后新增了无需 API key 的真实 Loader 覆盖以及关于插件导出和可选服务访问的包package规则。
## 概述
ACP 服务器(`examples/acp-agent``@deepseek-ai/dsh-acp`在真实编辑器Zed连接的瞬间崩溃第一个 `session/new` 请求返回 `Internal error: cannot get property "agents" without inject``session/load``sessionPersistence` 返回相同错误。尽管有 178 个绿色单元测试和 100% 行覆盖率bridge 在生产环境中完全无法工作。两个独立的 bug 隐藏在同一个错误字符串背后,测试套件因同一个原因漏掉了二者:每个测试都通过一条不会触及插件实际加载方式或服务实际解析方式的路径来挂载插件。
## 影响
ACP 服务器无法创建或加载任何一个会话——这正是编辑器最先调用的两个 RPC。任何将 agent 接入 Zed 的人都会立即遇到硬性失败。无数据丢失(崩溃前没有持久化任何内容);代价完全是「功能不可用」加上两次定位原因的调试时间。
## 时间线
- BridgeRFC 010带着完整的单元测试套件编解码、内存传输、基于属性的协议形状测试、失败路径、HMR热模块替换、一个需要 key 的真实 API e2e 测试,以及一个无需 key 的 stdout 纯净性 e2e 测试一起落地。全部绿色100% 覆盖率。
- 一次真实的 Zed 会话立即在 `session/new` 上失败,报错 `cannot get property "agents" without inject`
- 调查最初追踪的是 Cordis「traceable/shadow」理论合理且机制确实存在——见 Bug #2),随后在 vendor 的 `reflect.ts` 中对实际 fiber 遍历做了插桩并运行了真实子进程。trace 显示 throw 发生在 `apply()` 第 179 行、**插件加载时**,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。
- 找到根因 #1:一行多余的 `export default apply`。移除后 `session/new` 修复。
- 移除后暴露了 Bug #2`session/load` 仍然在 `sessionPersistence` 上抛出——这是一个真正不同的机制shadow 遍历),通过隔离修复并重新运行真实子进程得到确认。
## 根因 #1——`export default apply` 丢弃了插件的 `inject`(导致 `session/new` 崩溃)
`packages/ui/acp/src/index.ts` 是一个*命名空间插件*:它将 `name``inject``Config``apply` 作为独立的命名导出——与仓库中其他所有插件(`invariants``llm-deepseek``tool-bash``stdio-chat` 等)形状相同。但它*还*多了一行其他插件都没有的代码:
```ts ignore-check
export const name = 'acp'
export const inject = ['agents', 'sessions', 'sessionPersistence']
export function apply(ctx: Context, config: AcpConfig): void { /* … */ }
// …
export default apply // ← the bug
```
当插件从 `cordis.yml` 加载时Cordis Loader 通过 `Loader.unwrapExports``vendor/loader/src/index.ts`)对导入的模块做规范化处理:
```ts ignore-check
unwrapExports(exports: any) {
if (isNullable(exports)) return exports
exports = exports.default ?? exports // ← prefers `.default`
if (!exports.__esModule) return exports
return exports.default ?? exports
}
```
存在 default export 时,`exports.default ?? exports` 解析为**裸 `apply` 函数**。裸函数没有 `inject`、没有 `name`、没有 `Config` 属性——这些作为*兄弟*命名导出存在于模块命名空间上,而 unwrap 到 `.default` 把命名空间整个丢弃了。Loader 随后基于一个空的 `inject` 构建了插件的 fiber。
因此 `apply` 在一个**没有注入任何服务**的 fiber 中运行。第一行 `const agents = ctx.agents` 遍历 fiber 树ROOT → Include → Loader → ROOT在所有 fiber 的 store 中都找不到 `agents`,到达根 fiber`runtime === null`)后抛出 `cannot get property "agents" without inject`。崩溃发生在*加载时*,而非后续的请求处理器中——请求只是恰好触发了加载。
**修复:**删除 `export default apply`。Loader 随后使用模块命名空间,正确识别 `inject`/`name`/`Config``apply` 在一个真正授予了声明服务的 fiber 中运行。
## 根因 #2——可选服务的属性读取在 traceable shadow 中触发 inject 守卫(导致 `session/load` 崩溃)
修复 #1 后,`session/new` 正常工作,但 `session/load` 仍然抛出 `cannot get property "sessionPersistence" without inject`。这次*确实*是 Cordis 的 traceable/shadow 机制,值得精确理解。
`session/load` 调用 `agents.resume(...)`,后者委托给 `AgentLoop.resume()`,其中读取了 `this.ctx.sessionPersistence`。`AgentLoop` 的 `static inject` 故意**不**包含 `sessionPersistence`——注入它会导致非持久化的演示永远挂起,等待一个永远不会加载的后端。该服务由一个独立的兄弟插件/fiber 提供,按需读取。
Cordis 中的服务访问通过上下文代理(`vendor/cordis/src/reflect.ts`)进行。当通过从外部 fiber 获取的 *traceable 代理*调用服务方法时此处bridge fiber 调用 `ctx.agents.resume`,注册表返回 `this.factory`——即 `AgentLoop`——被重新包装为绑定到调用方的新 traceable 代理),`createShadowMethod``vendor/cordis/src/utils.ts`)将 `this` 重新绑定到一个 *shadow* 对象,其 `ctx` 携带 `[symbols.shadow]` 指向 `AgentLoop` 自身的构造上下文。在 `resume` 内部,`this.ctx.sessionPersistence` 的解析从 shadow 的 fiber 开始遍历:
```ts ignore-check
// reflect.ts get handler
let fiber = (ctx[symbols.shadow] as Context ?? ctx).fiber // ← starts at AgentLoop's fiber
while (true) {
const impl = fiber.store?.[prop]
if (impl) return getTraceable(ctx, impl.value)
if (prop in fiber.inject) { /* inactive-context error */ }
if (!fiber.runtime) throw error // ← reached root, throw
if (fiber.parent[symbols.isolate][prop] !== key) throw error
fiber = fiber.parent.fiber // ← ancestor-only
}
```
遍历**只走祖先方向**。`sessionPersistence` 既不在 `AgentLoop` 的 fiber store 中(不在其 `static inject` 里),也不在通往根的任何祖先上(它在一个*兄弟*分支上),因此遍历到达根 fiber 后抛出。
为什么内存中的 `AgentLoop` resume 测试没有捕获到这个问题?因为它们从测试代码中直接调用 `ctx.agents.resume(...)`——*不在任何插件 fiber 内*。此时 `ctx.fiber.runtime` 为 `null`,代理处理器走了一条提前退出的路径:
```ts ignore-check
if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct global-store lookup, no fiber walk
```
`ctx.reflect.get(name, false)` 是基于 isolate symbol 的全局服务 store 直接查找——完全忽略 fiber 拓扑,能找到服务。因此从顶层测试读取正常;从真实插件 fiber 内部、经由 shadow 到达时则抛出。bridge 恰好是后者。
**修复:**使用 `ctx.get('sessionPersistence')` 读取可选服务,该方法使用全局 isolate-keyed store同时保留活跃状态检查。对于插件声明注入集中的服务直接属性读取仍然适用。
## 为什么所有测试都漏掉了(真正的失败)
两个 bug 共享同一个流程缺口:**没有任何测试通过插件的真实加载路径或真实调用拓扑来运行它。**
- 内存 harness 通过手动构建插件对象来挂载 bridge`ctx.plugin({ name, inject, apply })`。这手动提供了 `inject`,因此永远无法复现 Bug #1——`unwrapExports` 只被 *Loader* 调用,`ctx.plugin` 从不调用它。即使 `ctx.plugin(NamespaceImport)` 也无法捕获此问题。
- 同一个 harness 把所有东西平铺挂载在一个根上下文上,因此从中触达的 `AgentLoop` resume 要么在顶层运行(`!runtime` 旁路),要么通过一个 origin 仍在根上解析的 shadow——掩盖了 Bug #2 的祖先遍历失败。
- 唯一的无 key e2e 发送 `initialize` 并检查 stdout 纯净性。`initialize` 从不触达 factory因此安然通过两个 bug。
- 唯一驱动 `session/new`/`session/load` 的测试需要 key 才能运行CI无 key跳过了它——而本地它之所以「通过」只是因为一个陈旧的已构建 `lib/`(包含旧代码)恰好满足了模块解析。
100% 行覆盖率自始至终满足。覆盖率证明代码行*被执行过*;它不能说明功能是否*以交付的方式*工作。
## 新增的防护措施
- **移除 `export default apply`**`packages/ui/acp/src/index.ts`——Bug #1 的修复。
- **`AgentLoop.resume` 使用 `this.ctx.get('sessionPersistence')`**`packages/core/agent-loop/src/index.ts`——Bug #2 的修复,附注释说明 shadow 遍历陷阱。
- **无需 key 的 `session/new` e2e通过真实 stdio 运行**`examples/acp-agent/tests/acp.e2e.ts`):以子进程方式通过真实 Loader 启动示例,并断言 `session/new` 正常返回。无需 API key 即可在 Bug #1 上大声失败。已验证恢复 `export default apply` 时测试失败。
- **e2e spawn 中设置 `TSX_TSCONFIG_PATH`**:子进程从临时 cwd 运行tsx 无法通过向上搜索找到仓库根的 tsconfig `paths` 映射——因此 dsh-* 的导入静默回退到已构建的 `lib/`。将 tsx 指向仓库 tsconfig 使解析不依赖 cwd确保测试运行的是*源码*而非可能陈旧的构建产物。
- **[docs/testing.md](../testing.md) 规则**:「测试真实入口路径」,行覆盖率不等于行为覆盖率——将此教训编纂为所有未来插件的规则。
## 教训
- 命名空间插件与 default export 在 Cordis Loader 下互斥。选择命名空间形式(`name`/`inject`/`Config`/`apply`),不要添加 `export default`——`unwrapExports` 会丢弃命名空间。
- 对于插件按需读取但**不**声明在 `static inject` 中的服务,使用 `ctx.get(name)`,绝不使用 `ctx.<name>`。属性代理通过只走祖先方向的 fiber 遍历解析,经由外部 shadow 时会失败;`ctx.get(name)` 是拓扑无关的查找(且默认严格——后端未激活时返回 `undefined`,而非在 teardown 过程中把半拆除的实例交出去)。
- 手动构造插件的测试无法验证插件的加载方式。至少一个测试必须端到端地驱动真实的 Loader/export 路径。当核心操作不调用模型时,该测试无需 API key——因此它属于 CI而非 key 门控之后。
- 相信 trace不要相信理论。优雅的 shadow 解释是真实的,但它是*第二个* bug*第一个*是一行导出错误,在数小时合理但错误的推理之后,一条 fiber 遍历的 `console.error` 几分钟就找到了它。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
0002-js-expression-disabled-filesystem-tools.md: 43e57a6bd1b68f38c47eeda3c3abb8455024b350
0002-js-expression-disabled-filesystem-tools.zh.md: e54431b7f4061bb4bdc22a37f0651697c6247dda

View File

@@ -1,5 +1,7 @@
# Post-mortem 0002: Filesystem snapshot tools were permanently disabled
English | [中文](0002-js-expression-disabled-filesystem-tools.zh.md)
Status: resolved
## Executive summary

View File

@@ -0,0 +1,47 @@
# 事后分析 0002文件系统快照工具被永久禁用
[English](0002-js-expression-disabled-filesystem-tools.md) | 中文
Status: resolved
## 摘要
ACP 示例试图通过 `disabled: !!js ...` 有条件地启用文件系统插件,但 Cordis 仅在插件 `config` 内部求值 JavaScript 表达式。原始的表达式对象为 truthy因此文件系统栈始终处于禁用状态。快照刷新随后将 `UNKNOWN_TOOL` 结果作为新的 golden 接受。修复方案使用显式的文件系统 overlay并增加了静态配置守卫和快照结果守卫。
## 概述
默认的 ACP 组合有意仅包含 bash因为其沙箱无法约束进程内的文件系统提供方。文件系统快照场景仍需要 `read``write``edit`,因此这些插件被放入默认的 `cordis.yml`,并附带一个 `disabled` 表达式,意图仅在全权限启动和快照模式下启用它们。
Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader 递归地对插件的 `config` 进行了插值,但直接消费了 `disabled` 等入口元数据。因此每个文件系统入口都看到一个 truthy 对象,在所有模式下均保持禁用。
## 影响
七个文件系统场景和一个混合工作区编辑场景调用了注册表中不存在的工具。它们的结构化会话日志携带 `ToolNotFoundError`code 为 `UNKNOWN_TOOL`stdout 则渲染了通用的失败工具卡片。快照套件通过了,因为两个表面都与刷新后的 fixture测试前置数据匹配它证明的是回归的确定性回放而非文件系统行为的正确性。
实际运行的受限默认组合并未获得意外的文件系统访问。一个朴素的插值修复反而会引入该风险:权限预设在运行时更新 bash 沙箱和审批状态,但无法挂载、卸载或约束文件系统栈。
## 时间线
- PR #261 整合了 ACP 组合并刷新了文件系统快照,同时引入了条件式文件系统入口。
- 所有单元测试、覆盖率、快照、文档、构建和 hygiene 检查均通过。
- 对刷新后的文件系统 golden 的评审发现了通用的失败卡片和结构化的 `UNKNOWN_TOOL` 结果。
- 一次真实的 Loader 启动确认:每个 `disabled` 值仍然是表达式对象,每个文件系统 fiber 均未注册。
## 根因
实现方假设 `!!js` 适用于整个 Loader 入口。其实际边界更窄:`Entry._resolveConfig()` 仅对 `entry.options.config` 进行插值;`Entry.disabled` 直接测试 `entry.options.disabled`不做插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。
快照框架将任何确定性的 transcript文本记录视为有效行为。Header pin 验证了组合后的工具 schema但文件系统场景共享了来自默认组合的 pin因此未独立证明其所需工具已注册。刷新在任何语义断言拒绝缺失工具之前就已重写了预期的 stdout 和会话日志。
## 新增的防护措施
- 文件系统场景启动 `fs.cordis.yml`:一个显式的固定全权限 overlay配有对应的 replay 配置和独立的 request-header 类。
- [`AGENTS.md`](../../AGENTS.md) 和 [Cordis 入门](../cordis-primer.md#loader-configuration) 明确说明 `!!js` 仅在插件 `config` 下有效,条件式组合应使用 overlay。
- `verify-cordis-config` 解析仓库中的 Cordis YAML拒绝 Loader 入口元数据(包括 include patch 和插入的入口)中出现表达式节点。
- `dsh-acp-snapshot` 在新鲜运行和已提交的会话 fixture 中拒绝结构化的 `UNKNOWN_TOOL` 结果,阻止其成为被接受的 golden。
## 教训
- 语法上被接受的配置值不一定在该位置被求值;应记录并验证插值边界。
- 快照刷新是 fixture 生产,不是正确性评审。像「已注册工具缺失」这样的语义不可能性需要独立于 golden 的断言。
- 权限控制只应描述它实际管辖的能力。组合时的文件系统访问无法安全地跟随运行时的 bash-only 预设。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 4dc59e4f5e70f51c4c0baa64fbe34b213f2a7c3d
README.zh.md: 7e2d05d429b2521e7e772956b7644740d4642fac

View File

@@ -1,5 +1,7 @@
# Post-mortems
English | [中文](README.zh.md)
Incident write-ups: a bug reached a place it shouldn't have (a real user, a merged PR, a release), and the interesting part is *why our process let it through*, not just the one-line fix.
A post-mortem is NOT an [RFC](../rfc/README.md) (which records a deliberate design decision and its rejected alternatives, or proposes future work). It is a backward-looking record of a failure: what broke, the mechanism, why every safety net missed it, and the concrete guardrails added so the same class of bug fails loudly next time.

View File

@@ -0,0 +1,16 @@
# 事后分析
[English](README.md) | 中文
事故记录:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PRPull Request、已发布的版本有意义的部分是**为什么我们的流程放过了它**,而不仅仅是那行修复。
事后分析不是 [RFC](../rfc/README.md)RFC 记录的是经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份面向过去的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体护栏使同类 bug 下次能快速失败。
满足以下条件时写一篇bug **隐蔽**(机制不显而易见,一位细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性手误)、**重新发现的代价高**它消耗了真实的调试时间而且下次还会。请链接该事后分析所推动建立的护栏测试、AGENTS.md 规则、ADR
每篇事后分析以一段 **Executive summary** 开头:一段简短的文字,让忙碌的读者在三十秒内了解全貌——什么坏了、用通俗语言说的根因、为什么逃逸了、以及持久的教训——之后再展开详细的 Summary / Timeline / Root cause / Guardrails 各节。
| # | 标题 |
|---|---|
| [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` |
| [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object |