diff --git a/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.i18n.yaml index abb55cc623..df64ebf865 100644 --- a/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.i18n.yaml @@ -9,11 +9,11 @@ en: bf506a273f12562e zh: 5e97cd5f975f771d /agent-note-deprecated-projection-registration-compatibility/decision: - en: ed409b59b3364797 - zh: 9089c8537c215cf7 + en: 933fedc151b0de59 + zh: 3cf26884bc7c3c29 /agent-note-deprecated-projection-registration-compatibility/alternatives-considered: en: 9a71e47f7692dfd3 zh: 9cd497ede37faca9 /agent-note-deprecated-projection-registration-compatibility/consequences: - en: 28b20cfd57ec9005 - zh: f747a421f9e98173 + en: 1032d9dc480c1b9f + zh: 619c3181828a6595 diff --git a/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.md b/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.md index ba87d883a0..0be30b24a2 100644 --- a/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.md +++ b/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.md @@ -12,7 +12,7 @@ Existing external plugins use the disposer returned by projection registration a The [retained-reader decision](2026-10-12-session-projection-registration-handles.md) remains the normal API. A registration is Promise-compatible and callable: awaiting it yields a handle, while calling it preserves the old disposal operation. The callable value is the original Cordis effect disposer so a composed effect can adopt its ownership and control release order. Keyed reads remain a deprecated runtime API returning `unknown`. TypeScript consumers migrate their declarations; the old host state map and generic signatures are not restored. Each registry instance owns one-time console warnings for legacy registration and keyed reads; warning state has no process-global owner. -Definition installation stays synchronous and handle delivery stays asynchronous. The registry observes acquisition and the existing `fiber.await()` result without changing Cordis or delaying activation. After startup settles, an unobserved registration rejection is logged and the calling plugin is disposed through its existing API. This contains the failed plugin but does not make registration part of the activation promise. A plugin that explicitly observes the returned Promise retains error-handling responsibility, including when it awaits registration later in its asynchronous initialization. +Definition installation stays synchronous and handle delivery stays asynchronous. The registry observes acquisition and the existing `fiber.await()` result without changing Cordis or delaying activation. After startup settles, an unobserved registration rejection is logged and the calling plugin is disposed through its existing API, unless the owner is already unloading or disposed. This leaves a brief interval when its services can be visible and ends in `DISPOSED` rather than retryable `FAILED`; failure containment does not join the activation promise. A plugin that explicitly observes the returned Promise retains error-handling responsibility, including when it awaits registration later in its asynchronous initialization. This partially supersedes the earlier decision's unconditional migration requirement. Retained handles still fold after release, keyed reads still lose access after the final release, and new code uses private handles or owning domain services. Synchronous `try/catch` cannot catch asynchronous acquisition failure. Compatibility relies on the current synchronous definition installation; introducing asynchronous preparation requires revisiting legacy consumers that read before activation rather than silently returning incomplete state. @@ -26,4 +26,4 @@ This partially supersedes the earlier decision's unconditional migration require ## Consequences -Legacy plugins can migrate independently while new code keeps explicit asynchronous acquisition. The compatibility layer retains runtime calls and once-per-registry diagnostics. Focused tests cover modern error recovery, ignored rejection reporting and disposal, callable and composed disposal, owner unload during acquisition, and independent warning ownership. A real Loader subprocess exercises an unchanged-style plugin against the package entry points and verifies its reads and console warnings. +Legacy plugins can migrate independently while new code keeps explicit asynchronous acquisition. The compatibility layer retains synchronous runtime composition and once-per-registry diagnostics. An async function or async generator assimilates the callable thenable into its handle, so legacy async return/yield composition still requires migration. Native-Promise-only consumers use `Promise.resolve(registration)`. Focused tests cover modern error recovery, ignored rejection reporting and disposal, callable and composed disposal, owner unload during acquisition, and independent warning ownership. A real Loader subprocess exercises an unchanged-style plugin against the package entry points and verifies its reads and console warnings. diff --git a/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.zh.md b/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.zh.md index 8aea651fb0..cddae550c0 100644 --- a/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-19-session-projection-compatibility.zh.md @@ -12,7 +12,7 @@ Status: implemented [保留读取句柄的决策](2026-10-12-session-projection-registration-handles.zh.md)仍是常规 API。注册返回值兼容 Promise 且可调用:等待它得到句柄,调用它保留旧的释放操作。这个可调用值就是原有 Cordis effect disposer,使组合 effect 能接管其所有权并控制释放顺序。按键读取作为已弃用的运行时 API 保留,返回类型为 `unknown`。TypeScript 消费方仍需迁移声明;旧宿主状态映射和泛型签名不再恢复。每个 registry 实例拥有针对旧注册和按键读取的一次性控制台警告;警告状态不归进程全局对象所有。 -定义安装保持同步,句柄交付保持异步。registry 观察获取结果和现有 `fiber.await()` 的结果,不修改 Cordis,也不延迟激活。启动结束后,未被观察的注册拒绝会被记录,并通过现有 API 卸载调用方插件。这会停止故障插件,但不会把注册加入激活 Promise。显式观察返回 Promise 的插件仍负责处理错误,包括在异步初始化稍后才等待注册的情况。 +定义安装保持同步,句柄交付保持异步。registry 观察获取结果和现有 `fiber.await()` 的结果,不修改 Cordis,也不延迟激活。启动结束后,未被观察的注册拒绝会被记录,并通过现有 API 卸载调用方插件,除非 owner 已在卸载或已释放。这会留下服务短暂可见的间隔,最终进入 `DISPOSED` 而非可重试的 `FAILED`;故障处理不会加入激活 Promise。显式观察返回 Promise 的插件仍负责处理错误,包括在异步初始化稍后才等待注册的情况。 这部分取代了先前决策中无条件迁移的要求。保留的句柄仍可在释放后折叠事件,按键读取仍在最后一次释放后失去访问,新代码使用私有句柄或所属领域服务。同步 `try/catch` 无法捕获异步获取失败。兼容性依赖当前同步安装定义的实现;引入异步准备时,需要重新处理在激活前读取的旧消费方,不能静默返回不完整状态。 @@ -26,4 +26,4 @@ Status: implemented ## Consequences -旧插件可以独立迁移,新代码保留显式异步获取。兼容层保留运行时调用和每个 registry 一次的诊断。定向测试覆盖现代错误恢复、忽略拒绝后的报错与卸载、可调用与组合释放、获取期间 owner 卸载,以及独立的警告所有权。真实 Loader 子进程通过包入口运行保持旧写法的插件,并验证读取结果和控制台警告。 +旧插件可以独立迁移,新代码保留显式异步获取。兼容层保留同步运行时组合和每个 registry 一次的诊断。异步函数或异步生成器会把可调用的 thenable 吸收为句柄,因此旧的异步 return/yield 组合仍需迁移。仅接受原生 Promise 的消费方使用 `Promise.resolve(registration)`。定向测试覆盖现代错误恢复、忽略拒绝后的报错与卸载、可调用与组合释放、获取期间 owner 卸载,以及独立的警告所有权。真实 Loader 子进程通过包入口运行保持旧写法的插件,并验证读取结果和控制台警告。 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 29371e83ff..7cfd45f1d3 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -316,7 +316,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package * Retained handles remain readable after release. * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns a ready handle with synchronous state reads and the effect disposer. + * @returns an awaitable yielding a ready handle; the callable runtime value is deprecated. */ register( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> @@ -325,7 +325,7 @@ register( definition: Omit( definition: ProjectionDefinition & { key: Exclude }, ): Promise> diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 254e62642a..00279d8cd1 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -316,7 +316,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package * Retained handles remain readable after release. * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns a ready handle with synchronous state reads and the effect disposer. + * @returns an awaitable yielding a ready handle; the callable runtime value is deprecated. */ register( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> @@ -325,7 +325,7 @@ register( definition: Omit( definition: ProjectionDefinition & { key: Exclude }, ): Promise> diff --git a/docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.i18n.yaml new file mode 100644 index 0000000000..04796e1641 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.md +/: + en: 1a25bfb7e416f46a + zh: ef6c593731b3060d +/session-projection-registration-returns-a-callable-thenable: + en: b6dc85dc2b48b22d + zh: e35f32e364693189 +/session-projection-registration-returns-a-callable-thenable/change: + en: e577472dc17a0152 + zh: 167312dd4ab3b2b2 +/session-projection-registration-returns-a-callable-thenable/migration: + en: 324f8178180519c8 + zh: e38782e76b4dc244 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.md new file mode 100644 index 0000000000..baeb72219d --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Session projection registration returns a callable thenable instead of a native Promise." +--- + +# Session projection registration returns a callable thenable + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-session-projection` keeps the declared `register(definition): Promise>` API, but its runtime return value changes from a native Promise to a callable thenable. `await`, `.then()`, `.catch()`, and `.finally()` still deliver the same retained handle. `typeof registration` is now `"function"`, and `registration instanceof Promise` is false. Code that branches on those checks or treats functions as callbacks is affected; Vitest's `.rejects` can call the disposer instead of observing the registration error. + +The callable value supports deprecated synchronous disposer usage by compiled plugins. Registry-level `stateOf(session, key): unknown` is also available again as a deprecated runtime API; new code reads through retained handles. Async return/yield assimilates it into the handle, which is not a valid Cordis effect. See [runtime compatibility](../../../../packages/session/session-projection/README.md#deprecated-plugin-compatibility) for warning timing and ignored-error handling. + +## Migration + +1. Keep awaiting `ctx.sessionProjections.register(definition)` and use the returned handle's `stateOf(session)` and `dispose()` methods. In async plugins or effects, await registration and return `void`; the owning fiber releases the registration automatically. +2. Where a native Promise is required, pass `Promise.resolve(registration)`. Replace checks on the registration's `typeof` or `instanceof Promise` with ordinary awaiting. In Vitest, write `await expect(Promise.resolve(registration)).rejects.toThrow(...)`. +3. Run plugin initialization and disposal tests, including a rejected registration. Confirm that the expected registration error is observed and released registrations no longer participate in snapshots. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.zh.md new file mode 100644 index 0000000000..80a85cbe44 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Session 投影注册返回可调用的 thenable,取代原生 Promise。" +--- + +# Session 投影注册返回可调用的 thenable + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-projection` 保持声明的 `register(definition): Promise>` API,但运行时返回值从原生 Promise 变为可调用的 thenable。`await`、`.then()`、`.catch()` 和 `.finally()` 仍交付相同的保留句柄。`typeof registration` 现在为 `"function"`,`registration instanceof Promise` 为 false。按这些检查分支或把函数视为回调的代码会受影响;Vitest 的 `.rejects` 可能调用 disposer,而不是观察注册错误。 + +可调用值支持已编译插件中已弃用的同步 disposer 用法。registry 级 `stateOf(session, key): unknown` 也恢复为已弃用的运行时 API;新代码通过保留句柄读取。异步 return/yield 会将其吸收为句柄,而句柄不是有效的 Cordis effect。警告时机和忽略错误的处理方式见[运行时兼容说明](../../../../packages/session/session-projection/README.zh.md#deprecated-plugin-compatibility)。 + +## 迁移 + +1. 继续等待 `ctx.sessionProjections.register(definition)`,并使用返回句柄的 `stateOf(session)` 和 `dispose()` 方法。在异步插件或 effect 中等待注册并返回 `void`;所属 fiber 会自动释放注册。 +2. 需要原生 Promise 时传入 `Promise.resolve(registration)`。将对注册值的 `typeof` 或 `instanceof Promise` 检查改为正常等待。在 Vitest 中使用 `await expect(Promise.resolve(registration)).rejects.toThrow(...)`。 +3. 运行插件初始化和释放测试,包括注册拒绝用例。确认能观察到预期注册错误,且已释放的注册不再参与快照。 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6aa344b3b8..fa2c14fed8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2391,13 +2391,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'register( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): Promise>', description: 'Register one domain\'s unit on the calling context\'s fiber. Fiber or handle disposal releases one reference; the final release removes live participation. Retained handles remain readable after release. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], - returns: 'a ready handle with synchronous state reads and the effect disposer.', + returns: 'an awaitable yielding a ready handle; the callable runtime value is deprecated.', }, { signature: 'register( definition: ProjectionDefinition & { key: Exclude }, ): Promise>', description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], - returns: 'a ready handle with synchronous state reads and the effect disposer.', + returns: 'an awaitable yielding a ready handle; the callable runtime value is deprecated.', }, { signature: 'onChanged(listener: ProjectionChangeListener): () => void', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index e4ee58b298..9e6b3af0e1 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -27,8 +27,8 @@ en: 976fb019501a941b zh: e15038a7205337c3 /deepseek-ai-dsh-session-projection/use-this-package/deprecated-plugin-compatibility: - en: 126263f35037a8f6 - zh: cda4c5b699a42dff + en: 100a3aa6acb707fe + zh: ba1515027e504c3a /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 3bbc61a438aeabd0 zh: bd1824f4da278764 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 2f2e14d88d..c82ebbf66b 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -68,11 +68,15 @@ A domain that owns projected state declares `sessionProjections` as a Cordis ser ### Deprecated plugin compatibility -`register(definition)` keeps its public return type `Promise>`. Its runtime value remains callable for old compiled plugins: awaiting it delivers the retained handle, while calling it releases the registration through the original Cordis effect disposer. Calling the return value is deprecated; use `handle.dispose()` in new code. Legacy `return register(...)`, `ctx.effect(() => register(...))`, and generator yields retain their cleanup ownership. Promise assertions that treat functions as callbacks must wrap the registration in `Promise.resolve(...)`. +`register(definition)` keeps its public return type `Promise>`, but the runtime value is a callable thenable, not a native `Promise` (`typeof` is `"function"`; `instanceof Promise` is false). Awaiting it delivers the retained handle; calling it releases the registration through the original Cordis effect disposer. Calling the return value is deprecated; use `handle.dispose()` in new code. Use `Promise.resolve(registration)` when an API requires a native Promise or treats functions as callbacks, including Vitest Promise assertions. See the [upgrade guide](../../../docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.md). -Legacy `stateOf(session, key)` is deprecated and returns `unknown`. Reading a registered key materializes every registered unit at the Session cursor without producing unrelated wire views. An unregistered or fully released key returns `undefined`, while retained handles remain readable. Compatibility covers runtime calls; TypeScript consumers migrate their type declarations. Each registry instance emits one `console.warn` for legacy registration usage and one for keyed reads, regardless of how many plugins use them. Awaited registrations and handle methods emit none. +Legacy synchronous `return register(...)`, `ctx.effect(() => register(...))`, and synchronous generator yields retain cleanup ownership. Returning or yielding the registration through an async function or async generator assimilates the thenable into a handle, which Cordis rejects as `Invalid effect`. Async plugins and effects await registration and return `void`; the owning fiber releases the registration automatically. -Definitions still install synchronously, and handle delivery remains asynchronous. The compatibility layer observes startup through the existing fiber API without delaying activation. After startup settles, an unobserved registration rejection is logged and the calling plugin is disposed; it is not propagated as an activation failure. An explicitly observed rejection remains the caller's responsibility. New plugins await registration before exposing readers. Synchronous `try/catch` cannot catch asynchronous registration errors. +Legacy `stateOf(session, key)` is deprecated and returns `unknown`. Reading a registered key materializes every registered unit at the Session cursor without producing unrelated wire views. An unregistered or fully released key returns `undefined`, while retained handles remain readable. Compatibility covers runtime calls; TypeScript consumers migrate their type declarations. + +Each registry instance emits at most one `console.warn` for legacy registration and one for keyed reads; these warnings bypass logger filtering. A registration created during plugin initialization warns if it remains unobserved when startup settles. Awaiting or chaining it before startup completes suppresses that warning; awaiting it afterward may be too late. Registrations created after activation warn only when directly called as disposers. Handle methods and owner cleanup do not warn. + +Definitions install synchronously, and handle delivery remains asynchronous. The compatibility layer observes startup without delaying activation. An unobserved rejection is logged after startup and the plugin is disposed: its services can briefly become visible, and its final state is `DISPOSED`, not `FAILED`; recovery requires loading the plugin again rather than updating that fiber. A plugin already unloading or disposed needs no additional warning or failure report. Explicitly observed rejections remain the caller's responsibility. New plugins await registration before exposing readers. Synchronous `try/catch` cannot catch asynchronous registration errors. ### Persisted checkpoints diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 0b6010b514..7821c30bba 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -68,11 +68,15 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### 已弃用的插件兼容接口 -`register(definition)` 的公开返回类型保持为 `Promise>`。运行时值仍可被旧的已编译插件调用:等待它会得到保留的句柄,调用它则通过原有 Cordis effect disposer 释放注册。调用返回值的用法已弃用;新代码使用 `handle.dispose()`。旧的 `return register(...)`、`ctx.effect(() => register(...))` 和生成器 yield 保留原有清理所有权。把函数视为回调的 Promise 断言需要用 `Promise.resolve(...)` 包装注册返回值。 +`register(definition)` 的公开返回类型保持为 `Promise>`,但运行时值是可调用的 thenable,并非原生 `Promise`(`typeof` 为 `"function"`,`instanceof Promise` 为 false)。等待它会得到保留的句柄,调用它则通过原有 Cordis effect disposer 释放注册。调用返回值的用法已弃用;新代码使用 `handle.dispose()`。API 要求原生 Promise 或把函数视为回调时,包括 Vitest 的 Promise 断言,使用 `Promise.resolve(registration)`。参见[升级指南](../../../docs/upgrade-guide/v0.2.1-rc.2/session-projection-registration/guide.zh.md)。 -旧的 `stateOf(session, key)` 已弃用,返回类型为 `unknown`。读取已注册的键时,它会将所有已注册单元的状态计算到 Session 当前游标,不生成无关的客户端视图。未注册或已完全释放的键返回 `undefined`,保留的句柄则仍可读取。兼容范围限于运行时调用;TypeScript 消费方仍需迁移类型声明。每个 registry 实例针对旧注册用法和按键读取各输出一次 `console.warn`,不随使用它们的插件数量增加。等待注册和调用句柄方法不产生警告。 +旧的同步 `return register(...)`、`ctx.effect(() => register(...))` 和同步生成器 yield 保留原有清理所有权。通过异步函数返回或异步生成器 yield 注册值时,thenable 会被吸收为句柄,Cordis 会以 `Invalid effect` 拒绝它。异步插件和 effect 等待注册完成后返回 `void`;所属 fiber 会自动释放注册。 -定义仍同步安装,句柄交付保持异步。兼容层通过现有 fiber API 观察启动结果,不延迟激活。启动结束后,未被观察的注册拒绝会被记录,并触发调用方插件卸载;它不会作为激活失败向上传播。被显式观察的拒绝仍由调用方负责。新插件在提供读取操作前等待注册完成。同步 `try/catch` 无法捕获异步注册错误。 +旧的 `stateOf(session, key)` 已弃用,返回类型为 `unknown`。读取已注册的键时,它会将所有已注册单元的状态计算到 Session 当前游标,不生成无关的客户端视图。未注册或已完全释放的键返回 `undefined`,保留的句柄则仍可读取。兼容范围限于运行时调用;TypeScript 消费方仍需迁移类型声明。 + +每个 registry 实例针对旧注册和按键读取各输出至多一次 `console.warn`;这些警告不受 logger 过滤影响。插件初始化期间创建的注册,如果在启动结束时仍未被观察,就会产生警告。在启动完成前等待它或调用 Promise 链方法会抑制该警告;之后再等待可能已来不及。激活后创建的注册只在被直接作为 disposer 调用时警告。句柄方法和 owner 清理不产生警告。 + +定义同步安装,句柄交付保持异步。兼容层观察启动结果,不延迟激活。未被观察的拒绝在启动后记录,并触发插件卸载:其服务可能短暂可见,最终状态为 `DISPOSED` 而非 `FAILED`;恢复需要重新加载插件,不能更新该 fiber。已在卸载或已释放的插件无需额外警告或失败报告。被显式观察的拒绝仍由调用方负责。新插件在提供读取操作前等待注册完成。同步 `try/catch` 无法捕获异步注册错误。 ### 持久检查点 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index faaaf4a9f8..5db3e3795b 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -284,7 +284,7 @@ export class SessionProjectionRegistry extends Service { * Retained handles remain readable after release. * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns a ready handle with synchronous state reads and the effect disposer. + * @returns an awaitable yielding a ready handle; the callable runtime value is deprecated. */ register( definition: Omit, 'wire'> & { @@ -296,11 +296,12 @@ export class SessionProjectionRegistry extends Service { * and always checkpointed like every other unit. * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns a ready handle with synchronous state reads and the effect disposer. + * @returns an awaitable yielding a ready handle; the callable runtime value is deprecated. */ register( definition: ProjectionDefinition & { key: Exclude }, ): Promise> + /** Legacy runtime behavior: [plugin compatibility](../README.md#deprecated-plugin-compatibility). */ register( definition: ProjectionDefinition, ): Promise> { @@ -335,14 +336,12 @@ export class SessionProjectionRegistry extends Service { })() if (owner.state === FiberState.LOADING) { // Observe startup without delaying it; the plugin may await registration later in apply(). - void Promise.allSettled([owner.await(), ready]).then(([activation, result]) => { - if (observed) return + void Promise.allSettled([owner.await(), ready]).then(([, result]) => { + if (observed || owner.state === FiberState.UNLOADING || owner.state === FiberState.DISPOSED) return this.warnCompatibility('register') if (result.status === 'rejected') { - if (activation.status === 'fulfilled' || activation.reason !== result.reason) { - owner.ctx.logger.error(result.reason) - } - void owner.dispose().catch((error: unknown) => { owner.ctx.logger.error(error) }) + owner.ctx.logger.error(result.reason) + void Promise.resolve(owner.dispose()).catch((error: unknown) => { owner.ctx.logger.error(error) }) } }) } @@ -361,7 +360,7 @@ export class SessionProjectionRegistry extends Service { }, [Symbol.toStringTag]: 'Promise', } - // Preserve the exact effect identity so generator effects can adopt its cleanup. + // Keep the original disposer for composed cleanup; replace its then() so awaiting yields the handle. return Object.assign(dispose, promise) } diff --git a/packages/session/session-projection/tests/compatibility.spec.ts b/packages/session/session-projection/tests/compatibility.spec.ts index e5527e3915..3aee6d2127 100644 --- a/packages/session/session-projection/tests/compatibility.spec.ts +++ b/packages/session/session-projection/tests/compatibility.spec.ts @@ -1,5 +1,6 @@ /** Legacy projection calls keep their effect ownership while new callers await retained readers. */ /* oxlint-disable typescript/no-deprecated -- exercise the public compatibility API. */ +import { setImmediate } from 'node:timers/promises' import { Context, FiberState } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -47,6 +48,8 @@ describe('projection registration compatibility', () => { inject: ['sessionProjections'], async apply(inner) { const registration = inner.sessionProjections.register(definition) + expect(registration).not.toBeInstanceOf(Promise) + expect(Promise.resolve(registration)).toBeInstanceOf(Promise) const handle = await registration.then(value => value).catch(() => { throw new Error('unexpected rejection') }) expect(await registration.finally(settled)).toBe(handle) expect(handle.stateOf(session)).toBe(0) @@ -138,6 +141,23 @@ describe('projection registration compatibility', () => { expect(hostInit).toHaveBeenCalledOnce() }) + it('warns for startup registration even when the caller awaits it after activation', async () => { + const { ctx, warning } = await setup() + const warned = Promise.withResolvers() + warning.mockImplementation(() => { warned.resolve(undefined) }) + let registration!: ReturnType + await ctx.plugin({ + inject: ['sessionProjections'], + apply(inner) { + registration = inner.sessionProjections.register(definition) + }, + }) + await warned.promise + const handle = await registration + await handle.dispose() + expect(warning).toHaveBeenCalledOnce() + }) + it('keeps callable early disposal idempotent without invalidating an awaited reader', async () => { const { ctx, warning, session } = await setup() const registration = legacyRegistration(ctx.sessionProjections.register(definition)) @@ -177,6 +197,24 @@ describe('projection registration compatibility', () => { expect(warning).toHaveBeenCalledOnce() }) + it('rejects an async plugin that returns the legacy registration as a disposer', async () => { + const { ctx, warning, session } = await setup() + const errorLog = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { errorLog.mockRestore() }) + const owner = ctx.plugin({ + inject: ['sessionProjections'], + async apply(inner) { + // Compiled legacy callers see a disposer type; async return still assimilates its runtime then(). + return legacyRegistration(inner.sessionProjections.register(definition)) as () => Promise + }, + }) + await expect(owner.await()).rejects.toThrow('Invalid effect') + expect(owner.state).toBe(FiberState.FAILED) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + expect(errorLog).toHaveBeenCalledOnce() + expect(warning).not.toHaveBeenCalled() + }) + it('transfers the original disposer into a composed effect and retains it until earlier cleanup settles', async () => { const { ctx, session } = await setup() const release = Promise.withResolvers() @@ -275,4 +313,24 @@ describe('projection registration compatibility', () => { expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) expect(warning).not.toHaveBeenCalled() }) + + it('silently releases an unobserved registration when its plugin cancels before handoff', async () => { + const { ctx, warning, session } = await setup() + const errorLog = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { errorLog.mockRestore() }) + const owner = ctx.plugin({ + inject: ['sessionProjections'], + apply(inner) { + void inner.sessionProjections.register(definition) + void inner.fiber.dispose() + }, + }) + await owner.await() + // The next event-loop turn includes startup observation and any unhandled-rejection delivery. + await setImmediate() + expect(owner.state).toBe(FiberState.DISPOSED) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + expect(errorLog).not.toHaveBeenCalled() + expect(warning).not.toHaveBeenCalled() + }) })