DSH / Atlas
2026-08-28implementedarchitecture

One Remote failure vocabulary for ctx.remote

One Remote failure vocabulary for ctx.remote

Every Remote owner package maintained its own failure surface: an `XxxErrorDetailsMap` interface, an `XxxError` union derived from it, and an exit mapping function that translated domain error classes (`UnknownPresetError`, `PresetMountError`, `SessionTitleInvalidError`, and their peers) into a wire failure value. `@deepseek-ai/dsh-typert-protocol` carried two failure classes at once — `TypertRemoteFailure` for a fai

English

Problem

Every Remote owner package maintained its own failure surface: an XxxErrorDetailsMap interface, an XxxError union derived from it, and an exit mapping function that translated domain error classes (UnknownPresetError, PresetMountError, SessionTitleInvalidError, and their peers) into a wire failure value. @deepseek-ai/dsh-typert-protocol carried two failure classes at once — TypertRemoteFailure for a failure an owner reported and TypertLookupFailure for one a lookup resolver produced — while @deepseek-ai/dsh-client-connection kept a second typed view, RpcErrorDetailsMap, that hardcoded domain codes such as agent-preset-not-found and session-not-found into the carrier.

One code therefore existed in three places: the owner's table, the carrier's typed view, and whatever union or cast a consumer wrote to narrow it (result.error as SessionError). Adding a domain code meant editing all three, and relaying another domain's code meant copying that code into your own table — SessionErrorDetailsMap had absorbed five foreign codes this way, across agent-preset-*, subagent-*, and workspace-not-found.

Failure information was flattened in two places as well. All 17 of the Gateway's own assembly failures (an unmounted method, an ambiguous endpoint, a lookup provider mismatch, a result that fails its codec) reached the wire as code: 'internal', so a client could not separate an assembly fault from a business refusal; owners defensively pre-folded unrelated exceptions into their own domain codes, so a genuine Host bug arrived at the caller as a plausible-looking domain failure.

Fixed Host facts bypassed ctx.remote too: the Host home came from (ctx.get('connection') as ConnectionHandle).generation.getSnapshot()?.host.home, so every page that needed one fixed fact injected the carrier and understood its generation store.

Decision

@deepseek-ai/dsh-typert-protocol exports one failure class, RemoteError<Code>: a real Error carrying readonly code and details, the structural marker isDSHRemoteError, and standard ErrorOptions (cause holds in-process only). The correspondence between codes and details lives in one merge-extensible RemoteErrorDetailsMap; RemoteFailure is the code-distributed union of instances, and RemoteResult<T> keeps its shape.

export class RemoteError<Code extends RemoteErrorCode = RemoteErrorCode> extends Error {
  readonly isDSHRemoteError: true = true
  constructor(readonly code: Code, message: string,
    readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions)
}
export type RemoteFailure = { [C in RemoteErrorCode]: RemoteError<C> }[RemoteErrorCode]
export type RemoteResult<T> = { ok: true; value: T } | { ok: false; error: RemoteFailure }

A failure point throws directly: throw new RemoteError(code, message, details). A domain builds no error-class family and writes no exit mapping function; only the "classify any provider exception" case keeps one catch, and inside it throw new RemoteError(code, messageOf(error), details, { cause: error }). An existing exception class that an in-process flow still consumes (ApiSessionCwdConflict and its peers) stays as a non-exported private class and converts to a RemoteError in one line at the exit.

A code is a <domain>/<reason> string: session/not-found, gateway/cancelled, workspace/invalid-path, agent-preset/locked. The prefix follows the wire-namespace style, so the code itself says who owns it, and relaying another domain's code no longer needs an awkward unprefixed name.

Code ownership

