Просмотр исходного кода

docs(profile-resolution): clarify review assumptions

imccyu 1 неделя назад
Родитель
Сommit
662bdf3833

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
-2026-09-09-profile-resolution-generations.md: 3785f62a77c8b5a7f467153a2fe5cc046c4f7965
-2026-09-09-profile-resolution-generations.zh.md: 3998eda50e4a05691af86a4f7e78a8ce86bc80ab
+2026-09-09-profile-resolution-generations.md: bca98ba8aaacc77d86b8a44526f36d84dbf67a46
+2026-09-09-profile-resolution-generations.zh.md: 93de3163df59722765acffea6c41fdf13cb2133b

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md

@@ -8,7 +8,7 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md)
 
 A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. The current launcher bridges the trees by calculating package precedence at startup and materializing that result as shared symlinks, profile-owned links, or packaged-executable proxy packages. The files persist across processes and installations, require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
 
-The runtime design preserves the existing selection rules rather than introducing a second package policy. It covers imports performed by plugin modules as well as Loader row imports, works in the main thread and Harness-owned Workers, and keeps hot per-resolution overhead within 15% of Node without hooks. Generation replacement accepts only additive package sets and never mutates a live table entry by entry.
+The runtime design preserves the existing selection rules rather than introducing a second package policy. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry.
 
 ## Decision
 
@@ -50,6 +50,8 @@ The resolver does not expose `imported(entry)` and does not observe ModuleJobs,
 
 The implementation lives under `app-boot/src/profile-resolution/`. `service.ts` provides the long-lived `ctx.pluginPackages` and owns the main-thread resolver and Worker-generation lifetimes; `resolver.ts` implements generation lookup and the Node Internal adapters; `worker-bootstrap.ts` installs an inherited generation in one thread. Existing profile selection and disk materialization remain in `profile.ts`. Workers reference the bootstrap only through the public `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` export.
 
+The service definition and provider remain together in `app-boot` because profile boot owns the resolver lifetime. Extracting a separate capability seam becomes warranted when a launcher-independent provider or independently evolving consumers require it.
+
 ### Workers and generation updates
 
 The main thread publishes a structured-clone representation of the current generation through Worker environment data. Each built Harness-owned Worker uses its build banner to obtain its own ESM and CommonJS internal objects and install the same adapters without traversing manifests. The bootstrap bundle has no static package imports; on Windows it temporarily exposes a service-private native cache directory and restores the environment before business code starts. Source Worker entries retain their existing self-contained dependencies. Third-party Workers remain unchanged.
@@ -82,7 +84,7 @@ Packaged-carrier selection, virtual-filesystem adaptation, and Electron ASAR lau
 
 Generation construction is startup or update work, not resolve work, and its absolute latency is reported separately. The hot path consists of scope classification, bare-name extraction, local-before-fallback selection, a Map lookup, and at most one native resolution; a cache hit returns the generation-owned result directly. Out-of-scope calls do not read manifests and cache only whether each parent belongs to the profile scope.
 
-Performance measurements run built JavaScript under plain Node in fresh processes and compare against a process that installs no hook. Seven alternating rounds cover outside, profile-local, and fallback imports through dynamic import, `import.meta.resolve`, require, and `require.resolve`. Across Node 22.19, 24.18, and 26.8, the largest positive hot-path median is 4.5%. On Node 24.18, a 256-package cold workload regresses by at most 11.2% and generation construction takes 16.027 ms median; the 32-package local `require.resolve` case adds 1.033 ms across the batch (+34.7%) from fixed startup cost.
+One-off local measurements taken during implementation ran built JavaScript under plain Node in fresh processes and compared it with a process that installed no hook. The measurement script and results are not committed, and these figures are not a benchmark or CI budget. Seven alternating rounds covered outside, profile-local, and fallback imports through dynamic import, `import.meta.resolve`, require, and `require.resolve`. Across Node 22.19, 24.18, and 26.8, the largest positive hot-path median was 4.5%. On Node 24.18, a 256-package cold workload regressed by at most 11.2% and generation construction took 16.027 ms median; the 32-package local `require.resolve` case added 1.033 ms across the batch (+34.7%) from fixed startup cost.
 
 Behavior tests compare the runtime generation with the disk materializer over the same package trees, then exercise root order, transitive and peer dependencies, local and external precedence, exports and subpath errors, conditions, explicit CommonJS options, source and built Workers, and supported Node versions. Generation tests prove failed construction does not publish partial state and successful replacement is atomic.
 

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。当前启动器在启动时计算包优先级,再将结果物化为共享 symlink、profile 自有链接或打包可执行文件的代理包。文件跨进程和安装版本持续存在,需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。
 
