Browse Source

fix(hmr): retain consecutive profile configuration changes

Turtle 1 month ago
parent
commit
b77c19c376

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
-2026-09-14-current-profile-plugin-management.md: 194e3a36bfd3c1fd09b9a3d091e3cd39f47d8814
-2026-09-14-current-profile-plugin-management.zh.md: 207f0d90ba64363dbfab45978b9390d36db9ef75
+2026-09-14-current-profile-plugin-management.md: a4228b424b740090c6b4dbb0c3441059333d1951
+2026-09-14-current-profile-plugin-management.zh.md: 6a9d477a16c52dd7724b1efedc2ada526f89c6f5

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md

@@ -12,6 +12,8 @@ Web and agent controls need to change a running profile without creating an inde
 
 
 [Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies `ctx.profileContext` as data: profile and resolution locations, startup bundles and invocation overlays. Shared functions compose the current files; this interface contains no callbacks or mutation methods. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and service mutations through one queue. HMR registers the profile watches and `hmr/before-reload` file-lock wrapper during its own initialization, then waits for application readiness before processing edits. The final YAML composition controls whether HMR runs; the launcher installs no fallback. Package mutations take that same file lock inside `hmr.runExclusive()`. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence.
 [Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies `ctx.profileContext` as data: profile and resolution locations, startup bundles and invocation overlays. Shared functions compose the current files; this interface contains no callbacks or mutation methods. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and service mutations through one queue. HMR registers the profile watches and `hmr/before-reload` file-lock wrapper during its own initialization, then waits for application readiness before processing edits. The final YAML composition controls whether HMR runs; the launcher installs no fallback. Package mutations take that same file lock inside `hmr.runExclusive()`. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence.
 
 
+Configuration watches use Chokidar write stabilization by default. Its ordinary change handler discards a second event within 50 ms, so a write immediately after activation can leave the previous bundle running. Stabilized delivery observes the final file instead; file-driven updates pay the stability delay, while direct manager transactions do not. A regression feeds consecutive changes through Chokidar’s real normalization and verifies both applied states.
+
 Profile files remain the persisted state: entry toggles edit only `disabled` in the YAML document, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
 Profile files remain the persisted state: entry toggles edit only `disabled` in the YAML document, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
 
 
 This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
 This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md

@@ -12,6 +12,8 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
 
 
 [插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 通过纯数据 `ctx.profileContext` 提供 profile 与解析位置、启动时组合包和调用级 overlay。共享函数组合当前文件;该接口不包含回调或修改方法。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与 service 修改。HMR 在自身初始化时注册 profile 监听与 `hmr/before-reload` 文件锁包装,等待应用就绪后再处理编辑。最终 YAML 组合决定是否运行 HMR,启动器不安装回退实例。包修改在 `hmr.runExclusive()` 内获取同一文件锁。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
 [插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 通过纯数据 `ctx.profileContext` 提供 profile 与解析位置、启动时组合包和调用级 overlay。共享函数组合当前文件;该接口不包含回调或修改方法。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与 service 修改。HMR 在自身初始化时注册 profile 监听与 `hmr/before-reload` 文件锁包装,等待应用就绪后再处理编辑。最终 YAML 组合决定是否运行 HMR,启动器不安装回退实例。包修改在 `hmr.runExclusive()` 内获取同一文件锁。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
 
 
+配置监听默认使用 Chokidar 写入稳定检测。普通变化处理器会丢弃 50 ms 内的第二个事件,因此激活后立即再次写入可能让之前的组合包继续运行。稳定后交付事件会观察最终文件;文件驱动的更新承担稳定等待,直接管理器事务则不需要。回归测试通过 Chokidar 的真实规范化路径交付连续变化,验证两个状态均被应用。
+
 profile 文件保持为持久状态:条目开关只修改 YAML 文档中的 `disabled`,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
 profile 文件保持为持久状态:条目开关只修改 YAML 文档中的 `disabled`,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
 
 
 这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
 这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。

+ 2 - 2
packages/boot/hmr/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/hmr/README.md
 #   pnpm run verify-translation-pairing --write packages/boot/hmr/README.md
-README.md: 01ab7a8e91af163970f607904cf478a95815c412
-README.zh.md: 23ff8242ac3078f6827a72bafbc652e0a7a99a71
+README.md: 0e90b39778ea3ba38928afc1823b6c8d24dde385
+README.zh.md: ec32478eda821152a128d54eac0d2aa0a17c9815

+ 1 - 1
packages/boot/hmr/README.md

@@ -44,7 +44,7 @@ Existing configurations replace the module name `@deepseek-ai/cordis-plugin-hmr`
 | `ignored` | `["**/node_modules", "**/.*", "cache", "data"]` | Excluded module paths. |
 | `ignored` | `["**/node_modules", "**/.*", "cache", "data"]` | Excluded module paths. |
 | `debounce` | `100` | Milliseconds for combining module changes. |
 | `debounce` | `100` | Milliseconds for combining module changes. |
 
 
-Chokidar options, including polling, retain their existing meaning. Exact configuration watches also observe additions, removals and initially missing parent directories.
+Chokidar options, including polling, retain their existing meaning. Exact configuration watches also observe additions, removals and initially missing parent directories. They default to `awaitWriteFinish: true`: edits wait for Chokidar's 2-second write-stability window, avoiding its lossy change-event throttle. Configure `awaitWriteFinish` to adjust that window; disabling it can miss rapid consecutive edits. Direct Plugin Manager operations apply without waiting for file events.
 
 
 -----
 -----
 
 

+ 1 - 1
packages/boot/hmr/README.zh.md

@@ -44,7 +44,7 @@ kind: "package-reference"
 | `ignored` | `["**/node_modules", "**/.*", "cache", "data"]` | 排除的模块路径。 |
 | `ignored` | `["**/node_modules", "**/.*", "cache", "data"]` | 排除的模块路径。 |
 | `debounce` | `100` | 合并模块变化的毫秒数。 |
 | `debounce` | `100` | 合并模块变化的毫秒数。 |
 
 
-Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时观察新增、删除及初始不存在的父目录。
+Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时观察新增、删除及初始不存在的父目录。它们默认使用 `awaitWriteFinish: true`:编辑后等待 Chokidar 的 2 秒写入稳定窗口,避免其变化事件节流丢失通知。可通过 `awaitWriteFinish` 调整窗口;禁用它可能漏掉快速连续编辑。直接通过 Plugin Manager 发起的操作无需等待文件事件即可应用。
 
 
 -----
 -----
 
 

+ 3 - 2
packages/boot/hmr/src/watch-config.ts

@@ -28,7 +28,7 @@ async function findWatchRoot(filename: string): Promise<{ filename: string; root
  * Watch one patch path, including missing parents, and serialize refresh callbacks.
  * Watch one patch path, including missing parents, and serialize refresh callbacks.
  * @param ctx Context that owns watcher disposal and receives refresh failures.
  * @param ctx Context that owns watcher disposal and receives refresh failures.
  * @param filename Absolute patch-file path.
  * @param filename Absolute patch-file path.
- * @param options Deployment watcher options inherited from the HMR configuration.
+ * @param options Deployment watcher options; configuration watches enable write stabilization by default.
  * @param refresh Callback for additions, changes, and removals.
  * @param refresh Callback for additions, changes, and removals.
  * @param inTransaction Whether disposal is running inside the refresh being removed.
  * @param inTransaction Whether disposal is running inside the refresh being removed.
  * @returns A disposer that closes the watcher and drains its current refresh.
  * @returns A disposer that closes the watcher and drains its current refresh.
@@ -44,7 +44,8 @@ export async function watchConfig(
   if (paths.has(target.filename)) throw new Error(`config path already registered: ${filename}`)
   if (paths.has(target.filename)) throw new Error(`config path already registered: ${filename}`)
   const { cwd: _cwd, ignored: _ignored, ...watchOptions } = options
   const { cwd: _cwd, ignored: _ignored, ...watchOptions } = options
   const watcher = watch(target.root, {
   const watcher = watch(target.root, {
-    ...watchOptions, depth: target.depth, ignoreInitial: false,
+    // Stabilized events bypass Chokidar's lossy 50 ms change-event throttle.
+    awaitWriteFinish: true, ...watchOptions, depth: target.depth, ignoreInitial: false,
   })
   })
   paths.add(target.filename)
   paths.add(target.filename)
   const state = { dirty: false }
   const state = { dirty: false }

+ 31 - 0
packages/boot/hmr/tests/watch-config.spec.ts

@@ -196,6 +196,37 @@ describe('HMR exact config paths', () => {
     expect(calls).toBe(2)
     expect(calls).toBe(2)
   })
   })
 
 
+  it('observes consecutive writes after the previous configuration was applied', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-consecutive-'))
+    hmrRoots.push(dir)
+    const filename = join(dir, 'package.json')
+    writeFileSync(filename, 'initial')
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    let watcher!: FSWatcher
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    configWatch.create = (options) => {
+      watcher = new FSWatcher(options)
+      // Use Chokidar's real normalization with deterministic event delivery.
+      Reflect.set(watcher, '_readyEmitted', true)
+      queueMicrotask(() => { watcher.emit('ready') })
+      return watcher
+    }
+    const observed: string[] = []
+    await watchConfig(ctx, filename, {}, async () => {
+      const value = readFileSync(filename, 'utf8')
+      observed.push(value)
+      if (value === 'enabled') {
+        writeFileSync(filename, 'disabled')
+        await watcher._emit('change', filename)
+      }
+    })
+    writeFileSync(filename, 'enabled')
+    await watcher._emit('change', filename)
+    await vi.waitFor(() => { expect(observed).toEqual(['enabled', 'disabled']) }, { timeout: 6_000 })
+  }, 10_000)
+
   it('rejects a patch path whose parent is a regular file', async () => {
   it('rejects a patch path whose parent is a regular file', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-parent-'))
     const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-parent-'))
     hmrRoots.push(dir)
     hmrRoots.push(dir)