A code has exactly one declaration site, and the site follows from both who produces it and who can see the declaration — declaration merging only applies where the augmenting file enters the current program, so the home must be a package every producer already sees:

  • Carrier codes: gateway/bad-request, gateway/cancelled, and gateway/internal are declared by the protocol and reachable everywhere.
  • Gateway assembly codes: the 17 gateway/* codes are declared in packages/api/gateway/src/remote-error-codes.ts with the uniform TypertGatewayFaultDetails { endpoint, field? } details; that module is face-neutral and each face imports it, so both programs see the same entries.
  • Produced by several packages: when two or more packages throw the same code, the declaration lands in the lowest layer both already depend on. session/not-found lands in @deepseek-ai/dsh-session (session-controller and workspace-controller both depend on it), and workspace/not-found lands in @deepseek-ai/dsh-workspace (no dependency edge exists between the two API packages, so the capability package is their only shared layer).
  • Single producer: a code only one package throws lands in that producer. subagent/not-found and agent-preset/conflict therefore live in session-controller — it is their only thrower in the repository, and neither the subagent nor the agent-presets table declares them.

What two domains share is validation logic, not a code. session/invalid-time-zone and subagent/invalid-time-zone are two codes each declared and thrown by its own domain, and both endpoints canonicalize through canonicalClientTimeZone() from @deepseek-ai/dsh-util-time; no client branches on this code, so splitting it costs nothing while merging it would recreate the reachability problem.

Discrimination by code

Discrimination always reads code and never uses instanceof. Client and Host are separately bundled programs, and a worker transport bundles the page half once more, so several copies of the same class exist and prototype identity across copies does not hold. The mechanism layer reads the structural marker plus a string code through the protocol's remoteErrorOf(value), and the Gateway client face additionally exports isRemoteFailure(error) for a consumer's catch site; both read those fields, never the class — the test does not even require instanceof Error, because an Error thrown in another realm fails that too.

Business code usually needs neither function: the ok: false branch of RemoteResult is already a typed RemoteFailure, so if (result.error.code === 'session/not-found') narrows details to that code's shape with no cast. A site that must propagate the failure writes throw result.error — it is a real Error, with a working stack and message.

The client plane does not construct RemoteError; the one exception is the Gateway's own client face, which rebuilds an instance from wire data in invoke() and folds carrier throws at stream boundaries into the same vocabulary. A test double that needs a failure value takes RemoteError from @deepseek-ai/dsh-client-test-runtime instead of making a client package import the protocol as a value. Assertions match the code (plus details fields where they matter) with toMatchObject: RemoteError is an Error, its own-key set differs from the former literal, and toEqual fails on it.

Fixed Host facts

ctx.remote.$host exposes two fixed facts: home: string | undefined and isLoopback: boolean. It is a getter on the Client Remote service reading the connection handle captured at service construction — home comes from the ready frame in the generation snapshot (undefined before ready), isLoopback from the carrier. There is no store, no subscription, and no generation counter.

Refresh after a reconnect rides the existing signal: the Client Remote emits connection/reset when it connects, and a consumer that must re-read listens for that or for its own domain's remote event rather than turning $host into a subscribable object. Consumers therefore no longer inject connection: the @deepseek-ai/dsh-client-connection consumer allowlist shrinks to hmr, frontend-static, bundle/web-app, session-log-export, webworker-runtime, and the gateway and api-remotes assemblies.

What the wire carries

The envelope is unchanged: the wire still carries { code, message, details } data, and RemoteError is each side's in-process carrier for it. On the Host, rpcFailure() collapses to two branches — a structurally identified RemoteError is encoded as-is, everything else folds into gateway/internal — and carrier-signal cancellation uses the same vocabulary (the RemoteInvocationCancelled class is deleted, and its four throw points raise RemoteError('gateway/cancelled', …)).

Three wire-visible behaviors follow. The Gateway's 17 assembly codes travel as themselves, so a client can handle "method not mounted" separately from a business refusal. Owners do not pre-fold unrelated exceptions: an unclassified throw reaches the Gateway, which folds it into gateway/internal once and keeps the diagnostic chain in message. A client unary call aborted by its caller answers gateway/cancelled, matching the code the Host would have produced even when the local throw wins the race against the wire round-trip.

The carrier keeps only the open wire shape. ConnectionRpcFailure and ConnectionRpcResult in @deepseek-ai/dsh-client-connection carry no domain-code knowledge, and its transportError() produces gateway/internal; the only home for the typed view is now the protocol's RemoteFailure.

Alternatives considered

A RemoteFault error-class family per domain. Giving each domain (or each code) its own Error subclass reads as more object-oriented, but it splits one fact — the code — across class identity and a field, and cross-realm discrimination has to fall back to the field anyway. Class identity then becomes pure overhead: every domain maintains a subclass, exports it, and explains it in prose, while consumers still branch on code. One class plus one code table trades that weight for a single declaration line.

attempt / unwrap / remoteFailureOf wrappers at call sites. A wrapper saves one if per call site, but it turns RemoteResult from the canonical shape into "first pass it through a library function," and both styles then coexist indefinitely; unwrap additionally turns "failure is a normal result" back into an exception flow, against the Remote face's contract of never rejecting. The remoteErrorOf that survives serves the mechanism layer and test assertions only — business code holds either a typed result.error or a failure it threw itself.

A host/updated event with a subscribed $host store. A subscription would refresh automatically when the Host home changes, but home and isLoopback are fixed for the lifetime of one connection, so a store, generation, and subscription lifecycle would tax every page that only wants one read. Reconnection already has a signal (connection/reset) and business invalidation rides each domain's remote event, so fixed facts stay plain reads.

Putting local, non-wire failures in the code table. ui-goal's no-current-goal never crosses a process boundary; admitting it would mix entries only one client package cares about into a shared vocabulary and would suggest it has wire semantics. Local failures keep their own local types, and the code table describes the Remote vocabulary alone.

Consequences

Adding a domain code is one declaration merge plus one throw: no mapping function, error class, and carrier typed view to keep in step. The cost is that the home now requires a judgment — it must be reachable from every producer — and that judgment only surfaces once a second producer appears; workspace/not-found moved from workspace-controller to the capability package exactly that way, which also gave @deepseek-ai/dsh-workspace a type-only protocol dependency.

Prefixing the code strings changes the wire strings wholesale, so codes embedded in connection fixtures, assertions on both the Host and Client sides, and spec-local declarations all move in one pass. The pre-release stance accepts that single cut; the same rename after a release would need a compatibility window.

The type of details follows from the code, so a code-and-details mismatch is rejected at compile time. The other face of that is every throw site having to supply the code's required detail fields: the protocol makes issues optional on gateway/bad-request precisely so a business validation point with no codec issues still writes {}.

RemoteError is an Error, so it keeps message and cause through any logger and through errorChain(); but cause holds only in-process, and the wire carries exactly code, message, and details. Cross-realm discrimination always reads the structural marker, and any new transport (a worker, a bundle split) must carry that marker or an equivalent marker frame across, or failure values degrade into plain Errors.

Consumer signatures for Remote methods are uniformly Promise<RemoteResult<T>>, matching the generated projection described in the method-call surface; the ledger for the unary endpoints is the unary endpoint migration.

中文

Problem

每个 Remote owner 包各自维护一套失败面:一个 XxxErrorDetailsMap 接口、由它派生的 XxxError union,以及一个出口映射函数,把域内错误类(UnknownPresetError、PresetMountError、SessionTitleInvalidError 等)翻译成 wire 失败值。@deepseek-ai/dsh-typert-protocol 同时携带两个失败类——owner 主动上报用 TypertRemoteFailure,lookup resolver 产生的用 TypertLookupFailure——而 @deepseek-ai/dsh-client-connection 又保留了第二份 typed 视图 RpcErrorDetailsMap,把 agent-preset-not-found、session-not-found 这类域码硬编码进载体层。

于是一个码同时存在三处:owner 的表、载体的 typed 视图、以及消费方为窄化而写的 union 或 cast(result.error as SessionError)。新增一个域码要改三处,跨域转述一个别人的码则要把对方的码复制进自己的表——SessionErrorDetailsMap 就收编了 agent-preset-*、subagent-*、workspace-not-found 五个他域码。

失败信息也在两处被压平。Gateway 自己的 17 个装配失败(未挂载的方法、歧义 endpoint、lookup provider 不匹配、结果未过 codec 等)一律以 code: 'internal' 上 wire,client 无法把装配 bug 与业务拒绝区分开;owner 又出于防御把无关异常预折成自己的域码,于是一个真正的 Host bug 会以一个看起来合理的域失败到达调用方。

Host 固定事实同样绕过了 ctx.remote:Host home 取自 (ctx.get('connection') as ConnectionHandle).generation.getSnapshot()?.host.home,任何只需要一条固定事实的页面都得注入载体并理解它的 generation store。

Decision

@deepseek-ai/dsh-typert-protocol 导出唯一的失败类 RemoteError<Code>:一个真 Error,带只读 code 与 details、结构标记 isDSHRemoteError,以及标准 ErrorOptions(cause 只在进程内有效)。码与 details 的对应关系收进一张 merge-extensible 的 RemoteErrorDetailsMap;RemoteFailure 是按码分布的实例 union,RemoteResult<T> 形状不变。

export class RemoteError<Code extends RemoteErrorCode = RemoteErrorCode> extends Error {
  readonly isDSHRemoteError: true = true
  constructor(readonly code: Code, message: string,
    readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions)
}
export type RemoteFailure = { [C in RemoteErrorCode]: RemoteError<C> }[RemoteErrorCode]
export type RemoteResult<T> = { ok: true; value: T } | { ok: false; error: RemoteFailure }

失败点直接 throw new RemoteError(code, message, details)。域内不再建错误类家族,也不再写出口映射函数;只有「把任意 provider 异常归类」这一种场景保留一个 catch,并在其中 throw new RemoteError(code, messageOf(error), details, { cause: error })。进程内仍需消费的既有异常类(ApiSessionCwdConflict 等)保留为不导出的私有类,在出口一行转成 RemoteError。

码是 <语义域>/<理由> 形式的字符串:session/not-found、gateway/cancelled、workspace/invalid-path、agent-preset/locked。前缀与 wire namespace 同风格,读者从码本身就能看出它属于谁,跨域转述时也不再需要一个别扭的无前缀名。

Code ownership

一个码只有一个声明处,落点由「谁生产它」和「声明对谁可达」共同决定——声明合并只在增补文件进入当前 program 时生效,所以正家必须是每个生产者都能看见的包:

  • 载体码:gateway/bad-request、gateway/cancelled、gateway/internal 由 protocol 声明,人人可达。
  • Gateway 装配码:17 个 gateway/* 由 packages/api/gateway/src/remote-error-codes.ts 声明,details 统一为 TypertGatewayFaultDetails { endpoint, field? };该模块 face-neutral,Host 与 Client 两面各自 import,因此两个 program 看到同一批条目。
  • 跨包共产:两个及以上不同包抛同一个码时,声明落到双方都已依赖的最低层。session/not-found 落 @deepseek-ai/dsh-session(session-controller 与 workspace-controller 都依赖它),workspace/not-found 落 @deepseek-ai/dsh-workspace(session-controller 与 workspace-controller 之间没有依赖边,能力包是唯一共同下层)。
  • 单一生产者:只有一个包抛的码落生产者包。subagent/not-found 与 agent-preset/conflict 因此落 session-controller——全仓只有它抛这两个码,subagent 与 agent-presets 的码表里都没有它们。

共享的是校验逻辑,不是码。session/invalid-time-zone 与 subagent/invalid-time-zone 是两个域各自声明、各自抛出的两个码,两个端点共用 @deepseek-ai/dsh-util-time 的 canonicalClientTimeZone() 做规范化;client 对这个码没有分支语义,拆码的成本是零,而合成一个码就会重新制造可达性问题。

Discrimination by code

判别一律读 code,从不用 instanceof。Client 与 Host 是两个独立打包的 program,worker 传输还会把页面侧再分一次包,因此同一个类会存在多份副本,跨副本的原型链身份不成立。机制层用 protocol 的 remoteErrorOf(value) 读结构标记加一个字符串 code,Gateway client face 另外导出 isRemoteFailure(error) 供消费方在 catch 里判别;两者都只看这两个字段、不看类——连 instanceof Error 都不要求,因为另一个 realm 抛出的 Error 同样通不过它。

业务代码通常连这两个函数都不需要:RemoteResult 的 ok: false 分支已经是类型化的 RemoteFailure,if (result.error.code === 'session/not-found') 就把 details 窄化到该码的形状,无需 cast。需要向上抛的站点直接 throw result.error——它是真 Error,栈与 message 都成立。

client 面不构造 RemoteError:唯一例外是 Gateway 的 client face 本身,它在 invoke() 里按 wire 数据重建实例、在流边界把载体 throw 折进同一词汇。测试替身要构造失败值时从 @deepseek-ai/dsh-client-test-runtime 取 RemoteError,而不是让 client 包值引入 protocol。断言用 toMatchObject 判 code(必要时加 details 字段):RemoteError 是 Error,own key 集合与旧字面量不同,toEqual 会失败。

Fixed Host facts

ctx.remote.$host 暴露两条固定事实:home: string | undefined 与 isLoopback: boolean。它是 Client Remote service 上的 getter,读的是 service 构造期取得的 connection 句柄——home 来自 generation 快照的 ready frame(ready 之前是 undefined),isLoopback 来自载体。没有 store、没有订阅、没有 generation 计数器。

重连后的刷新走既有信号:Client Remote 在连上时 emit connection/reset,需要重取的消费方监听它或各域自己的 remote event,而不是让 $host 变成一个可订阅对象。因此消费方不再注入 connection:@deepseek-ai/dsh-client-connection 的消费白名单收缩到 hmr、frontend-static、bundle/web-app、session-log-export、webworker-runtime、gateway 与 api-remotes 装配。

What the wire carries

envelope 不变:wire 上仍是 { code, message, details } 数据,RemoteError 是两端各自的进程内载体。Host 侧 rpcFailure() 收敛为两分支——结构识别出的 RemoteError 原样编码,其余折成 gateway/internal;载体信号取消也走同一词汇(RemoteInvocationCancelled 类整体删除,四个 throw 点改抛 RemoteError('gateway/cancelled', …))。

三条 wire 可见行为随之确定。Gateway 的 17 个装配码按语义上 wire,client 因此能把「方法未挂载」与「业务拒绝」分开处理。owner 不预折无关异常:未归类的 throw 交给 Gateway 折一次 gateway/internal,诊断串保留在 message 里。client 一元调用被调用方 abort 时答 gateway/cancelled,即使本地 throw 抢在 wire 往返之前赢得竞争,也与 Host 会给出的码一致。

载体层只保留开放的 wire 形状。@deepseek-ai/dsh-client-connection 的 ConnectionRpcFailure/ConnectionRpcResult 不含任何域码知识,其 transportError() 产出 gateway/internal;typed 视图的正家从此只有 protocol 的 RemoteFailure。

Alternatives considered

每域一套 RemoteFault 错误类家族。 让每个域(或每个码)有自己的 Error 子类,看起来更 OO,但它把「码」这一条信息拆成了类身份加字段两处,跨 realm 又只能退回判字段——于是类身份成为纯粹的负担:每个域要维护子类、导出它、在文档里解释它,而消费方仍然只能判 code。单类加一张码表把这份重量换成了一行声明。

在调用点加 attempt / unwrap / remoteFailureOf 包装函数。 包装能让调用点少写一个 if,但它把 RemoteResult 这个 canonical 形状变成了「先过一层库函数」,两种风格会长期并存;unwrap 还会把「失败是正常结果」重新变成异常流,与 Remote 面不 reject 的契约背道而驰。被保留的 remoteErrorOf 只服务机制层与测试断言,业务代码拿到的要么是已类型化的 result.error、要么是自己抛的,不需要它。

host/updated 事件加订阅式 $host store。 订阅能在 Host home 变化时自动刷新,但 home 与 isLoopback 在一条连接内是固定事实,为它引入 store、generation 与订阅生命周期,等于让每个只想读一次的页面都承担一套状态管理。重连是已有信号(connection/reset),业务失效走各域 remote event,固定事实保持普通值读取。

把不上 wire 的本地失败也纳入码表。 例如 ui-goal 的 no-current-goal:它从不跨进程,纳入码表会让共享词汇混入只有一个 client 包关心的条目,还会误导读者以为它有 wire 语义。本地失败保持各自的本地类型,码表只描述 Remote 词汇。

Consequences

新增一个域码是一处 declaration merging 加一个 throw:不再有映射函数、错误类、载体 typed 视图三处联动。代价是落点需要判断——正家必须对每个生产者可达,而这条判断只有在真的出现第二个生产者时才显现;workspace/not-found 就是这样从 workspace-controller 迁到能力包的,并为此给 @deepseek-ai/dsh-workspace 加了一条 type-only 的 protocol 依赖。

码字符串带前缀后,wire 字符串整体变化,connection fixture 内嵌的码、host 与 client 两侧断言、spec 本地 declare 一次性同步。发布前阶段接受这次一波切;发布后同样的改名需要一个兼容期。

details 的类型由码决定,因此码与 details 的搭配错误在编译期就被拒。反面是每个抛点都要给全 details 的必填字段:protocol 把 gateway/bad-request 的 issues 设为可选,正是为了让没有 codec issues 的业务校验点仍然只写 {}。

RemoteError 是 Error,所以它进任何日志与 errorChain() 都保留 message 与 cause;但 cause 只在进程内成立,wire 上只有 code、message、details 三个字段。跨 realm 的判别永远读结构标记,任何新增的传输(worker、bundle 分片)都必须把标记或等价的 marker 帧带过去,否则失败值会退化为普通 Error。

Remote 方法的消费端签名统一为 Promise<RemoteResult<T>>,与方法调用面描述的生成投影一致;一元调用的迁移账本见一元端点迁移。