维护者视角:
上游如何运转。
毕业不是终点,换个座位再坐一次。这一次你坐在维护者的位置上,回答四个上游运转问题: 远程客户端怎么调到 Host 服务、一个仓库为什么要两份 TypeScript 世界、持久日志怎么安全升级、 版本怎么发出去;再把 benchmarks、llm-retry、token-meter 三个仪表盘 和 Agent Notes 的正规读法一并补齐。
- 讲清 Remote API 的四层分层、
@Remote/@RemoteScope编程模型,以及「活对象不穿线」的 lookup 设计 - 说出仓库必须拆成 Host/Client 双 aggregate 的原因,以及三条不可破的纪律
- 按 cookbook 走通加一条 Session 格式迁移的完整动作:迁移包 manifest、catalog 门、release/* 集成分支、验证命令
- 描述三条互不等待的发布线与「本地 bump → 人工 tag → CI 彩排 → 人工发布」的节奏
- 会用 benchmarks 预算门、llm-retry 策略、token-meter 投影回答性能与成本问题
- 按 lifecycle/class 路径查 Agent Notes,讲清 Alternatives considered 为什么是强制的
Remote API:给远程客户端开的点菜窗口
前十课里你的插件都跑在本地 Host 进程内。维护者要回答的下一个问题是: Web UI、SDK、ACP 这些远程客户端,怎么调用 Host 里的业务服务? 答案是 Remote API——注意这个词在上游专指 Typert Remote 的 unary 调用, 一请求一结果;流式会话数据不在其列(本节末尾细说)。
编程模型一句话:Host 服务继承 TypertRemoteService,用
@Remote('方法名') 和 @RemoteScope(key) 自己挑选暴露面。
没标装饰器的方法不进生成物、经 ctx.remote 根本调不到——
不是运行时权限拦截,而是构建期生成器根本看不见它们。
Typert 生成器读 Host 源码,为每个标了 @Remote 的方法生成两端的严格契约:
Host 面的调用描述符,和 Client 面的类型化调用桩。
活对象不穿线
Agent 这样的活对象不能作为参数直接穿越 HTTP。声明
agent: Agent 参数时,Typert 把它映射成一个 agentId wire 字段,
运行时经 TypertLookupMap 注册的提供者还原成真对象;
@RemoteScope 的上下文同样走 ctx.typert.contexts 还原。
agent/session 的标准解析策略(冷启动恢复、并发 resume 去重、拒绝 subagent 身份,
错误码 session/not-found、session/agent-busy)
统一收在 api-session-controller 里,Host 方法拿到的永远是已解析好的对象。
生成管线的硬顺序
# Typert 生成器只在 Host 的 tsdown 阶段跑,以 Host aggregate 为唯一 ts.Program seed
tsc -b tsconfig.host.json # ① Host 类型检查
tsdown --env.DSH_BUILD_FACE host # ② 这里生成 typert.host.js / typert.remote-client.js
tsc -b tsconfig.client.json # ③ Client 类型检查(消费 ② 的产物)
tsdown client && pnpm run build:web # ④ Client 打包 + Web 构建
两个产物落进各业务包的 lib/:typert.host.js(Host 面运行时反射与严格描述符)
和 typert.remote-client.js(Client 面可挂载的调用桩,含 declaration merge 与
source map 回指 Host 源码)。改了签名、码表、namespace 或导出名,必须重跑
pnpm run build:lib,否则两端契约不同步。
生成器还做严格分析:@Remote 方法必须公有、非静态、非泛型,参数必须有名字,
禁解构/默认值/rest/可选参数——契约要能从源码唯一确定,就没有「猜」的余地。
错误模型:一个类,一张码表
Remote 只有一种错误:RemoteError,配一张 <domain>/<reason> 格式的码表
(由各包 declaration merging 汇总)。单包独有的码放生产者包里,多包共用的放最低公共域包;
gateway/bad-request、gateway/cancelled、gateway/internal
是基础设施码,不许复制仿造;未分类异常一律兜底成 gateway/internal。
调用侧按 result.ok 与 error.code 分支,不用 instanceof 错误家族——
一条线、一张表,比一树错误类好维护得多。
Client 侧的装配只挂 dsh-api-remotes 一个门面:它挑选本应用要暴露的 Host 能力
(Commands、credentials、settings、Goal、动态 Cordis、文件与 Session 引用、插件清单、
Session/Workspace Controller 等),经 ctx.remote.$mount() 逐个挂载并 re-export 类型词汇;
调用方插件自己声明 inject: ['remote', 'remote.<ns>']。
Remote API 只做 unary(一请求一结果)。流式、增量、分页必须另立数据协议或走精确的 Connection Fetch 路由,不许伪装成 Remote 方法。面试里把「会话流」说成 Remote 方法,维护者会立刻皱眉。
双 aggregate:一个仓库为什么要两份 TypeScript 世界
打开仓库根目录,你会看到 tsconfig.host.json 和 tsconfig.client.json
两份配置(docs/development.md 的 contributor reference 是权威文本)。
原因藏在 Cordis 里:Host 侧和 Client 侧都要给 cordis 的 Context 接口做
declaration merging,同名 key(比如 sessions、loader)
在两侧挂的是不同的服务。TypeScript 不允许一个 ts.Program 里出现两份同名合并——
于是切成两个互不相见的编译世界。
精妙之处在于:这个冲突只活在 ts.Program 内部,模块解析根本不会触发它。
所以根 solution tsconfig.json 可以同时 reference 两个 aggregate
(它自己是 files: [] 的空壳,也是 tsserver 的发现入口),
tsc 挨个构建时各自独占一个世界,互不打架。
三条纪律
tsconfig.base.json永不加include/files——否则两个世界被迫看见同一批文件,冲突复活。- 要建仓库级 ts.Program 的脚本,必须显式 seed host 或 client aggregate,绝不 seed 根 solution。
- 新包只登记进一个 aggregate;少数两边都要检查的,走共享 leaf 或拆分包机制(见下)。
共享 leaf 与拆分包
有三个包两侧都引用:packages/host/webserver、packages/compaction/compaction、 packages/typert/registry——两侧必须 type-check 同一份源码的共享叶子。另有六个两头都有代码的包(api/remotes、api/gateway、 api/session-controller、api/workspace-controller、client/connection、 session-query/session-log-export)各带 host/client 两份 leaf config: 包根 tsconfig 只是 solution,聚合与直接消费方必须点名 leaf—— constraints 门沿 Project Reference 图逐个检查,指错脸直接拒绝。
测试文件也要分脸:命名 *.client.* 的归 Client aggregate,
*.host.spec.ts 的归 Host,两个 aggregate 互相 exclude 对方脸的文件。
一份测试属于哪个编译世界,从文件名一眼可读。
判断一个文件属于哪边:它 import 的服务挂在哪个 Context 合并上,就是哪张脸。 谁也不挂的纯算法,才能进共享 leaf。
Session 格式迁移:亲手加一条世代边
第 06 课讲过版本闸门与不可变世代:旧日志靠相邻迁移链升级到当前代,
已落盘的 generation 永不移动、覆盖、删除。这一节讲轮到你动手时的完整动作——
权威手册是 docs/cookbook/adding-a-session-format-version.md,
固定范例是 packages/session/session-format-v2-to-v3/README.md。
「writer 现在写哪个版本」的权威是代码常量 SESSION_FORMAT_VERSION
(packages/core/session/src/types.ts);「哪个版本已随产品发布」的权威是
docs/session-format-status.md 的 release record。写文档引用版本号时指向这两个权威,
不要把数字抄死在自己的页面里。
先问要不要 bump
版本是单调整数,没有 major/minor。bump 判据只有一条:
旧运行时能否对新日志做全语义正确的读取。只有结构性变化
(header、envelope、核心事件语义、surface 机制)才值得 bump;
新增一种普通事件不动版本——给事件信封标 ignorable: true 即可,
默认不标就是必需事件,旧读取器遇到不认识的必需事件宁可拒绝也不静默跳过。
迁移包:一个包一条边
每个世代步由一个独立包负责:packages/session/session-format-v0-to-v1、
-v1-to-v2、-v2-to-v3,包与版本边一一对应。
邻接关系声明在包的 package.json 里,v2→v3 的真实 manifest 长这样:
// packages/session/session-format-v2-to-v3/package.json —— 一个包恰拥有一条 vN → vN+1 边
"dsh": {
"sessionFormatMigration": {
"from": 2, "to": 3,
"export": ".",
"migration": "sessionFormatV2ToV3",
"sourceCodec": "releasedV2SessionFormatCodec", // 复用前一边的 codec,不复制
"targetCodec": "releasedV3SessionFormatCodec",
"targetHeaderValidator": "assertReleasedV3Header",
"targetRestorer": "restoreReleasedV3Artifact"
}
}
迁移实现 migrateHeader / validateTargetHeader /
createStage(每次调用独立状态),Stage 内用
transformEvent / transformRun / finish 经
context.emitEvent / emitRun 同步发出事件——
一进可出零或多个。继承 cut 是逻辑事件数不是物理行数,未知 cut 绝不用 0 顶替。
目录结构由 scripts/gen-session-format-catalog.ts 把关:
0 到 writer 版本之间每步必须恰有一个相邻包,缺口、重复、多余边都拒绝;
generated.ts 禁止手改。
发布节奏:release/* 集成分支
N+1 的开发用一条共享的 release/* 集成基分支承载基础变更
(writer、codec、catalog 接线、identity 迁移、验证),每个独立子分支从它切出、
做一项结构变换、PR 打回 release 分支;评审合并后整体验证发布。
发布后该相邻边冻结:后续结构变化必须开新边,不得修改已发布的边;
release 分支受 force-push 与删除保护。
release/* 只服务于 Session 格式集成。npm 发版走
master + dsh-v<version> tag(下一节),
把两者混成「release/dsh-* 分支发版节奏」是本课最容易写错的一句话。
不用真加一个版本,把迁移链的验证网跑一遍(在上游仓库源码 checkout 里):
pnpm run verify-session-format-catalog再跑快照基线——迁移改动的语义不变性全靠它兜底:
pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts测试未发布版本时用一次性隔离 home(N+1 中间文件已带目标版本,事后改版本不会再迁移它们),绝不重写已提交世代、绝不复用真实用户 home。
发布流程:三条版本线,CI 只彩排不发布
上游发版不是按一个按钮跑流水线,而是三条互不等待的版本线
(权威记录在 Agent Note 2026-08-10-npm-release-sequences.md):
发 dsh 不会重发 vendor,发 vendor 不会重发 native。
第一步 · 版本在本地命令里落仓库
pnpm run release:dsh -- <major|minor|patch|显式版本>
(scripts/release/bump.ts)把发布家族、全部私有包、workspace root
写成同一版本并连 lockfile 一起提交;人 merge 之后手工打 tag。
CI 永不写仓库。预发布排序 alpha < canary < rc < stable,
dist-tag 映射:alpha/canary → 同名,rc → next,stable → latest。
第三步 · 无凭证彩排,每个 PR 都证明还能打包
# release.yml 在每个 PR 与 master push 上依次执行:
pnpm run release:verify --family dsh # 版本基线 + 打印完整 publishOrder + 发布时校验 tag
pnpm run build:official # 本地等价的 CI/发布产物构建
pnpm run release:pack --family dsh --out dist/npm # 打包,无任何凭证
pnpm run release:verify-packed-install --family dsh --from dist/npm ...
# tarball 装进一次性消费者,纯 Node 跑 dsh --version第四五步 · 人工发布与 registry 三态判断
真正的发布只在 release-publish.yml 里发生:workflow_dispatch-only、
从 dsh-v* tag 运行,先重新打包再按 publishOrder
(npm 安装关系 + peer 声明的拓扑序)逐个发布,全程置于 npm-publish 环境的人工审批之后。
发布脚本对 npm registry 做三态判断:
- registry 没有该版本 → 正常发布;
- 有,且 tarball 的 sha512 与已记录 integrity 一致 → 跳过(这是同一次发布的重跑);
- 有,但 integrity 不同 → 失败:内容变了却没升版本。
连「代码变了忘升版本」都会被 integrity 对比抓住。另有两道护栏:
verify-application-entrypoints 强制所有受支持的 Node 应用都从
dsh CLI + named profile 启动(包 bin、可执行源码、根 demo 全部白名单分类,
绕过 dsh 的启动路径一律拒绝);FIXME 注释按约定应阻断发版。
发布成功的最后一项义务:核对 Session writer 与 release record,
发了更高格式就更新 docs/session-format-status.md 的双语记录与证据链接。
release 工作流必须 fetch-depth: 0——release 脚本靠读历史 tag 判断各包是否变更,
浅克隆会把 vendor 的变更判断退化成「全部重新发布」。本地验收发布链前三个命令即可,
第四步永远属于 GitHub Actions。
在上游仓库把彩排链跑起来,看维护者发布前的自检长什么样:
pnpm run release:verify --family dsh输出里的 publishOrder 就是即将发布的完整拓扑序;有兴致再跑
pnpm run release:pack --family dsh --out dist/npm 摸一摸产物。
三个仪表盘:benchmarks · llm-retry · token-meter
仓库边缘有三个不直接做产品功能的包,它们是维护者回答日常问题的仪表盘: 多快(benchmarks)、挂了怎么办(llm-retry)、这次请求多挤(token-meter)。
benchmarks:仓库级性能红线
- 按被测用户路径组织——session-open、agent-continuation、conversation-fold、active-stream-reconnect、long-session-browser——不镜像包树;Host 用
*.bench.ts,Client 用*.bench.client.ts(benchmarks/AGENTS.md)。 - 预算是代码内评审常量,环境变量不得覆盖;输入一律合成固定数据,绝不录 Session、用户素材或网络。
- CI 必需基准在标准
ubuntu-24.04hosted runner 上独立跑(不受 selfhosted failover 影响),整个 job 15 分钟超时;session-open 的 open 端点预算按 50ms CI 期望 × 1.25 余量定为 63ms。 - 维护者介入点:改了被测路径的性能相关代码;预算门变红需要实测校准(阈值变更要求实测数据 + 正负对照);换 runner 必须先跑一次真实 hosted 基准验证超时可信。
llm-retry:模型请求挂了怎么办
llm-retry 自己没有配置——重试策略长在每个 provider adapter(dsh-llm-deepseek、dsh-llm-pi-ai)
的 retryPolicy 里。不配置时是 normal 模式:针对
EMPTY_RESPONSE / RATE_LIMIT / SERVER / TIMEOUT / TRANSPORT
重试 5 次,500ms → 10s 有界指数退避加 10% jitter;always 模式无上限,
直到成功、取消或插件卸载。两条边界要背下来:只有 agent turn 是重试边界,
直接 ctx.llm.stream() 没有重试;重试对模型完全不可见
(事件、延迟、失败都不进模型上下文),而 每次重试都是又一次计费请求——
llm/retry-started 还会结束 token-meter 的 usage 替换域,让重试计入用量。
token-meter:这次请求有多挤
ctx.tokenMeter.measure(session) / estimateMessage(message)
通过重放持久日志做估算:确定性、零模型调用,compaction、占用率 UI、遥测共享同一个答案。
文本用「字符数 + 结构开销」的固定启发式(CJK 与 JSON schema 在四字符一 token 的启发式下会明显低估),
有定价声明的图像走 provider 精确视觉 token;provider 自报 usage 只在请求 envelope 完全一致时复用。
会话投影可用时它注册三个 units:tokenUsage、contextPressure、
contextBreakdown,插件卸载即移除。定位要摆正:参考值,不是账单——
harness 里没有任何决策依赖它。
benchmarks 回答「多快」,红线红了拿实测数据重新校准; llm-retry 回答「挂了怎么办」,策略在 provider 手里,费用在用户账上; token-meter 回答「多挤」,重放日志零模型调用,但它是估计不是计费。
Agent Notes 读法:从「必须写」到「读出门道」
第 10 课讲过「每个非琐碎改动必须同 PR 带一篇 Agent Note」。
这一课补全读法与格式细节——权威手册是 .agents/notes/README.md。
路径即检索
.agents/notes/{lifecycle}/{class}/yyyy-mm-dd-topic.md:
lifecycle 是 proposed / implemented / rejected,
class 是 feature / bug-fix / simplification /
architecture / process / testing 的闭集
(scripts/agent-note-tree.ts 是权威,分类门拒绝其他文件夹)。
组织全在路径里,三个人生阶段乘六个决策领域;没有集中 INDEX.md——
有专门的决策删掉了它,交叉引用一律用相对 markdown 链接(verify-md-links 检查)。
implemented 骨架与 Alternatives considered
- 骨架:
## Problem→## Decision→ 自由技术节 →## Alternatives considered→## Consequences。spec-speak(## Proposal、## Migration plan等)在 implemented 里会被门拒绝。 - Alternatives considered 强制:每个真实备选写一段「它是什么、为何落败」。动机很直白——不记下被击败者,半年后就有人把它当新点子重提。
- proposed → implemented:把提案重写成现在时的决策陈述,验收标准与风险折进 Consequences;移动文件夹必须同一变更内更新 Status 行并满足目标骨架。
- archived 冻结:理由不再指导未来工作的 implemented 笔记移入 archived/,只许整组移动(英/中/sidecar 三件套)、插 Archived 日期行、修入链;此后永不可编辑、不得当现状权威。
- 中文 .zh.md 逐节镜像:header token(
# Agent Note:与Status:行)保持英文,由配对门校验。
写作本身也有门:一段一物理行(verify-md-wrap)、
文档里的 ts 块必须真实编译(doc-typecheck)、根文档有字数预算、
禁止叙述历史与手抄目录。docs 分层口诀一条记牢:
bug → postmortem、理由 → Agent Notes、流程 → cookbook、类型 → subsystems、
包契约 → README、常驻命令 → 根 AGENTS.md——一个事实一个家。
postmortem 的触发标准是三条同时成立:机制不显然(subtle)、因测试/工具/惯例缺口而逃逸
(systemic)、重新发现代价高(costly to rediscover)。
Agent Note 记「为什么这样设计」,postmortem 记「流程为什么漏了这个 bug」。 读笔记时先看 Status 与路径——implemented 是现行依据,archived 只是冻结的历史。
课堂练习
ctx.remote.notes.list() 时,请求实际经过的路径是?正文第 01 节图 11-1:调用路径是 ctx.remote.<ns>.<method>() →
connection.rpc.call('/api', '<ns>/<method>', {args}) →
POST /api/<ns>/<method>,由 Gateway 派发。Connection 管传输/RPC id/取消/信任检查,
Gateway 只管 Remote 数据协议与 dispatch——各层职责不互换。
@Remote 的公有方法会怎样?正文第 01 节:暴露面由 @Remote/@RemoteScope 显式挑选,
Typert 生成器构建期读源码生成两端契约——没标的方法根本不进生成物。
这不是运行时权限拦截(D),也不报错(B),更不会自动暴露(A):
白名单哲学,缺省即不可达。
D 错:tsconfig.base.json 永不加 include/files,否则两个世界被迫看见同一批文件、冲突复活。
E 错:测试文件命名是分脸机制——*.client.* 归 Client、*.host.spec.ts 归 Host,
两个 aggregate 互相 exclude 对方脸的文件。A、B、C 分别是第 02 节的冲突根源、解法精髓与拆分包纪律。
C 错:世代不可变——已提交的 generation 永不重命名/覆盖/删除,迁移结果另存新世代。 E 错:release record 发布后凭证据追加更新,历史条目不改写;而且发布中的相邻边随即冻结, 后续结构变化必须开新边。A、B、D 分别是第 03 节的 bump 判据、迁移包纪律与 release/* 集成节奏。
问答描述一次 dsh 发版从代码合并到 npm 上线的完整流程:三条版本线怎么分工?版本号、tag、彩排、发布各在哪一步、由谁完成?写下答案后点击对照›
参考要点:① 三条线互不等待——dsh(非实验 packages/*/* + apps/*,单一版本,tag dsh-v<version>)、vendor(九个包各自独立版本,tag vendor-<package>-v<version>)、native(node-addon-system);② 版本由维护者本地命令 pnpm run release:dsh -- <major|minor|patch|显式版本> 写进仓库提交,merge 后人工打 tag,CI 永不写仓库;③ release.yml 在每个 PR 与 master push 上无凭证彩排:release:verify(基线 + publishOrder + tag 校验)→ build:official → release:pack → release:verify-packed-install(tarball 装进一次性消费者跑 dsh --version);④ 真发布在 release-publish.yml:workflow_dispatch-only、从 dsh-v* tag 运行、npm-publish 环境人工审批后按拓扑序 publish;⑤ registry 三态:无该版本→发布;sha512 一致→跳过;integrity 不同→失败(代码变了没升版本)。加分项:发布后核对 Session writer 与 session-format-status.md 的 release record。
问答你决定给 Session 日志引入一个结构性格式变化(v3 → v4)。列出要动的每一处、要过的每一道门、以及发布后的义务。写下答案后点击对照›
参考要点:① 先过 bump 判据:只有结构性变化才值得动 SESSION_FORMAT_VERSION;普通事件新增用 ignorable: true;② 在 release/* 集成基分支上提交基础变更(writer、codec、catalog 接线、identity 迁移、验证),独立子分支做结构变换、PR 打回 release 分支;③ 新建 packages/session/session-format-v3-to-v4 迁移包:package.json 声明 dsh.sessionFormatMigration(from/to/export/migration/sourceCodec/targetCodec/targetHeaderValidator/targetRestorer),复用 v3 codec 不复制,Stage 实现里继承 cut 用逻辑事件数、未知 cut 不用 0 顶替;④ 过 pnpm run verify-session-format-catalog(每步恰一条边、禁手改 generated.ts)+ 迁移包测试 + test:snapshot 语料基线;测试用一次性隔离 home,绝不重写已提交世代;⑤ 发布后该边冻结——后续结构变化开新边;发布操作员更新 docs/session-format-status.md 的 release record(latestReleasedVersion 与 evidenceTag),中英双语同步。
本课小结
维护者视角补齐了五块拼图:Remote API 的四层分层与显式暴露(unary 之外没有 Remote)、 双 aggregate 的冲突根源与三条纪律、Session 格式迁移的完整动作 (一个包一条边、release/* 集成、世代不可变)、三条互不等待的发布线 (版本本地落仓库、CI 只彩排、人工审批发布)、三个仪表盘 (多快 / 挂了怎么办 / 多挤),以及 Agent Notes 的路径检索法与骨架门。 到 deep-dive.html 完成 42 题终面, 你就已经从使用者一路走到了维护者的座位。