-运行时设计保留现有选包规则,不另建一套包策略。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,在主线程和 Harness 自有 Worker 中工作,并将热 resolve 相对无 hook Node 的开销控制在 15% 以内。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。
+运行时设计保留现有选包规则,不另建一套包策略。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。
 
 ## Decision
 
@@ -50,6 +50,8 @@ resolution generation 列出可用 fallback 包;Loader entries 组成活动插
 
 实现集中在 `app-boot/src/profile-resolution/`。`service.ts` 提供长期存在的 `ctx.pluginPackages`,并拥有主线程 resolver 与 Worker generation 的生命周期;`resolver.ts` 实现 generation 查询和 Node Internal 适配器;`worker-bootstrap.ts` 在线程内安装继承的 generation。旧 profile 选包和磁盘 materialize 逻辑留在 `profile.ts`。Worker 只通过 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` 公开入口引用 bootstrap。
 
+服务定义与提供方继续放在 `app-boot`,因为 profile boot 拥有 resolver 生命周期。出现与 launcher 无关的提供方或需要独立演进的消费方时,再抽出单独的能力 seam。
+
 ### Worker 与 generation 更新
 
 主线程通过 Worker environment data 发布当前 generation 的可结构化克隆表示和 profile scope。每个 Harness 自有 Worker 构建产物通过构建 banner 获取自己的 ESM/CJS Internal 并安装同一适配器,不重新遍历 manifest。bootstrap bundle 不静态导入任何包;在 Windows 上,它会临时暴露一个服务私有的 native cache 目录,并在业务代码启动前恢复环境。源码 Worker 入口保持原有自包含依赖;第三方 Worker 保持不变。
@@ -82,7 +84,7 @@ runtime 模式要求受支持的 Node Internal loader 接口,并且不会创
 
 generation 构造发生在启动或显式更新阶段,不属于单次 resolve,但需要单独报告绝对延迟。热路径只包括 scope 分类、bare name 提取、本地优先判断、Map 查询和至多一次原生解析;缓存命中直接返回 generation 级结果。作用域外调用不读取 manifest,只缓存 parent 是否位于 profile scope。
 
-性能测量用 plain Node 在全新进程中执行构建后的 JavaScript,并以完全没有安装 hook 的进程为基线。七轮交替顺序覆盖 outside、profile-local 和 fallback 的 dynamic import、`import.meta.resolve`、require、`require.resolve`。Node 22.19、24.18 和 26.8 的热路径中位数最大正向回退为 4.5%。Node 24.18 的 256 包 cold workload 最大回退为 11.2%,generation 构造中位数为 16.027 ms;32 包本地 `require.resolve` 因固定启动成本在整批增加 1.033 ms(+34.7%)。
+实现期间的一次性本地测量用 plain Node 在全新进程中执行构建后的 JavaScript,并与完全没有安装 hook 的进程比较。测量脚本和结果未提交,这些数据不是 benchmark 或 CI 预算。七轮交替顺序覆盖 outside、profile-local 和 fallback 的 dynamic import、`import.meta.resolve`、require、`require.resolve`。Node 22.19、24.18 和 26.8 的热路径中位数最大正向回退为 4.5%。Node 24.18 的 256 包 cold workload 最大回退为 11.2%,generation 构造中位数为 16.027 ms;32 包本地 `require.resolve` 因固定启动成本在整批增加 1.033 ms(+34.7%)。
 
 行为测试在同一包树上比较运行时 generation 与磁盘 materializer,再覆盖根顺序、传递依赖和 peer、本地与外层优先级、exports 与 subpath 错误、conditions、显式 CommonJS options、源码和构建 Worker及支持的 Node 版本。generation 测试证明构造失败不发布部分状态,成功换代只做原子引用替换。
 

+ 1 - 1
apps/cli/src/profile-boot.ts

@@ -265,7 +265,7 @@ export interface RunProfileOptions {
   patchFiles: readonly string[]
   /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
   args: readonly string[]
-  /** Module fallback backend; defaults to retained link materialization. */
+  /** Module fallback backend; production launchers omit it and retain link materialization. */
   resolutionMode?: ProfileResolutionMode
 }
 

+ 2 - 0
packages/boot/app-boot/src/profile-resolution/resolver.ts

@@ -485,6 +485,8 @@ export function installProfileResolution(
     const resolveRequest = adaptEsm((request, parent, attributes) => original.call(
       loader, parent as string, { specifier: request, attributes },
     ))
+    // Node 24.12+ adds skipSyncHooks after request. Its presence marks hook
+    // delegation whose outer call already selected a route.
     const wrapped = (parent: string, request: { specifier: string; attributes?: ImportAttributes }, ...rest: unknown[]): ResolveResult => (
       rest.length
         ? Reflect.apply(original, loader, [parent, request, ...rest]) as ResolveResult

+ 13 - 27
packages/preset/agent-presets/src/discovery.ts

@@ -97,14 +97,18 @@ export function entryListProblem(rows: unknown, at = ''): string | undefined {
   return undefined
 }
 
-/**
- * Whether a package specifier resolves from `base` without importing it.
- * @param specifier - the package specifier, possibly carrying a subpath.
- * @param base - the URL to walk up from.
- * @returns true when the package is installed, including an unexported subpath.
- */
+/** Package lookup injected into preset discovery. */
 type PackageResolves = (specifier: string, base: string) => boolean
 
+/**
+ * Whether a package specifier is installed above `base` without importing it.
+ *
+ * The direct disk walk accepts unexported subpaths and rejects stale links
+ * whose package directory no longer exists.
+ * @param name - package specifier, possibly carrying a subpath.
+ * @param base - directory URL whose ancestors contain candidate `node_modules` directories.
+ * @returns true when the package is installed.
+ */
 function packageInstalled(name: string, base: string): boolean {
   const pkg = name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/')
   let dir = fileURLToPath(base)
@@ -119,30 +123,12 @@ function packageInstalled(name: string, base: string): boolean {
 /**
  * Whether one classified row names a module that exists, importing nothing.
  *
- * Each kind is checked by what actually answers it. A package name is looked
- * up on disk — the same upward walk Node's own resolver starts with — and a
- * relative or `file:` specifier is statted, because both name one file.
- * Nothing is evaluated either way, so a row is judged without its plugin
- * observing that discovery looked.
- *
- * `import.meta.resolve` is deliberately not the fallback for a name the disk
- * lookup misses. Its `parentURL` argument only takes effect under
- * `--experimental-import-meta-resolve`, which no launch passes, so it would
- * resolve from THIS module rather than from the harness — reporting a
- * dependency visible only to this package as healthy, and a plugin the mount
- * can import as broken. The resolver that does honour an explicit parent is
- * the Loader's internal one, whose `resolveSync` signature differs between
- * Node 22 and 24 (`ModuleLoader.fromInternal` tags the raw object rather than
- * normalising it); reaching into that for a case the walk already covers buys
- * nothing a supported deployment needs, because every plugin a preset names
- * is installed beside the roster.
- *
- * What that gives up: a package resolvable ONLY through a loader hook — an
- * import map, or a tree with no `node_modules` at all — is reported broken.
- * No supported install produces one.
+ * Package rows delegate to the injected lookup. Relative and `file:` rows use
+ * file metadata. No check evaluates the named module.
  * @param row - the classified specifier, from {@link classifyRowSpecifier}.
  * @param presetBase - directory URL a preset-relative specifier resolves against.
  * @param harnessBase - base URL a package name resolves against.
+ * @param resolves - package lookup selected by the owning caller.
  * @returns true when the row names something that can be imported.
  */
 async function rowResolves(