diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 1adde54c7b..638d87373d 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -298,6 +298,9 @@ "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml": "sha256:89b6dc255cb0dd9d97ae6f34f37d185412d4260d7bf2f87d6b25e340f01dc26e", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md": "sha256:1742e09435ade4a09349c8843eb381e870a2be2d51b74449f08260422c8096b3", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md": "sha256:220bd53a88617b09ee8970627c46540fa8951724b102acfe296a7ed7d0f7b5fe", + "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5", + "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936", + "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml similarity index 67% rename from .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml rename to .agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 2575f2f4b3..f7995fd9cf 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.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/process/2026-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: c2b5419c106e232520b9ad21339b0758da1b6694 -2026-07-27-wine-windows-gates-experiment.zh.md: 7ad19e49a43c07369eba6511fb7a46f5b3291df8 +2026-07-27-wine-windows-gates-experiment.md: ce2ad1ad4506f59083de5731677d052161849e3d +2026-07-27-wine-windows-gates-experiment.zh.md: a0a5e8e7cce078adf2c961fe4d8cc50edc28355c diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.md similarity index 97% rename from .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md rename to .agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.md index c2b5419c10..ce2ad1ad45 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.md @@ -1,6 +1,7 @@ # Agent Note: Wine-run Windows blocking gates on Linux runners Status: implemented +Archived: 2026-08-08 English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md) @@ -8,7 +9,7 @@ English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md) The pull-request Windows lane exists to prove the two blocking win32 surfaces — the workspace build and the production site — and it ran on hosted `windows-2025`, the slowest job in the required matrix: 7–9 minutes against 1.5–2.5 for the Linux jobs, so the Windows VM's boot, setup, and filesystem costs dominated every pull request's critical path. -The experiment tested whether a plain Linux runner can produce an equivalent win32 signal for the blocking surfaces at Linux wall clock, so no Windows VM sits on the pull-request path at all. +The question the experiment answered: can a plain Linux runner produce an equivalent win32 signal for the blocking surfaces at Linux wall clock, so no Windows VM sits on the pull-request path at all? ## Decision diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.zh.md similarity index 95% rename from .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md rename to .agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.zh.md index 7ad19e49a4..a0a5e8e7cc 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -1,6 +1,7 @@ # Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁 Status: implemented +Archived: 2026-08-08 [English](2026-07-27-wine-windows-gates-experiment.md) | 中文 @@ -8,13 +9,13 @@ Status: implemented Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 workspace 构建与生产站点。该通道此前运行在托管 `windows-2025` 上,是必需矩阵中最慢的作业:耗时 7–9 分钟,而 Linux 作业耗时 1.5–2.5 分钟,因此 Windows VM 的启动、准备与文件系统开销主导了每个 pull request 的关键路径。 -实验测试了一台普通 Linux runner 能否以 Linux 作业的墙钟时间为这些阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM。 +实验回答的问题是:一台普通 Linux runner 能否以 Linux 作业的墙钟时间为这些阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM? ## 决策 [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。 -依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:一个独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了该原型的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装约定在此仍由 Linux 侧验证)。 +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:一个独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了该原型的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 该通道靠四个杠杆把墙钟时间保持在与 Linux CI 作业相当的水平:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 @@ -32,7 +33,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo **在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可投入使用。 -**在 Wine 下由 Windows pnpm 执行安装。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装约定本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 +**在 Wine 下由 Windows pnpm 执行安装。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 **Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 故障类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml index a8bd523cb6..d8c55c9f0a 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.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-08-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 61aa737a4b7d6f15f160d3673a14cdc0c5df4a76 -2026-08-08-per-preset-standing-mounts.zh.md: 17de2cf3ec9ee9564cb5fde1fabb5014c389be39 +2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971 +2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md index 61aa737a4b..834d645f5f 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -10,7 +10,7 @@ Per-session preset mounts made the model-facing registry surface per-agent while ## Decision -A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by `setScopeParent(agentKey, standingKey)`. Two `dsh-scope` mechanisms carry everything: registration views walk the parent chain (`agent → preset → global`, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf. +A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by binding its scope key to the mount's (`bindScopeParent(agentKey, standingKey)`). Two `dsh-scope` mechanisms carry everything: registration views walk the parent chain (`agent → preset → global`, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf. ## Consequences @@ -25,7 +25,7 @@ Standing mounts fix the class, not the instances: the registrations a reader nee - **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer. - **A settled mount serves until its composition file's stamp changes.** The composition a running session joined must survive its file changing or disappearing; each generation records the file's stamp (mtime + size) and a session that finds it stale starts the next generation, so file edits — the only composition editor once authoring became copy-only — reach later sessions without any authoring call dropping the pointer. Joined sessions keep their generation, and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations. - **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it). -- **Re-linking a key (`setScopeParent` on a live agent) is the blank-session recompose path** — valid only while nothing produced under the old parent is retained, which the caller must uphold; the relation cannot see session logs. +- **Re-linking runs only through the `ScopeParentBinding` the mount's one bind returned** — the roster holds it privately, so the blank-session recompose path is the sole re-link and no other caller can move a composed agent; it stays valid only while nothing produced under the old parent is retained, which the holder must uphold because the relation cannot see session logs. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md index 17de2cf3ec..45ce0f4e7d 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -一个 preset 是**每进程**一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过 `setScopeParent(agentKey, standingKey)` 加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。 +一个 preset 是**每进程**一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过把自己的 scope key 绑定到挂载的 key(`bindScopeParent(agentKey, standingKey)`)加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。 ## Consequences @@ -25,7 +25,7 @@ Status: implemented - **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。 - **挂载一旦成功即持续供职,直到组装文件的 stamp 变化。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stamp(mtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 - **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。 -- **对活 agent 重新认父(`setScopeParent`)是空白会话 recompose 的路径**——仅当旧父之下的产出一概不被保留时才合法,由调用方保证;该关系看不见会话日志。 +- **重新认父只能经由挂载首绑返回的 `ScopeParentBinding`**——roster 私藏该句柄,空白会话 recompose 因此是唯一的重链路径,其他调用方无法挪动已组合的 agent;其合法性仍以旧父之下产出一概不被保留为前提,由持有方保证,因为该关系看不见会话日志。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 5c7deb9700..34f0f1457e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.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/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: c8aac6e2be927bd1f4a445c00e0aaa870b10a465 -2026-08-03-cli-signal-shutdown-escalation.zh.md: 14e2149eb5153b3743e188c6b233d000b36d60db +2026-08-03-cli-signal-shutdown-escalation.md: 55917400fac2728d13dc2cdd799a7e234b6ed661 +2026-08-03-cli-signal-shutdown-escalation.zh.md: c7897a8d77e8c2ebad43cec4e12170b04c837350 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index c8aac6e2be..55917400fa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -20,11 +20,13 @@ The fix has two ownership layers. The OTel backend adds `shutdownTimeoutMillis` Web and headless share `createProcessShutdown`, one process-level controller around root disposal: -- Normal shutdown calls coalesce onto one disposal and retain the first requested exit code; they never escalate one another. +- Normal shutdown calls coalesce onto one disposal and retain the first requested exit code; they never escalate one another. Successful disposal records that code through `process.exitCode` and lets Node drain its remaining handles naturally. Disposal failure still forces process exit because the launcher cannot assume the failed tree reached quiescence. - The first signal starts the same graceful disposal and a referenced five-second exit backstop. Disposal success or failure exits once; neither can cancel the process exit. - A signal received while shutdown is pending forces immediate exit with that signal path's code. This includes the first `Ctrl+C` after headless normal completion has already entered disposal, and a second signal after a signal initiated the drain. - The five-second bound is a process-safety invariant, not a deployment tunable. It is long enough for the telemetry deployment's ordinary drain ceiling while still bounding any wedged disposer at the launcher boundary. +Normal completion deliberately avoids `process.exit()`: an immediately forced exit after an Undici request can hit Node's [Windows libuv async-handle assertion](https://github.com/nodejs/node/issues/56645) before the completed request's native handle cleanup drains. A signal can still force exit after normal disposal has completed if another handle keeps the process alive. + Headless preserves exit 0 for a completed turn, exit 1 for another turn-end reason or API business error, 130 for SIGINT, and 143 for SIGTERM. Web preserves its existing SIGTERM exit 0 and SIGINT exit 130 behavior. This supersedes the [telemetry deployment Note's](../feature/2026-07-31-web-telemetry-default-mount.md) assumption that SDK exporter/processor timeouts bound complete provider shutdown, and its earlier decision to defer a process-level backstop. The backend owns its export loss/latency policy and closes the known SDK `forceFlush()` gap; the launcher owns the outer guarantee that no plugin can trap the process indefinitely. @@ -37,15 +39,17 @@ This supersedes the [telemetry deployment Note's](../feature/2026-07-31-web-tele **Add only the five-second timeout.** Rejected because a user pressing `Ctrl+C` again is asking to stop waiting now. Swallowing that intent for the rest of the grace period recreates the reported behavior at a shorter duration. +**Always call `process.exit()` after successful disposal.** Rejected because root disposal proves the application tree is quiescent, not that Node and its native dependencies have finished retiring every asynchronous handle. Setting `process.exitCode` preserves the requested status while letting the runtime finish that work. + ## Consequences -A healthy exit still disposes the complete Cordis tree. The known telemetry wait releases after at most three seconds; any other wedged exit lasts at most five seconds without further input, and a repeated signal ends it immediately. Forced or deadline-bounded exit can interrupt telemetry export or remaining cleanup, which is intentional only after the graceful contract has failed or the user has explicitly escalated. +A healthy normal exit still disposes the complete Cordis tree and then waits for Node's event loop to drain. The known telemetry wait releases after at most three seconds; any other wedged exit lasts at most five seconds without further input, and a signal ends a lingering normal completion or pending shutdown immediately. Forced or deadline-bounded exit can interrupt telemetry export or remaining cleanup, which is intentional only after the graceful contract has failed or the user has explicitly escalated. The controller is launcher infrastructure rather than a Cordis plugin: it makes no claim that disposal completed, and it does not weaken the lifecycle rule that ordinary disposers must reach quiescence. ## Testing -`apps/cli/tests/process-shutdown.spec.ts` pins resolved and rejected disposal, the five-second backstop, normal-call coalescing, a signal interrupting normal disposal, and second-signal escalation. +`apps/cli/tests/process-shutdown.spec.ts` pins natural completion after resolved disposal, forced exit after rejected disposal, the five-second backstop, normal-call coalescing, signal-owned disposal, a signal interrupting normal disposal or post-disposal handle draining, and second-signal escalation. `apps/cli/tests/headless-shutdown.e2e.ts` boots the real shipped Web/headless Loader tree in a PTY with a test-only plugin whose disposer announces entry and never settles. The test sends SIGINT after the observation URL, waits for proof that disposal started, sends SIGINT again, and requires exit 130. The source/artifact launch resolver keeps the same regression on both execution planes. This PTY case covers the user-visible process state; no model-output snapshot changes. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index 14e2149eb5..c7897a8d77 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -20,11 +20,13 @@ Status: implemented Web 与 headless 共用 `createProcessShutdown`,它是围绕根级 dispose 建立的进程级控制器: -- 多次正常关闭调用会汇合到同一次 dispose,并保留首次请求的退出码;这些调用不会相互触发强制退出。 +- 多次正常关闭调用会汇合到同一次 dispose,并保留首次请求的退出码;这些调用不会相互触发强制退出。dispose 成功后,控制器通过 `process.exitCode` 记录该退出码,让 Node 自然排空剩余句柄;dispose 失败时仍强制退出,因为启动器不能假定失败的插件树已经完全停稳。 - 第一个信号会启动同一次优雅 dispose,并设置一个带引用的 5 秒退出兜底。dispose 无论成功或失败都会触发且仅触发一次退出;任何一种结果都无法取消进程退出。 - 关闭待结算期间收到信号时,会立即按该信号路径的退出码强制退出。这既包括 headless 正常完成已经进入 dispose 后收到的第一次 `Ctrl+C`,也包括由信号启动排空后收到的第二个信号。 - 5 秒上限是进程安全不变式,而不是部署调节项。它足以覆盖遥测部署的常规排空时限,同时仍在启动器边界为任何卡死的 disposer 设置等待上限。 +正常完成会刻意避免调用 `process.exit()`:Undici 请求刚完成后立即强制退出,可能会在原生句柄清理尚未排空时触发 Node 的 [Windows libuv 异步句柄断言](https://github.com/nodejs/node/issues/56645)。如果正常 dispose 已经完成,但仍有其他句柄让进程保持存活,信号依然可以强制退出。 + headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API 业务错误仍以 1 退出,对 SIGINT 以 130 退出,对 SIGTERM 以 143 退出。Web 保留现有行为:SIGTERM 以 0 退出,SIGINT 以 130 退出。 这项决策取代了[遥测部署 Agent Note](../feature/2026-07-31-web-telemetry-default-mount.md) 中 SDK 导出器/处理器超时能够限制提供方完整关闭流程的假设,也取代了其中暂缓进程级退出兜底的决定。后端负责导出数据丢失与延迟策略,并封住已知的 SDK `forceFlush()` 缺口;启动器负责最外层保证,确保任何插件都无法无限期困住进程。 @@ -37,15 +39,17 @@ headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API **只增加 5 秒超时。** 不予采纳:用户再次按下 `Ctrl+C`,就是要求立即停止等待。若在剩余宽限期内继续吞掉这一意图,只是缩短了报告中故障的持续时间,并未解决问题。 +**dispose 成功后仍总是调用 `process.exit()`。** 不予采纳:根级 dispose 只能证明应用插件树已经完全停稳,不能证明 Node 及其原生依赖已经回收所有异步句柄。设置 `process.exitCode` 既保留请求的状态码,也允许运行时完成这部分工作。 + ## 后果 -健康的退出流程仍会对整棵 Cordis 插件树执行 dispose。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒,再次收到信号则立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭约定已经失败,或用户明确要求强制退出时,才会有意接受这一结果。 +健康的正常退出流程仍会对整棵 Cordis 插件树执行 dispose,随后等待 Node 事件循环自然排空。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒;收到信号时,仍在排空句柄的正常完成流程或待结算的关闭流程都会立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭约定已经失败,或用户明确要求强制退出时,才会有意接受这一结果。 该控制器属于启动器基础设施,而不是 Cordis 插件:它不会声称 dispose 已经完成,也不会削弱普通 disposer 必须达到完全停稳状态的生命周期规则。 ## 测试 -`apps/cli/tests/process-shutdown.spec.ts` 固定了 dispose 成功与失败、5 秒退出兜底、正常调用汇合、信号中断正常 dispose,以及第二次信号强制退出的行为。 +`apps/cli/tests/process-shutdown.spec.ts` 固定了 dispose 成功后的自然完成、dispose 失败后的强制退出、5 秒退出兜底、正常调用汇合、信号拥有的 dispose、信号中断正常 dispose 或 dispose 后句柄排空,以及第二次信号强制退出的行为。 `apps/cli/tests/headless-shutdown.e2e.ts` 在 PTY 中启动真实交付的 Web/headless Loader 插件树,并挂载一个仅用于测试的插件;该插件的 disposer 会声明已经进入清理流程,但永不结算。测试在观察地址出现后发送 SIGINT,等待 dispose 已启动的证据,再次发送 SIGINT,并要求进程以 130 退出。源码/产物启动解析器使两个执行平面都覆盖同一项回归。该 PTY 用例覆盖用户可见的进程状态;模型输出快照没有变化。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 1d346efd96..2a66f8d5e4 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: cf41190c2d03965106e817b50fd2edb806e084bf -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: e0c9cfc96a68fe919a70af64b23ee7ed33ffcea3 +2026-08-04-claude-code-and-codex-subagent-backends.md: 783a8a6537d2b05ab99f1962bda88787e2e4e938 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f331dc698001e24df62733d219f28b59d2358fac diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index cf41190c2d..783a8a6537 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -39,11 +39,11 @@ Before publication, the provider validates a non-empty text-only task, starts th `turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. -For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. +For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. -Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. +Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. ## Claude Code provider @@ -61,7 +61,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped user configuration, verifies both fixed foreground-only tools in one context, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. +The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index e0c9cfc96a..f331dc6980 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -39,11 +39,11 @@ fixed tool → shared subagent service → product provider → official product `turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 -对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 +对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 -Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 +Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 ## Claude Code 提供方 @@ -61,7 +61,7 @@ Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示形态的用户配置,在同一个上下文中验证两个固定且只支持前台执行的工具,并且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 02daf774f7..33bffe92ff 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: 71b364f72a094f7899a0929eec29f3a16ccc8a93 -2026-07-21-serial-cross-platform-ci-reference.zh.md: bc8b210e4f32c4e2fe3154bb7ffe9d89210d5087 +2026-07-21-serial-cross-platform-ci-reference.md: 07dc430e6fed3fe75a006ca03523bd6e4fc969d0 +2026-07-21-serial-cross-platform-ci-reference.zh.md: 8bbb60cdead2957069de22ecaddf01c6cd9fb305 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 71b364f72a..07dc430e6f 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active reference is `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. @@ -28,13 +28,13 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses standard `windows-2025` under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered - **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression. - **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check. -- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts. +- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Wine-hosted Windows contracts, and the independent native job supplies the complete Windows result. - **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal. - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism. - **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index bc8b210e4f..8bbb60cdea 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 约定。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——该热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 @@ -28,13 +28,13 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)使用标准 `windows-2025`;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 - **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。 -- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 约定。 +- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和由 Wine 承载的 Windows 约定,而独立原生作业提供完整的 Windows 结果。 - **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。 - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 - **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index d8d79cbf9a..2c61edfac9 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: 53cc86efce9061c8f9836a17cb35ebb128085b7a -2026-07-22-evidence-based-larger-hosted-runners.zh.md: c9daf5273e190d3828e80ebe81369e72f5d7a74d +2026-07-22-evidence-based-larger-hosted-runners.md: 84c951809891b4936549a2f429dc7efc99833c1b +2026-07-22-evidence-based-larger-hosted-runners.zh.md: e097f8b18c7a03a4760e9cb4f45b6385c44b051c diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 53cc86efce..84c9518098 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -24,7 +24,7 @@ The gate dependencies remain explicit. Coverage consumes source and does not wai The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails. -Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. +Within this enterprise required topology, Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts, while Linux owns the duplicate lint, coverage, and snapshot inventories. The later [dual Windows pull-request topology](2026-08-08-native-windows-pull-request-ci.md) adds a separate non-blocking standard-hosted native job that independently enforces supported-source coverage without extending this paid required path. An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index c9daf5273e..e097f8b18c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -24,7 +24,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布约定只要遗漏一个运行时分片,检查仍会失败。 -Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物约定。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台约定。 +在这项企业级必需拓扑中,Windows 通过一次 32 核环境设置同时承载阻塞性构建、生产网站与观测性构建产物约定,重复的 lint、覆盖率和快照清单则由 Linux 负责。后续的[拉取请求双 Windows 拓扑](2026-08-08-native-windows-pull-request-ci.md)新增一个独立且不阻断的标准托管原生作业;该作业会独立强制执行受支持源码覆盖率,同时不延长这条付费必需路径。 一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 0f80587749..e67565f381 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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/process/2026-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 1a6939e8386e381cba114a7be71993a644457a45 -2026-07-23-portable-required-pull-request-ci.zh.md: 83737b61bc72e3c1ee6afb805d30256d572211a2 +2026-07-23-portable-required-pull-request-ci.md: b7c637408afa32611c4e70073ddec9aaabfba7f5 +2026-07-23-portable-required-pull-request-ci.zh.md: ee88412d982da5de613d8dfdbdcdd9977c899d2d diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 1a6939e838..b7c637408a 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)), keeping the pull-request Windows contract independent of any Windows runner allocation; the complete native-kernel Windows inventory lives in the master serial reference. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial references remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. -The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. +The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. @@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) ## Consequences -Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine-hosted Windows job keeps its verdict on standard Linux allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. +Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine job keeps the required Windows verdict on standard Linux allocation. The independent native job uses standard Windows allocation without delaying or changing the aggregate. A live exact-head run distinguishes the commands branch protection consumes from the separate diagnostic contract; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. -Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work. +Standard compatibility, required Wine, and diagnostic native Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index 83737b61bc..ee88412d98 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node 以覆盖阻断性检查范围([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 约定不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单位于 master 串行参考流程中。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 -两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。 +两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。 当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 托管的 Windows 作业让其判定保持在标准 Linux 运行器分配上。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 +普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 作业让必需的 Windows 判定继续使用标准 Linux 运行器容量。独立原生作业使用标准 Windows 运行器容量,不会延迟或改变聚合流程。一次实际的分支头精确运行会区分分支保护采用的命令与单独的诊断约定;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 -企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。 +企业级运行器分配能力下降时,标准兼容性作业、必需的 Wine 作业与诊断性原生 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 095b577bba..c0a7dcfc02 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 79a7ff1c573019a3a1877eb49177d3410aea5ed7 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 76d755656ecfdd47bac2191efac4ce5bba53f4fb +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 499141ca6a3703a12d10c195b732441d49419599 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: c57b7d32cd7204a845ab1884e9ae0b9045749a1c diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index 79a7ff1c57..499141ca6a 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -13,8 +13,8 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i `pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. -- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based pull-request Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. -- **Cache-less or persistent** (no store-cache action): native serial-windows and serial-macos plus `sandbox.yml` install from a cold or runner-local store. The self-hosted standby and failover jobs reuse their VM's persistent pnpm store without transferring a hosted cache archive. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Cache-less or persistent** (no store-cache action): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 76d755656e..c57b7d32cd 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -13,8 +13,8 @@ Status: implemented `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业与基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 -- **无缓存或持久化**(不使用 store 缓存 action):原生 serial-windows 和 serial-macos 加上 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。自托管热备与故障切换作业复用其 VM 的持久 pnpm store,不传输托管缓存归档。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 +- **无缓存或持久化**(不使用 store 缓存 action):独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml new file mode 100644 index 0000000000..07eb13b5cd --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/process/2026-08-08-native-windows-pull-request-ci.md +2026-08-08-native-windows-pull-request-ci.md: 6a62fddb79670c3ab4cc0446796dbffd7130aed9 +2026-08-08-native-windows-pull-request-ci.zh.md: 990b8ed1434934337b8ff20c5f3be2c03cd9c61b diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md new file mode 100644 index 0000000000..6a62fddb79 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -0,0 +1,57 @@ +# Agent Note: Dual Wine and native Windows pull-request CI + +Status: implemented + +English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) + +## Problem + +The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. Wine provides that critical-path signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. + +A coverage audit found that stale branch state had restored temporary exclusions for supported LSP sources. Native Windows therefore needed to execute the complete supported source inventory at the same 100%-per-file threshold instead of relying on a smaller platform-specific denominator. + +## Decision + +The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. + +Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 60-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. + +The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. + +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. + +The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. + +The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. + +Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. + +Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary. + +Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration, retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT`; nested Git-package installation therefore cannot rejoin the enclosing workspace or let an inherited Windows pnpm executable outrank the transaction-owned wrapper. + +Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. + +Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. + +## Alternatives considered + +**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path. + +**Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. + +**Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism. + +**Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures. + +**Keep GitHub's standard `windows-2025` runner.** That portable two-core image completed the exact inventory reliably, but its 32-minute serial result made the automatic native signal substantially less useful than the selected 16-core runner. + +**Use a 32-core or larger runner.** The 32-core comparison improved aggregate gate time by only 1.47 seconds over 16 cores and failed in Node's CJS lexer; earlier high-concurrency 32-core and 64-core trials failed in the same class. More capacity therefore added allocation cost without a stable end-to-end gain. + +## Consequences + +Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result. + +Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. + +Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native job uses the immutable workspace on the organization-owned 16-core Windows runner. A failure unique to either job must be classified against that boundary rather than weakened or silently skipped. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md new file mode 100644 index 0000000000..990b8ed143 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -0,0 +1,57 @@ +# Agent Note: Wine 与原生 Windows 双通道拉取请求 CI + +Status: implemented + +[English](2026-08-08-native-windows-pull-request-ci.md) | 中文 + +## 问题 + +拉取请求必需的 Windows 判定既需要快速的 win32 工具链信号,也不能让聚合流程等待稀缺的 Windows 容量。Wine 提供这项关键路径信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 + +覆盖率审计发现,陈旧分支状态恢复了针对受支持 LSP 源码的临时排除项。因此,原生 Windows 需要按同一逐文件 100% 阈值执行完整的受支持源码清单,而不能依赖缩小后的平台专用分母。 + +## 决策 + +[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 + +每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,60 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 + +原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 + +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 + +16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 + +首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 + +可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 + +原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。 + +Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,保留 `PNPM_HOME` 作为 pnpm 数据配置,同时从生命周期 `PATH` 中移除该目录,并在 `PATHEXT` 中优先选择 `.CMD`;因此,嵌套 Git 包安装既不会重新加入外层 workspace,也不会让继承的 Windows pnpm 可执行文件抢在事务持有的 wrapper 之前。 + +启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 + +Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 + +## 曾考虑的替代方案 + +**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最慢的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。 + +**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 + +**将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留常规独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。 + +**排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。 + +**保留 GitHub 标准的 `windows-2025` 运行器。** 该可移植双核镜像能可靠完成这份完整清单,但其 32 分钟的串行结果使自动原生信号的实用性远低于所选的 16 核运行器。 + +**使用 32 核或更大的运行器。** 32 核对比仅比 16 核将聚合门禁时间缩短 1.47 秒,且仍因 Node 的 CJS lexer 失败;先前高并发的 32 核和 64 核试验也以同类故障失败。因此,增加容量只会提高资源分配成本,却不能带来稳定的端到端收益。 + +## 后果 + +Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。 + +尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程与两项阻断构建,在标准镜像上明显更慢;但它也会暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 + +维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用不可变工作区。任一作业独有的失败都必须依据该边界分类,不得削弱或静默跳过。 diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml index 04c3e6a7df..0813498ae6 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.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/process/2026-08-09-committed-artifact-citations.md -2026-08-09-committed-artifact-citations.md: 181378aab12a4423d9ac5216bac5304f3cfca11c -2026-08-09-committed-artifact-citations.zh.md: 36f32bb1fd0d6991919959e1ebe6482bf7b19b32 +2026-08-09-committed-artifact-citations.md: a578b1e32268af2669ee26c59d19c738bc9f707b +2026-08-09-committed-artifact-citations.zh.md: d525dc0f58884bdf808b597213a7e23c1d6a0301 diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md index 181378aab1..a578b1e322 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md @@ -28,7 +28,7 @@ One repo-wide purge applied these rules across the prose surfaces, including the ## Verification -The audit's grep batteries (English and Chinese, comments and prose, `--hidden` for `.agents/`) return no design-ordinal citations outside recorded fixtures, archived notes, and the trim skill's own calibration examples; `verify-type-equiv`, the `gen-*` freshness checks, and `verify-translation-pairing` pin the regenerated and re-recorded surfaces. Coverage gap: no gate rejects a new ordinal citation — review owns the rule. +The audit's grep batteries (English and Chinese, comments and prose, `--hidden` for `.agents/`) return no design-ordinal citations outside recorded fixtures, archived notes, the trim skill's own files, and this note's quoted evidence; `verify-type-equiv`, the `gen-*` freshness checks, and `verify-translation-pairing` pin the regenerated and re-recorded surfaces. Coverage gap: no gate rejects a new ordinal citation — review owns the rule. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md index 36f32bb1fd..d525dc0f58 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md @@ -28,7 +28,7 @@ Status: implemented ## 验证 -审计的成批 grep 检索(英文与中文、注释与行文、对 `.agents/` 加 `--hidden`)在已录制 fixture、已归档 Agent Note 与该清理技能自带的校准示例之外没有命中任何设计序号引用;`verify-type-equiv`、各 `gen-*` 新鲜度检查与 `verify-translation-pairing` 把重新生成与重新记录的表面固定下来。覆盖缺口:没有门禁会拒绝新的序号引用,这条规则由评审把关。 +审计的成批 grep 检索(英文与中文、注释与行文、对 `.agents/` 加 `--hidden`)在已录制 fixture、已归档 Agent Note、该清理技能自身文件与本笔记引用的证据之外没有命中任何设计序号引用;`verify-type-equiv`、各 `gen-*` 新鲜度检查与 `verify-translation-pairing` 把重新生成与重新记录的表面固定下来。覆盖缺口:没有门禁会拒绝新的序号引用,这条规则由评审把关。 ## 后果 diff --git a/.agents/skills/dsh-trim-cot-leakage/SKILL.md b/.agents/skills/dsh-trim-cot-leakage/SKILL.md index 1ce776c28e..c80dfbd465 100644 --- a/.agents/skills/dsh-trim-cot-leakage/SKILL.md +++ b/.agents/skills/dsh-trim-cot-leakage/SKILL.md @@ -5,11 +5,11 @@ description: Use when auditing or fixing prose that reads like a leaked reasonin # Trimming Chain-of-Thought Leakage -Chain-of-thought leakage is prose whose vantage is the authoring session rather than the repository: it cites artifacts only that session could see, narrates the change instead of the state, or argues with a reviewer who has left. The fix is never deletion alone — restate every factual clause so it stands at HEAD, then delete the transcript around it. **REQUIRED BACKGROUND:** [dsh-prose-standard](../dsh-prose-standard/SKILL.md) owns the complete-proposition rule this skill applies; the [committed-artifact-citations note](../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) owns the citation rule's rationale. It is guidance, not a script. +Chain-of-thought leakage is prose whose vantage is the authoring session rather than the repository: it cites artifacts only that session could see, narrates the change instead of the state, or argues with a reviewer who has left. The fix is never deletion alone when a passage carries factual clauses — restate each so it stands at HEAD, then delete the transcript around it; a passage carrying none (an audit code, control-flow narration) is deleted outright. **REQUIRED BACKGROUND:** [dsh-prose-standard](../dsh-prose-standard/SKILL.md) owns the complete-proposition rule this skill applies; the [committed-artifact-citations note](../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) owns the citation rule's rationale. It is guidance, not a script. ## The one test -For every suspect passage ask: **could a reader at HEAD, with no access to any session transcript, PR thread, or uncommitted draft, resolve every reference and verify every claim?** If yes, it is not leakage, however historical it sounds. If no, restate the surviving facts from the repository's vantage and delete the rest. +For every suspect passage ask: **could a reader at HEAD, with no access to any session transcript, PR thread, or uncommitted draft, resolve every reference and verify every claim?** If no, restate the surviving facts from the repository's vantage and delete the rest. If yes, it is not leakage, however historical it sounds — but resolvability only clears this skill's bar: on current-state surfaces (READMEs, docs, JSDoc) a resolvable change story is still change narration, and class 3 routes it to its sanctioned home. ## Taxonomy @@ -42,4 +42,4 @@ The citation boundary is where unaided passes fail in both directions — deleti 2. Audit read-only first: run the [recall batteries](references/recall-batteries.md) (with `--hidden` so `.agents/` is searched), then judge every hit semantically. The batteries are probes, not the definition — each review round of the original purge surfaced shapes the batteries missed, so also read the densest prose in scope (module JSDoc, READMEs, Agent Notes) without a pattern in hand. 3. Fix owner-first per surface: generated catalogs → fix the source JSDoc or generator template, then regenerate; type-equivalence fences → fix the source JSDoc, then re-paste both bilingual pages (`verify-type-equiv` pins them); bilingual pairs → update the counterpart and re-record per [dsh-translate-docs](../dsh-translate-docs/SKILL.md); model-visible strings → wording is behavior, so flag for a snapshot-backed change instead of silently rewording. 4. Before deleting anything, enumerate the passage's propositions (prose-standard) and check the [overcorrection traps](references/examples.md#overcorrection-traps): trims that flip an obligation into an endorsement, promote a hypothetical to a shipped feature, delete a true fact, or drop provenance. -5. Verify: re-run the batteries expecting only sanctioned keeps and this skill's own references; confirm every remaining citation resolves at HEAD; run the gates for touched surfaces (`doc-sync` for docs, `verify-type-equiv`, `verify-translation-pairing`). +5. Verify: re-run the batteries expecting only sanctioned keeps, this skill's own directory, and the owning note's quoted evidence; confirm every remaining citation resolves at HEAD; run the gates for touched surfaces (`doc-sync` for docs, `verify-type-equiv`, `verify-translation-pairing`). diff --git a/.agents/skills/dsh-trim-cot-leakage/references/examples.md b/.agents/skills/dsh-trim-cot-leakage/references/examples.md index f478d90b13..22e73e10f4 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/examples.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/examples.md @@ -1,6 +1,6 @@ # Few-shot leakage examples -Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to identify the governing principle, not as text templates. This file deliberately quotes leaked shapes as calibration material — the [recall batteries](recall-batteries.md) exclude this directory, and its wording is not a license elsewhere. +Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to identify the governing principle, not as text templates. This file deliberately quotes leaked shapes as calibration material — the [recall batteries](recall-batteries.md) exclude the skill's directory, and its wording is not a license elsewhere. ## Dead citations @@ -8,9 +8,9 @@ Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to id **Leaked:** "Slash input resolves against the visible catalog (decision 21)." -**Fixed:** "Slash input resolves against the visible catalog — the plain-text-reference decision, owned by the web input-machine note." +**Fixed:** "Slash input resolves against the visible catalog — the plain-text-reference decision, owned by [the web input-machine note](../../../notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)." -The ordinal resolves nowhere at HEAD; the decision's name and owning note path do. Name the owning note's path at least once per file; later mentions may use the searchable name alone. +The ordinal resolves nowhere at HEAD; the decision's name and owning note path do. Name the owning note's path at least once per file — as a link where the surface supports one — and later mentions may use the searchable name alone. ### Decision ordinal without an owner diff --git a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md index bacb434d16..8f9cce4aad 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md @@ -5,18 +5,19 @@ Probes for [the taxonomy](../SKILL.md#taxonomy), tuned during the 2026-08 purge. ## Invocation rules - Add `--hidden --glob '!.git/**'` so `.agents/` is searched; ripgrep skips dot-directories by default and the purge's biggest miss risk was Agent Notes. -- Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/references/**'` (this directory quotes leaked shapes as calibration), plus recorded fixture and snapshot directories in scope. +- Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/**'` (the skill's own files quote leaked shapes as calibration), plus recorded fixture and snapshot directories in scope. The [owning note](../../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) also self-hits through its quoted evidence; judge it as evidence, not usage. +- Natural-language lines carry `-i` so sentence-initial capitals hit ("This PR adds…", "Probably fine…"); the code-shaped first line stays case-sensitive — `-i` would turn `\bT\d\b` and `\bP-I\b` into noise. - A zero-hit pattern proves nothing until you have seen it match: test it against a known-positive string before trusting the negative. ## English battery ```sh rg -n --hidden '\(decision \d|\(audit [A-Z]\d|design §|plan §|design ledger|\(B ruling|\bP-I\b|\bW\d\b|\bT\d\b' ... -rg -n --hidden 'this PR|this branch|this stack|later PR|previous commit|this commit' ... -rg -n --hidden 'used to |no longer|previously|the old |was renamed|was moved' ... -rg -n --hidden '\bv1\b|this cut|\bcut \d|\btoday\b|\bfor now\b|roadmap' ... +rg -n --hidden -i 'this PR|this branch|this stack|later PR|previous commit|this commit' ... +rg -n --hidden -i 'used to |no longer|previously|the old |was renamed|was moved' ... +rg -n --hidden -i '\bv1\b|this cut|\bcut \d|\btoday\b|\bfor now\b|roadmap' ... rg -n --hidden -i 'rejected in review|review round|reviewer|as of v\d' ... -rg -n --hidden 'probably |should be enough|should suffice|it simply|is safe —|is safe --' ... +rg -n --hidden -i 'probably |should be enough|should suffice|it simply|is safe —|is safe --' ... rg -n --hidden '§\d' ... ``` @@ -36,7 +37,7 @@ Judged and kept during the purge; expect them again: - **"This PR" in process docs** — documentation *about* PR workflow ("the PR body should…", templates, this repo's process notes) legitimately says "PR"; the ban is on a doc adopting one PR's vantage about the code. - **`v1` as protocol or path segment** — `/v1/chat` endpoints and wire-format names are identifiers, not version stamps. - **`§N` with a committed owner** — external standards (RFC 9110 §10.1.5) and committed docs that own their §-numbering stay citable by section. -- **Contrastive "actually" and noun "wait"** — ordinary English, not hedging. +- **Contrastive "actually" and noun "wait"** — ordinary English, not hedging; no committed line probes them, so they surface only when you extend the battery with broader hedging patterns. - **"Today" in generated timestamps and CLI output samples** — recorded output keeps its voice. - **本版本 in zh prose** — a legitimate rendering of "this release" in versioned-artifact contexts; the banned indexical is 本版 as a bare stamp mirroring "this cut". - **Alternatives-considered sections** — "rejected" inside an Agent Note's genre slot is the sanctioned home, not review choreography. diff --git a/.github/AGENTS.md b/.github/AGENTS.md index ff4fd4e6b2..c18f5b5948 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is not one of them: it runs Windows Node under Wine on hosted Linux, so its steps are bash — see the [Wine lane Agent Note](../.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md). +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is the deliberate exception: it runs Windows Node under Wine on hosted Linux and blocks `all checks passed`; `windows-native` runs automatically on `windows-2025` but reports independently — see the [dual-lane Agent Note](../.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f67929e26..eebdafaa48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -315,15 +315,14 @@ jobs: # The required pull-request Windows signal: the two blocking win32 surfaces # (workspace build, production site) execute with real, checksum-verified - # Windows Node under Wine on standard hosted Linux. The master - # serial-windows job below normally keeps the complete native-kernel - # inventory — including the observational portability gates this lane does - # not run — + # Windows Node under Wine on standard hosted Linux. The independent + # windows-native job below keeps the complete native-kernel inventory — + # including the observational portability gates this lane does not run — # on real windows-2025. This job only provisions runner state (caches, # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same # script the optional local gate `pnpm run check:windows-wine` runs. - # Layout, fidelity limits, and measured timings live in - # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md + # Current topology and fidelity limits live in + # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md windows: if: github.event_name == 'pull_request' runs-on: ubuntu-latest @@ -423,6 +422,49 @@ jobs: cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" du -sh "$HOME/wine-debs" + # Every pull request also gets a real Windows-kernel signal. This job keeps + # its own unmasked conclusion but is deliberately absent from + # all-checks-passed.needs, so it never delays or changes that required verdict. + # See the dual Wine/native pull-request CI decision: + # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md + windows-native: + if: github.event_name == 'pull_request' + runs-on: dsh-windows-2025-16core + name: windows node 24 / native complete + timeout-minutes: 60 + env: + DSH_COVERAGE_MAX_WORKERS: '2' + DSH_GATE_CONCURRENCY: '2' + DSH_PUBLINT_CONCURRENCY: '8' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + # Extracting the many-file pnpm store cache is slower than a clean install, + # and saving it adds more latency after the gates. + - name: Install (immutable) + shell: pwsh + run: pnpm install --frozen-lockfile + + - name: Run complete native Windows gate inventory + shell: pwsh + run: pnpm run check:ci:windows-complete + # The hosted reference jobs below are temporarily disabled; the self-hosted # standby remains active. Each enabled host executes the complete, unsharded # primary Node aggregate with one gate worker, giving reviewers a simple @@ -681,8 +723,8 @@ jobs: with: dest: ${{ runner.temp }}/setup-pnpm - # The Windows lanes deliberately skip the store cache like the required - # windows job; an empty cache input disables setup-node's caching. + # The benchmark's Windows lanes deliberately skip the store cache like + # the independent native Windows job; an empty input disables caching. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -824,9 +866,10 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`; observational Windows gates share the required Windows job but are - # marked non-blocking inside run-gates. (`needs` cannot reach across workflow - # files; e2e.yml stays its own check.) + # `needs`. The required Wine job is listed as `windows`; `windows-native` is + # deliberately absent so its independent result never delays or changes this + # verdict. (`needs` cannot reach across workflow files; e2e.yml stays its own + # check.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including diff --git a/apps/cli/src/process-shutdown.ts b/apps/cli/src/process-shutdown.ts index afc7d6fa3d..7ef9d32632 100644 --- a/apps/cli/src/process-shutdown.ts +++ b/apps/cli/src/process-shutdown.ts @@ -5,54 +5,73 @@ export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000 /** Process-exit controller shared by normal completion and Unix signal handlers. */ export interface ProcessShutdown { - /** Start or join graceful disposal before exiting with `code`. */ + /** Start or join graceful disposal before allowing natural completion with `code`. */ shutdown(code: number): Promise - /** Start graceful disposal, or force exit when a shutdown is already running. */ + /** Start graceful disposal followed by exit, or force exit when shutdown is already running. */ interrupt(code: number): void } /** * Create one process-exit controller around an application disposer. * @param dispose - Whole-application teardown that resolves at quiescence. - * @param exit - Process exit boundary, replaceable by tests. + * @param forceExit - Forced process exit boundary, replaceable by tests. + * @param complete - Natural process completion boundary, replaceable by tests. * @param timeoutMs - Grace before forced exit, replaceable by tests. * @returns A controller whose normal calls coalesce and whose repeated signal call escalates. */ export function createProcessShutdown( dispose: () => Promise, - exit: (code: number) => void = (code) => { process.exit(code) }, + forceExit: (code: number) => void = (code) => { process.exit(code) }, + complete: (code: number) => void = (code) => { process.exitCode = code }, timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS, ): ProcessShutdown { let pending: Promise | undefined let timeout: ReturnType | undefined - let exited = false + let completed = false + let forceExited = false - const exitOnce = (code: number): void => { - if (exited) return - exited = true + const clearExitTimeout = (): void => { /* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */ if (timeout !== undefined) clearTimeout(timeout) - exit(code) } - const shutdown = (code: number): Promise => { + const forceExitOnce = (code: number): void => { + if (forceExited) return + forceExited = true + clearExitTimeout() + forceExit(code) + } + + const completeOnce = (code: number): void => { + if (completed || forceExited) return + completed = true + clearExitTimeout() + complete(code) + } + + const start = (code: number, forceAfterDispose: boolean): Promise => { if (pending !== undefined) return pending - timeout = setTimeout(() => { exitOnce(code) }, timeoutMs) + timeout = setTimeout(() => { forceExitOnce(code) }, timeoutMs) pending = Promise.resolve().then(dispose).then( - () => { exitOnce(code) }, - () => { exitOnce(code) }, + () => { + if (forceAfterDispose) forceExitOnce(code) + else completeOnce(code) + }, + () => { forceExitOnce(code) }, ) return pending } return { - shutdown, + shutdown(code) { + return start(code, false) + }, interrupt(code) { if (pending !== undefined) { - exitOnce(code) + forceExitOnce(code) return } - void shutdown(code) + void start(code, true) }, } } diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index c62c2f140d..73ba895e9d 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' +import { FiberState, type Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath } from '@deepseek-ai/dsh-paths' import { @@ -193,6 +193,11 @@ export interface RunProfileOptions { environment: EnvironmentSnapshot } +/** Re-throw setup failures unless this invocation's signal already owns shutdown. */ +function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void { + if (!signal.aborted) throw error +} + /** * Boot one profile invocation end to end and leave process lifetime to the * mounted plugins (or to the one-shot runner when `task` is present). @@ -221,11 +226,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const app: { current?: Context } = {} const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) + const signalShutdown = new AbortController() + const interrupt = (code: number): void => { + signalShutdown.abort() + shutdown.interrupt(code) + } // Signals own teardown throughout the startup window, not only after boot() // settles: an inserted front door can publish readiness before sibling rows // finish mounting. - process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) }) - process.on('SIGINT', () => { shutdown.interrupt(130) }) + process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) + process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() }) @@ -268,33 +278,41 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con await options.prepare?.(hostCtx, composed.rows) }) app.current = ctx - // A surface can dispose the whole tree while startup was still in flight - // (early SIGTERM); the Loader service goes with it and there is nothing to - // keep live. - if (watchProfilePatch && ctx.get('loader') !== undefined) { - // Config-only HMR for the live profile patch layer: the web bundle - // disables the shared module-reload `hmr` row (its reload lifecycle is - // untested), so when the composition leaves no HMR service, mount a - // watch-only instance with no module roots — cordis.patch.yml edits stay - // live on every long-lived surface. A silent skip would break the - // documented hot-reload contract. HMR injects the timer service, which a - // bare custom profile may not mount either. - if (ctx.get('hmr') === undefined) { - if (ctx.get('timer') === undefined) { - await ctx.loader.create({ name: '@cordisjs/plugin-timer' }) + // A surface can dispose the whole tree while startup or this post-boot + // watcher setup is still in flight. Loader presence and fiber state own + // liveness; the local signal fact distinguishes that expected exit race + // from a real HMR error. + if (watchProfilePatch + && !signalShutdown.signal.aborted + && ctx.fiber.state === FiberState.ACTIVE + && ctx.get('loader') !== undefined) { + try { + // Config-only HMR for the live profile patch layer: the web bundle + // disables the shared module-reload `hmr` row (its reload lifecycle is + // untested), so when the composition leaves no HMR service, mount a + // watch-only instance with no module roots — cordis.patch.yml edits stay + // live on every long-lived surface. A silent skip would break the + // documented hot-reload contract. HMR injects the timer service, which a + // bare custom profile may not mount either. + if (ctx.get('hmr') === undefined) { + if (ctx.get('timer') === undefined) { + await ctx.loader.create({ name: '@cordisjs/plugin-timer' }) + } + await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) } - await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) + await watchUserPatches(ctx, { + binName: NAME, + filename: composed.profile.patchPath, + compose: composeLive, + }) + await watchUserPatches(ctx, { + binName: NAME, + filename: homePatchPath(), + compose: composeLive, + }) + } catch (error) { + suppressSignalShutdownError(signalShutdown.signal, error) } - await watchUserPatches(ctx, { - binName: NAME, - filename: composed.profile.patchPath, - compose: composeLive, - }) - await watchUserPatches(ctx, { - binName: NAME, - filename: homePatchPath(), - compose: composeLive, - }) } return { ctx, shutdown } } diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 0d2971ec62..b81f837cf8 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -49,6 +49,7 @@ interface ProfileLifecycleFixture { ready: string settled: string disposed: string + interrupt: string } /** @@ -61,16 +62,23 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { const ready = join(home, 'ready') const settled = join(home, 'settled') const disposed = join(home, 'disposed') + const interrupt = join(home, 'interrupt') const bundleDir = join(home, 'lifecycle-bundle') mkdirSync(bundleDir, { recursive: true }) writeFileSync(join(bundleDir, 'plugin.mjs'), [ - "import { writeFileSync } from 'node:fs'", + "import { existsSync, writeFileSync } from 'node:fs'", "import { join } from 'node:path'", "export const name = 'profile-lifecycle-fixture'", 'export function apply(ctx, config = {}) {', ' let active = true', ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.', - ' const heartbeat = setInterval(() => {}, 1000)', + ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.', + ' let interrupted = false', + ' const heartbeat = setInterval(() => {', + ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return', + ' interrupted = true', + " process.emit('SIGTERM')", + ' }, 20)', ' // Echo the mounted generation so the hot-reload e2e can assert both an', ' // applied override and its removal reverting to this bundle default.', " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", @@ -118,7 +126,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) { writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file))) } - return { home, ready, settled, disposed } + return { home, ready, settled, disposed, interrupt } } function startProfileLifecycle(fixture: ProfileLifecycleFixture) { @@ -131,10 +139,22 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { RAW_READY_FILE: fixture.ready, RAW_SETTLED_FILE: fixture.settled, RAW_DISPOSED_FILE: fixture.disposed, + RAW_INTERRUPT_FILE: fixture.interrupt, }, }) } +function requestProfileShutdown( + child: ReturnType, + fixture: ProfileLifecycleFixture, +): void { + if (process.platform === 'win32') { + writeFileSync(fixture.interrupt, 'interrupt') + return + } + child.kill('SIGTERM') +} + function createEnvironmentProbeProfile(home: string, project: string): void { const pluginFile = join(project, 'environment-probe.mjs') writeFileSync(pluginFile, [ @@ -152,7 +172,8 @@ function createEnvironmentProbeProfile(home: string, project: string): void { " if (chunk.type === 'text-delta') text += chunk.text", ' }', ' process.stdout.write(`${text}\\n`)', - " process.kill(process.pid, 'SIGTERM')", + " if (process.platform === 'win32') process.emit('SIGTERM')", + " else process.kill(process.pid, 'SIGTERM')", ' })', '}', '', @@ -321,9 +342,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = startProfileLifecycle(fixture) try { await waitForFile(fixture.ready) - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) const result = await child - expect(result.exitCode).toBe(0) + expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0) expect(result.signal).toBeUndefined() expect(existsSync(fixture.disposed)).toBe(true) } finally { @@ -373,9 +394,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ].join('\n')) await waitForFile(fixture.ready) expect(readFileSync(configFile, 'utf8')).toBe('home') - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) const result = await child - expect(result.exitCode).toBe(0) + expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0) expect(result.signal).toBeUndefined() expect(existsSync(fixture.disposed)).toBe(true) } finally { diff --git a/apps/cli/tests/process-shutdown.spec.ts b/apps/cli/tests/process-shutdown.spec.ts index feceba708f..95e54fe754 100644 --- a/apps/cli/tests/process-shutdown.spec.ts +++ b/apps/cli/tests/process-shutdown.spec.ts @@ -20,35 +20,50 @@ afterEach(() => { }) describe('process shutdown', () => { - it('exits once after graceful disposal resolves or rejects', async () => { + it('completes naturally after disposal resolves and forces exit when it rejects', async () => { const resolvedExit = vi.fn() - const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit) + const resolvedComplete = vi.fn() + const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete) await resolved.shutdown(0) - expect(resolvedExit).toHaveBeenCalledOnce() - expect(resolvedExit).toHaveBeenCalledWith(0) + expect(resolvedComplete).toHaveBeenCalledOnce() + expect(resolvedComplete).toHaveBeenCalledWith(0) + expect(resolvedExit).not.toHaveBeenCalled() const rejectedExit = vi.fn() - const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit) + const rejectedComplete = vi.fn() + const rejected = createProcessShutdown( + () => Promise.reject(new Error('dispose failed')), + rejectedExit, + rejectedComplete, + ) await rejected.shutdown(1) expect(rejectedExit).toHaveBeenCalledOnce() expect(rejectedExit).toHaveBeenCalledWith(1) + expect(rejectedComplete).not.toHaveBeenCalled() }) - it('uses process.exit as the default process boundary', async () => { + it('uses process.exitCode for default normal completion', async () => { const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never) + const originalExitCode = process.exitCode + process.exitCode = undefined const shutdown = createProcessShutdown(() => Promise.resolve()) - await shutdown.shutdown(7) + try { + await shutdown.shutdown(7) - expect(exit).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(7) + expect(process.exitCode).toBe(7) + expect(exit).not.toHaveBeenCalled() + } finally { + process.exitCode = originalExitCode + } }) it('forces exit when graceful disposal reaches its bound', async () => { vi.useFakeTimers() const disposal = deferred() const exit = vi.fn() - const shutdown = createProcessShutdown(() => disposal.promise, exit) + const complete = vi.fn() + const shutdown = createProcessShutdown(() => disposal.promise, exit, complete) const pending = shutdown.shutdown(0) await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1) @@ -60,13 +75,14 @@ describe('process shutdown', () => { disposal.resolve() await pending expect(exit).toHaveBeenCalledOnce() + expect(complete).not.toHaveBeenCalled() }) it('honors a caller-supplied grace period', async () => { vi.useFakeTimers() const disposal = deferred() const exit = vi.fn() - const shutdown = createProcessShutdown(() => disposal.promise, exit, 25) + const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25) const pending = shutdown.shutdown(0) await vi.advanceTimersByTimeAsync(24) @@ -81,7 +97,8 @@ describe('process shutdown', () => { it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => { const disposal = deferred() const exit = vi.fn() - const shutdown = createProcessShutdown(() => disposal.promise, exit) + const complete = vi.fn() + const shutdown = createProcessShutdown(() => disposal.promise, exit, complete) const pending = shutdown.shutdown(0) shutdown.interrupt(130) @@ -91,13 +108,29 @@ describe('process shutdown', () => { disposal.resolve() await pending expect(exit).toHaveBeenCalledOnce() + expect(complete).not.toHaveBeenCalled() + }) + + it('forces exit after disposal started by a signal', async () => { + const disposal = deferred() + const exit = vi.fn() + const complete = vi.fn() + const shutdown = createProcessShutdown(() => disposal.promise, exit, complete) + + shutdown.interrupt(143) + disposal.resolve() + await shutdown.shutdown(0) + + expect(exit).toHaveBeenCalledOnce() + expect(exit).toHaveBeenCalledWith(143) + expect(complete).not.toHaveBeenCalled() }) it('drains on the first signal and forces on the second signal', async () => { const disposal = deferred() const dispose = vi.fn(() => disposal.promise) const exit = vi.fn() - const shutdown = createProcessShutdown(dispose, exit) + const shutdown = createProcessShutdown(dispose, exit, vi.fn()) shutdown.interrupt(143) await Promise.resolve() @@ -116,7 +149,8 @@ describe('process shutdown', () => { it('coalesces normal shutdown calls without treating them as escalation', async () => { const disposal = deferred() const exit = vi.fn() - const shutdown = createProcessShutdown(() => disposal.promise, exit) + const complete = vi.fn() + const shutdown = createProcessShutdown(() => disposal.promise, exit, complete) const first = shutdown.shutdown(0) const second = shutdown.shutdown(1) @@ -125,7 +159,21 @@ describe('process shutdown', () => { disposal.resolve() await first + expect(complete).toHaveBeenCalledOnce() + expect(complete).toHaveBeenCalledWith(0) + expect(exit).not.toHaveBeenCalled() + }) + + it('lets a signal force exit while natural completion drains remaining handles', async () => { + const exit = vi.fn() + const complete = vi.fn() + const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete) + + await shutdown.shutdown(0) + shutdown.interrupt(130) + + expect(complete).toHaveBeenCalledOnce() expect(exit).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(0) + expect(exit).toHaveBeenCalledWith(130) }) }) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 6c4f450529..7555be866b 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -176,6 +176,12 @@ describe('web e2e: message IconActions and clocks on settled history', () => { () => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }, ).toBe(1) + // The child row is published before its inherited title rename settles; + // wait for that second RPC projection before freezing the ARIA tree. + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').textContent(), + { timeout: 10_000 }, + ).toContain('Use the read tool twice (2)') const tree = await captureStableAria( page, '[role="tree"][aria-label="Sessions"]', diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index eed02571a6..e540ed7d90 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: 2a1e52016dd7eabe9f1110ca44fcf27ac9548390 -core.zh.md: 71a395eefcd2259c81fff86c1a217aef3a55f812 +core.md: 9a1fa827a7e0168e662bc4595a3c0fc486be8d49 +core.zh.md: e51f8b61fb6ed0c7a6e2de377f8f93877f972831 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 2a1e52016d..9a1fa827a7 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -480,7 +480,10 @@ serviceFor(agent: { ctx: Context }, name: K): * and permanent, so the old composition stays for its other agents and the * new one is ensured BEFORE the link moves. An unknown or unusable preset * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 71a395eefc..e51f8b61fb 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -488,7 +488,10 @@ serviceFor(agent: { ctx: Context }, name: K): * and permanent, so the old composition stays for its other agents and the * new one is ensured BEFORE the link moves. An unknown or unusable preset * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index 350ea4de89..ef5e972fc0 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -9,7 +9,7 @@ * writes CRLF on Windows, so exact text assertions normalize line endings. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -33,7 +33,9 @@ const lf = (text: string): string => text.replace(/\r\n/g, '\n') /** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */ function samePath(actual: string, expected: string): boolean { - const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value) + const norm = (value: string) => ( + process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : value + ) return norm(actual) === norm(expected) } @@ -72,7 +74,11 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => it('falls through an empty configured path to platform resolution', () => { // SystemRoot points at a non-existent tree so the Windows PowerShell 5.1 // fallback candidate cannot exist either. - expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh') + expect(resolvePwshPath('', { + PATH: 'P:\\Store', + ProgramFiles: 'P:\\no-program-files', + SystemRoot: 'S:\\no-windows', + }, 'win32')).toBe('pwsh') }) it('returns pwsh on non-Windows platforms regardless of the environment', () => { @@ -80,6 +86,13 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh') }) + it('uses stable Windows roots when the environment omits both overrides', () => { + expect(candidatePwshPaths({})).toEqual([ + join('C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + }) + it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => { const candidates = candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', @@ -154,12 +167,12 @@ describe('spawn construction (pure, every platform)', () => { }) describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { - it('resolves with output and the effective timeout', async () => { - const { bash } = await setup({ timeoutMs: 5_000 }) + it('resolves with output and the effective timeout', { timeout: 15_000 }, async () => { + const { bash } = await setup({ timeoutMs: 10_000 }) const result = await bash.run(bash.resolve({ command: 'Write-Output hi' })) expect(result.exitCode).toBe(0) expect(lf(result.stdout.text)).toBe('hi\n') - expect(result.timeoutMs).toBe(5_000) + expect(result.timeoutMs).toBe(10_000) }) it('uses config cwd, overridable per call', async () => { @@ -301,9 +314,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' env: { BG_VAR: 'bg-env' }, dshEnv: { DSH_BG_VAR: 'bg-dsh-env' }, })) - const output = await readUntil(proc, '[bg-env][bg-dsh-env]') - expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n') + const partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]') await proc.done + const output = partialOutput + lf(proc.readOutput().delta) + expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n') expect(proc.exitCode).toBe(0) }) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index 1892a6e73a..82cbd71c28 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -1,4 +1,5 @@ -import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -6,14 +7,19 @@ import { Context } from 'cordis' import Hmr from '@cordisjs/plugin-hmr' import Loader from '@cordisjs/plugin-loader' import Timer from '@cordisjs/plugin-timer' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' -async function bootHmr(dir: string): Promise { +async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(dir).href + '/' await ctx.plugin(Loader) await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await ctx.plugin(Hmr, { + root, + ignored: [], + debounce: 0, + ...usePolling === undefined ? {} : { usePolling }, + }) return ctx } @@ -26,6 +32,57 @@ async function eventually(test: () => boolean, message: string): Promise { } describe('HMR exact config paths', () => { + it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) + const alias = `${target}-alias` + const aliasFilename = join(alias, 'module.ts') + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(aliasFilename, 'export const generation = 0\n') + // This acceptance owns alias-to-cache identity. Other cases below exercise + // native events; polling keeps Windows fs.watch queue pressure out of it. + const ctx = await bootHmr(alias, ['.'], true) + const filename = join(await realpath(target), 'module.ts') + const expected = pathToFileURL(filename).href + const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) + const observed: string[] = [] + ctx.on('hmr/change', (url) => { observed.push(url) }) + try { + const deadline = Date.now() + 20_000 + for (let generation = 1; !observed.includes(expected); generation += 1) { + if (Date.now() >= deadline) { + throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`) + } + // The watch base, not the writer spelling, is the alias under test. + // Grow the file on every write: polling must not depend on timestamp + // precision when several generations land inside one filesystem tick. + writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`) + // Leave Chokidar's atomic-write window idle so one coalesced change can publish. + await new Promise(resolve => setTimeout(resolve, 250)) + } + expect(cacheHas).toHaveBeenCalledWith(expected) + } finally { + await ctx.fiber.dispose() + rmSync(alias, { force: true }) + rmSync(target, { recursive: true, force: true }) + } + }) + + it('collapses filesystem aliases before registering an exact watch', async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-')) + const alias = `${target}-alias` + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const ctx = await bootHmr(alias) + try { + await ctx.hmr.registerConfig('plugins.yml', () => {}) + await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {})) + .rejects.toThrow('config path already registered') + } finally { + await ctx.fiber.dispose() + rmSync(alias, { force: true }) + rmSync(target, { recursive: true, force: true }) + } + }) + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) const filename = join(dir, 'plugins.yml') @@ -133,6 +190,9 @@ describe('HMR exact config paths', () => { expect(observed.error).toBeInstanceOf(Error) expect(observed.error.message).toBe('42') + // Let Chokidar's atomic-write window close before requiring a distinct + // second notification from the same path. + await new Promise(resolve => setTimeout(resolve, 250)) writeFileSync(filename, 'invalid again') await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected') } finally { diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index 9689790ffc..71aa90a440 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -2,7 +2,7 @@ import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, join } from 'node:path' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +11,9 @@ import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@ const execFileAsync = promisify(execFile) const roots: string[] = [] +/** Normalize Git's platform checkout line endings for source-content assertions. */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + async function temporaryRoot(name: string): Promise { const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`)) roots.push(root) @@ -116,9 +119,13 @@ describe('RepositoryCache', () => { const root = await temporaryRoot('repository-pnpm') const repository = join(root, 'source') await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) - await mkdir(join(repository, 'build-helper'), { recursive: true }) - await mkdir(join(repository, 'prepare-helper'), { recursive: true }) + await mkdir(join(repository, '.dsh-plugin', 'build-helper'), { recursive: true }) + await mkdir(join(repository, '.dsh-plugin', 'prepare-helper'), { recursive: true }) await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) + const shadowPnpm = join(root, 'shadow-pnpm') + await mkdir(shadowPnpm) + await writeFile(join(shadowPnpm, 'pnpm'), '#!/bin/sh\nexit 99\n', { mode: 0o700 }) + await writeFile(join(shadowPnpm, 'pnpm.bat'), '@exit /b 99\r\n') await writeFile(join(repository, 'package.json'), `${JSON.stringify({ name: 'repository-fixture', private: true, @@ -135,38 +142,46 @@ describe('RepositoryCache', () => { ' .: {}', '', ].join('\n')) - await writeFile(join(repository, 'build-helper', 'package.json'), `${JSON.stringify({ + await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'package.json'), `${JSON.stringify({ name: 'repository-build-helper', version: '1.0.0', bin: 'index.js', })}\n`) - await writeFile(join(repository, 'build-helper', 'index.js'), [ + await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'index.js'), [ '#!/usr/bin/env node', "require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')", '', ].join('\n'), { mode: 0o700 }) - await writeFile(join(repository, 'prepare-helper', 'package.json'), `${JSON.stringify({ + await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'package.json'), `${JSON.stringify({ name: 'repository-prepare-helper', version: '1.0.0', bin: { 'dsh-plugin-prepare': 'index.js' }, })}\n`) - await writeFile(join(repository, 'prepare-helper', 'index.js'), [ + await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'index.js'), [ '#!/usr/bin/env node', "const { cpSync, mkdirSync, writeFileSync } = require('node:fs')", "mkdirSync('dsh-plugin-assets/skills', { recursive: true })", "cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", "writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')", - "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", + "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}|${process.env.PNPM_CONFIG_IGNORE_WORKSPACE ?? 'absent'}\\n`)", + "writeFileSync('environment.json', `${JSON.stringify({ path: process.env.PATH, pathExt: process.env.PATHEXT })}\\n`)", '', ].join('\n'), { mode: 0o700 }) await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ name: 'repository-plugin-fixture', version: '1.0.0', - scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' }, + scripts: { + // The fixture owns dependency installation, not platform-specific + // node_modules/.bin shim generation during pnpm's Git preparation. + prepack: [ + 'node ./node_modules/repository-build-helper/index.js', + 'node ./node_modules/repository-prepare-helper/index.js', + ].join(' && '), + }, devDependencies: { - 'repository-build-helper': 'file:../build-helper', - 'repository-prepare-helper': 'file:../prepare-helper', + 'repository-build-helper': 'file:./build-helper', + 'repository-prepare-helper': 'file:./prepare-helper', }, dsh: { skills: ['../skills'] }, })}\n`) @@ -181,13 +196,22 @@ describe('RepositoryCache', () => { const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin` vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') + vi.stubEnv('PNPM_HOME', shadowPnpm) + vi.stubEnv('PATH', [shadowPnpm, ...(process.env.PATH === undefined ? [] : [process.env.PATH])].join(delimiter)) + vi.stubEnv('PATHEXT', '.BAT;.CMD;.EXE') const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n') - await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') + await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent|true\n') + const environment = JSON.parse(await readFile(join(installed, 'environment.json'), 'utf8')) as { + path: string + pathExt: string + } + expect(environment.path.split(delimiter)).not.toContain(shadowPnpm) + expect(environment.pathExt.split(';')[0]?.toUpperCase()).toBe('.CMD') await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') - await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')) - .resolves.toBe('repository skill source\n') + expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))) + .toBe('repository skill source\n') await expect(readFile(join(installed, 'package.json'), 'utf8')) .resolves.toContain('repository-plugin-fixture') }) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 31a6423f87..3a04214298 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -166,7 +166,11 @@ describe('QueueDock', () => { expect(view.getByText('remove me')).toBeTruthy() expect(view.getByText('second')).toBeTruthy() - act(() => { finishUpdate?.() }) + expect(updateQueue).toHaveBeenCalledOnce() + await act(async () => { + finishUpdate?.() + await Promise.resolve() + }) await waitFor(() => { expect(header).toHaveProperty('disabled', false) expect(header.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index d9a4d53ec2..50cbad2549 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -19,7 +19,7 @@ */ import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core' -import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' +import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from 'shiki/engine/javascript' import langTs from '@shikijs/langs/typescript' import langBash from '@shikijs/langs/shellscript' import langJson from '@shikijs/langs/json' @@ -139,15 +139,49 @@ const cssVariablesTheme = createCssVariablesTheme({ fontStyle: true, }) +/** + * The client regex engine compiles each TextMate pattern when its scanner is + * created. Shiki otherwise defers patterns longer than 3,000 characters until + * their first match; that compilation counts against Shiki's 500 ms per-line + * budget and can return a partial token stream under host contention. Eager + * compilation leaves the same budget in place for scanning user content. + */ +const regexEngine = createJavaScriptRegexEngine({ + forgiving: true, + regexConstructor: pattern => defaultJavaScriptRegexConstructor(pattern, { + lazyCompileLength: Number.POSITIVE_INFINITY, + }), +}) + let singleton: HighlighterCore | undefined +/** Representative paths through every boot grammar, compiled before user content is timed. */ +const BOOT_GRAMMAR_WARMUPS = [ + { lang: 'typescript', code: 'const answer: number = 42' }, + { lang: 'shellscript', code: 'printf \'%s\\n\' "$HOME"' }, + { lang: 'json', code: '{"ready":true}' }, +] as const + +/** Construct and pre-tokenize the boot grammars outside the user-content scan budget. */ +function createHighlighter(): HighlighterCore { + const instance = createHighlighterCoreSync({ + themes: [cssVariablesTheme], + langs: LANGS, + engine: regexEngine, + }) + for (const sample of BOOT_GRAMMAR_WARMUPS) { + instance.codeToTokens(sample.code, { + lang: sample.lang, + theme: 'css-variables', + tokenizeTimeLimit: 0, + }) + } + return instance +} + /** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */ function highlighter(): HighlighterCore { - singleton ??= createHighlighterCoreSync({ - themes: [cssVariablesTheme], - langs: LANGS, - engine: createJavaScriptRegexEngine({ forgiving: true }), - }) + singleton ??= createHighlighter() return singleton } diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx index 2d56bc1a2d..06fc6c7d90 100644 --- a/packages/client/ui-primitives/tests/code-block.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.spec.tsx @@ -47,7 +47,7 @@ describe('highlightToHtml', () => { // Once every grammar has registered, the same call highlights. await vi.waitFor(() => { for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki') - }) + }, { timeout: 5_000 }) }) }) diff --git a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx index b36ce8c674..4397aea124 100644 --- a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx @@ -58,7 +58,7 @@ const STREAM_DOC = [ describe('incremental streaming rendering', () => { for (const chunkSize of [1, 3, 7, 16]) { - it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => { + it(`matches a fresh render at every prefix (chunk=${chunkSize})`, { timeout: 20_000 }, () => { const live = render() for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) { const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)) diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 89ffd32346..6b35712bd2 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -445,7 +445,7 @@ describe('MarkdownText', () => { const startedAt = performance.now() const { container } = render() - expect(performance.now() - startedAt).toBeLessThan(1_000) + expect(performance.now() - startedAt).toBeLessThan(3_000) expect(container.querySelector('.katex')).toBeNull() }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index ec4d7e138b..71073264cd 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -4111,17 +4111,18 @@ describe('dynamic nested workspace context injection', () => { } }) - it('warns when an asynchronous file-result projection fails', async () => { + it('warns when an asynchronous file-result projection fails', { timeout: 20_000 }, async () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem - const agent = stubAgent('/') + const root = resolve('/') + const agent = stubAgent(root) const failure = new Error('projection failed') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - fs.entries.set('/.git', { type: 'directory' }) - fs.entries.set('/AGENTS.md', { type: 'file', content: 'workspace rule' }) + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'workspace rule' }) vi.spyOn(agent.inbox, 'prepend').mockImplementationOnce(() => { throw failure }) ctx.emit('tools/result', stubToolExecution({ @@ -4134,7 +4135,7 @@ describe('dynamic nested workspace context injection', () => { await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('workspace instruction refresh failed: %o', failure) - }) + }, { timeout: 10_000 }) } finally { await ctx.fiber.dispose() } diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml index 3ceb7931b6..7df5df403c 100644 --- a/packages/core/scope/README.i18n.yaml +++ b/packages/core/scope/README.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 packages/core/scope/README.md -README.md: b73f99fdffb7e3dba5e4eb31b35ff623e8f3d57c -README.zh.md: dd54ef053a8b5d8507c5049dd924b3fbe85bf5ee +README.md: a8fbe97ae3b59f223bb52e44860439803fda420c +README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8 diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index b73f99fdff..a8fbe97ae3 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -2,12 +2,12 @@ English | [中文](README.zh.md) -Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`setScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either. +Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`bindScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either. ## Public API -- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` records the enclosing scope via `setScopeParent` before the scope is usable. -- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Ordinarily written once at mint; re-linking an existing key is the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the caller's contract — this relation cannot see what a session logged). A link closing a cycle throws. `scopeChainOf` returns `[key, parent, …]` nearest-first. +- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` binds the enclosing scope via `bindScopeParent` before the scope is usable; the binding stays internal. +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Binding is once: a key that already has a parent throws, and only the returned binding's `rebind(parent)` may re-link it — the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the holder's contract — this relation cannot see what a session logged). Both the bind and every rebind reject a link closing a cycle. `scopeChainOf` returns `[key, parent, …]` nearest-first. - `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins). - `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). - `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md index dd54ef053a..af23823298 100644 --- a/packages/core/scope/README.zh.md +++ b/packages/core/scope/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`setScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。 +带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`bindScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。 ## 公开 API -- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `setScopeParent` 记录其外围作用域。 -- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。通常在创建时写入一次;对已有键重新认父是空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是调用方的约定——该关系看不见会话记录了什么)。会闭环的链接直接抛错。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。 +- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `bindScopeParent` 绑定其外围作用域;绑定句柄不外泄。 +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。绑定仅此一次:已有父级的键直接抛错,只有返回的绑定句柄的 `rebind(parent)` 才能重新认父——即空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是持有方的约定——该关系看不见会话记录了什么)。绑定与每次 rebind 都拒绝会闭环的链接。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。 - `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber)继承标签;嵌套作用域会遮蔽外层标签(最近的标签生效)。 - `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式(generator)effect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置(Cordis 按函数标识去重嵌套 effect;yield 一个包装函数会使作用域 teardown 成为并行的同级操作)。 - `Scope.dispose(): Promise`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。 diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 7a23a97bcd..b5f58dbdf0 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -38,25 +38,49 @@ const carrierKeys = new WeakMap() */ const scopeParents = new WeakMap() -/** - * Record `parent` as `key`'s enclosing scope. - * - * Ordinarily set once when the child scope is minted ({@link createScope}'s - * `parent` option). Re-linking an existing key to a different parent is the - * blank-session recompose operation: valid only while nothing produced under - * the old parent is retained, which is the caller's contract to uphold — this - * relation cannot see what a session logged. A link that would close a cycle - * is rejected, because every chain consumer walks parents to the root. - * @param key - the child scope key. - * @param parent - its enclosing scope key. - */ -export function setScopeParent(key: ScopeKey, parent: ScopeKey): void { +/** The privileged handle to move one scope key's parent link. */ +export interface ScopeParentBinding { + /** + * Re-link the bound key to a different parent, with the same cycle check as + * the bind. Valid only while nothing produced under the old parent is + * retained — the blank-session recompose contract, which the holder upholds + * because this relation cannot see what a session logged. + * @param parent - the new enclosing scope key. + */ + rebind(parent: ScopeKey): void +} + +/** Cycle-checked write shared by the bind and every rebind. */ +function linkScopeParent(key: ScopeKey, parent: ScopeKey): void { for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) { if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle') } scopeParents.set(key, parent) } +/** + * Bind `parent` as `key`'s enclosing scope, once. + * + * A key that already has a parent throws: there is no open re-link path, so a + * scope's ancestry cannot be moved by anyone but the original binder, who + * alone receives the {@link ScopeParentBinding}. A link that would close a + * cycle is rejected, because every chain consumer walks parents to the root. + * @param key - the child scope key. + * @param parent - its enclosing scope key. + * @returns the binding that alone may re-link this key. + */ +export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding { + if (scopeParents.has(key)) { + throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind') + } + linkScopeParent(key, parent) + return { + rebind(next: ScopeKey): void { + linkScopeParent(key, next) + }, + } +} + /** * Read one key's enclosing scope. * @param key - the scope key to inspect. @@ -98,7 +122,7 @@ function scope(): void {} /** Options accepted by {@link createScope}. */ export interface CreateScopeOptions { - /** Enclosing scope recorded via {@link setScopeParent} before the scope is usable. */ + /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */ parent?: ScopeKey } @@ -111,7 +135,7 @@ export interface CreateScopeOptions { * @returns the scoped context and exact/shared disposal boundaries. */ export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope { - if (options?.parent !== undefined) setScopeParent(key, options.parent) + if (options?.parent !== undefined) bindScopeParent(key, options.parent) const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) let disposing: Promise | undefined @@ -134,7 +158,7 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { /** * Build an opaque receiver that preserves the base filter, admits untagged * listeners globally, and admits tagged listeners for a matching key or any - * of its ancestors ({@link setScopeParent}): a listener owned by an enclosing + * of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing * scope receives every descendant scope's events, which is what lets one * standing composition observe each of the agents composed under it. A tag * BELOW the dispatch key stays excluded — events flow up the chain, never diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 8e31fe1522..7007624d53 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget, setScopeParent } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' declare module 'cordis' { @@ -166,22 +166,30 @@ describe('scope parent chain', () => { expect(scopeParentOf(preset)).toBeUndefined() expect(scopeChainOf(agent)).toEqual([agent, preset]) expect(scopeChainOf(undefined)).toEqual([]) - expect(() => { setScopeParent(preset, agent) }).toThrow(/cycle/) - expect(() => { setScopeParent(preset, preset) }).toThrow(/cycle/) + expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/) + expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/) }) - it('re-links to a different parent (the blank-session recompose path)', () => { + it('re-links only through the binding held by the original binder', () => { const ctx = new Context() const presetA = { id: 'a' } const presetB = { id: 'b' } const agent = { id: 'agent' } createScope(ctx, presetA) createScope(ctx, presetB) - createScope(ctx, agent, { parent: presetA }) + const binding = bindScopeParent(agent, presetA) + createScope(ctx, agent) - setScopeParent(agent, presetB) + // A bound key cannot be re-bound from the outside; only the binding moves it. + expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/) + binding.rebind(presetB) expect(scopeChainOf(agent)).toEqual([agent, presetB]) + // The rebind keeps the cycle check: a parent may not adopt its ancestor. + const child = { id: 'child' } + const childBinding = bindScopeParent(child, agent) + void childBinding + expect(() => { binding.rebind(child) }).toThrow(/cycle/) }) it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 5b90029f65..34758b840f 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1589,7 +1589,7 @@ describe('per-agent presentation', () => { }) it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => { - const { setScopeParent } = await import('@deepseek-ai/dsh-scope') + const { bindScopeParent } = await import('@deepseek-ai/dsh-scope') const { ctx, systemPrompt } = await setup({ mode: 'native' }) registerEcho(ctx) // The preset's standing scope declares once; the agent only PARENTS to it @@ -1597,7 +1597,7 @@ describe('per-agent presentation', () => { const standing = await mintAgentScope(ctx, 'preset:code-like') standing.scope.ctx.tools.presentAs('code') const joined = await mintAgentScope(ctx, 'joined-agent') - setScopeParent(joined.agent, standing.agent) + bindScopeParent(joined.agent, standing.agent) const loner = await mintAgentScope(ctx, 'loner-agent') expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index f0971f5d17..cd1822b15a 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.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 packages/credentials/credentials-local/README.md -README.md: 6051c07628c0214f9396d12554719c5614610826 -README.zh.md: 23f48080136b5d212bef34d931a74773c5da4fd3 +README.md: 462618b990f8d07e9b855248db1e149b3c673964 +README.zh.md: 050216bb4b4d22ef53ab0823eef09c1aaf7d1170 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 6051c07628..462618b990 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -47,7 +47,7 @@ The provider creates the directory `0700` and creates or atomically replaces the ## Hot reload -External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. ## Security boundary diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 23f4808013..050216bb4b 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -47,7 +47,7 @@ OPENAI_API_KEY: sk-… ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index c95e0291e1..cdb4bd6eb8 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -42,7 +42,7 @@ import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { Document, parseDocument, type YAMLError } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { environmentOf } from '@deepseek-ai/dsh-environment' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' @@ -98,24 +98,27 @@ const GROUP_OTHER_BITS = 0o077 * here — so the check is skipped rather than faked, and the file's protection * there is whatever the create and replace APIs express. * @param filename - absolute path of the document. - * @throws when the file exists with group or other permission bits set. + * @throws when the path hierarchy is invalid or the file exists with group or other permission bits set. */ async function assertOwnerOnly(filename: string): Promise { - /* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */ - if (process.platform === 'win32') return let mode: number try { mode = (await stat(filename)).mode } catch (error) { if (!isENOENT(error)) throw error + await canonicalizeWatchPath(filename) return } + /* v8 ignore next -- POSIX coverage cannot take the Windows peer; native Windows coverage does. */ + if (process.platform === 'win32') return + /* v8 ignore start -- Windows has no POSIX mode enforcement; POSIX behavior tests enforce this peer. */ const offending = mode & GROUP_OTHER_BITS if (offending === 0) return throw new Error( `credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});` + ` run "chmod 600 ${filename}" before starting again`, ) + /* v8 ignore stop */ } /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ @@ -271,7 +274,7 @@ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- same watcher discipline as settings-local by design: the serialized-refresh and quiesce-on-dispose shape is the reviewed lifecycle contract, not accidental repetition. */ - const watcher = chokidarWatch(this.spec.filename, { + const watcher = chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.spec.debounceMs, diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 26c917492f..e9f65356bd 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -168,7 +168,7 @@ describe('layer ladder', () => { expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) }) - it('refuses a document other OS users can read', async () => { + it.skipIf(process.platform === 'win32')('refuses a document other OS users can read', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 }) @@ -191,6 +191,13 @@ describe('layer ladder', () => { .rejects.toThrow(/ENOTDIR/) }) + it('propagates a permission check rejected before the OS lookup', async () => { + const dir = await tempDir() + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials\0.yaml'), watch: false })) + .rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) + }) + it('propagates a read that fails for a reason other than absence', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') @@ -274,7 +281,7 @@ describe('document writes', () => { const seen = updates(ctx) await ctx.credentials.set(KEY, 'sk-fresh') expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n') - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) expect(seen).toEqual([KEY]) }) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts index fcec7fceb9..b7839cf538 100644 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -78,7 +78,7 @@ describe('read-modify-write', () => { const home = join(dir, 'home') const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false }) await ctx.credentials.set(ALPHA, 'one') - expect((await stat(home)).mode & 0o777).toBe(0o700) + if (process.platform !== 'win32') expect((await stat(home)).mode & 0o777).toBe(0o700) }) }) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 8c216e6976..e49f1421b4 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -6,6 +6,25 @@ import { join } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '../src/index.ts' +const fsHarness = vi.hoisted(() => ({ + nextReadError: undefined as NodeJS.ErrnoException | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readFile: (async (path: unknown, ...rest: never[]) => { + const error = fsHarness.nextReadError + if (error !== undefined) { + fsHarness.nextReadError = undefined + throw error + } + return (actual.readFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.readFile, + } +}) + /** Credential documents are seeded owner-only, exactly as the provider creates them. */ function writeCredentials(file: string, text: string): Promise { return writeFile(file, text, { mode: 0o600 }) @@ -48,6 +67,7 @@ const KEY = credentialRef('DSH_CRED_PIPE') const cleanups: Array<() => Promise> = [] afterEach(async () => { + fsHarness.nextReadError = undefined while (cleanups.length > 0) await cleanups.pop()!() ;(await fakeInstances()).length = 0 }) @@ -107,6 +127,21 @@ describe('watcher pipeline', () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) }) + it('keeps the last good snapshot when the read fails after its permission check', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_PIPE: good\n') + const ctx = await boot({ path, debounceMs: 5 }) + fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' }) + + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(() => { + expect(fsHarness.nextReadError).toBeUndefined() + }) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) + }) + it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index 5472b2cfa7..a142817870 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -923,4 +923,20 @@ describe('E2B subprocess terminal service', () => { await fiber.dispose() await expect(terminal.terminate()).resolves.toBeUndefined() }) + + it('contains an immediate automatic terminal release rejection before disposal retries it', async () => { + const { fiber, fake } = await service() + fake.groups = [] + const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec()) + const terminate = vi.spyOn(terminal, 'terminate') + .mockRejectedValueOnce(new Error('automatic release failed')) + fake.handle.succeed(0) + await terminal.done + await vi.waitFor(() => { expect(terminate).toHaveBeenCalledTimes(1) }) + await new Promise(resolve => setTimeout(resolve, 0)) + + await fiber.dispose() + expect(terminate).toHaveBeenCalledTimes(2) + expect(fake.handle.disconnects).toBe(1) + }) }) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 5757e5f245..8ffd971829 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' @@ -416,7 +416,7 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) - it('snapshots a created project skill through catalog refresh and progressive loading', async () => { + it('snapshots a created project skill through catalog refresh and progressive loading', { timeout: 15_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-')) const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-home-')) try { @@ -449,6 +449,15 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(LocalFileSystem, { cwd: root }) await ctx.plugin(ToolFs) + ctx.on('tools/post-execute', async (exec, _result, next) => { + const decision = await next() + if (exec.callId === 'write-skill') { + await vi.waitFor(async () => { + expect((await ctx.skills.list({ cwd: root })).map(skill => skill.name)).toContain('hot-skill') + }, { timeout: 5_000 }) + } + return decision + }) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ sessionId: SessionId('skill-refresh-session'), @@ -491,7 +500,8 @@ describe('dsh-agent-spine-demo bundle', () => { callId: event.data.message.source.callId, isError: result.isError, text: result.content.map(block => block.type === 'text' ? block.text : '').join('\n') - .replaceAll(root, '{{cwd}}'), + .replaceAll(root, '{{cwd}}') + .replaceAll(sep, '/'), }] } return [] diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 637c2fcbe0..ced130d7f7 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( - workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + workspaceRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 7922592d01..2ab50d6e80 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -22,6 +22,29 @@ import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' import * as DirectoryPickerAuto from '../src/index.ts' +const renameControl = vi.hoisted(() => ({ + attempts: 0, + failureCode: 'EPERM', + injectedFailures: 0, + remainingFailures: 0, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async rename(oldPath: string, newPath: string): Promise { + renameControl.attempts++ + if (renameControl.remainingFailures > 0) { + renameControl.remainingFailures-- + renameControl.injectedFailures++ + throw Object.assign(new Error(`injected rename failure for ${newPath}`), { code: renameControl.failureCode }) + } + await actual.rename(oldPath, newPath) + }, + } +}) + const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto' const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native' const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse' @@ -41,6 +64,10 @@ afterEach(async () => { } root = undefined fakeBin = undefined + renameControl.attempts = 0 + renameControl.failureCode = 'EPERM' + renameControl.injectedFailures = 0 + renameControl.remainingFailures = 0 }) /** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ @@ -163,9 +190,30 @@ describe('real Loader composition', () => { const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! await ctx.loader.remove(backendEntry.id) const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! + renameControl.remainingFailures = 1 await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) // Same self-dispose persistence as above: let the write land before teardown. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') + expect(renameControl.injectedFailures).toBe(1) + expect(renameControl.remainingFailures).toBe(0) + expect(renameControl.attempts).toBeGreaterThanOrEqual(2) + }) + + it('reports a terminal debounced-write failure again to the teardown owner', { timeout: 60_000 }, async () => { + stubAttendedHost() + const { ctx } = await loadComposition('127.0.0.1') + const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! + const include = [...ctx.loader.entries()] + .find(entry => entry.options.name === 'cordis:include')?.subtree as Include | undefined + if (include === undefined) throw new Error('expected the root Include tree') + renameControl.failureCode = 'EIO' + renameControl.remainingFailures = 1 + + await autoEntry.fiber!.dispose() + await expect.poll(() => renameControl.injectedFailures).toBe(1) + await expect(include.stop()).rejects.toMatchObject({ code: 'EIO' }) + await expect(ctx.fiber.dispose()).resolves.not.toThrow() + context = undefined }) }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index d2e101505d..a8a06a167d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -272,7 +272,7 @@ describe('PiAiAdapter provider routing', () => { await Promise.race([ server.responseClosed, new Promise((_resolve, reject) => { - setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 100) + setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 1_000) }), ]) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 85221e7ca2..02b859d5f2 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -275,10 +275,28 @@ describe('draft-provider model discovery', () => { it('reports cancellation during the body read as an abort, not a raw reason', async () => { const ctx = await harness() const controller = new AbortController() - // Chunked, so the headers arrive and the cancellation lands mid-body. - const slow = await listingServer({ chunks: ['{"data":[', '{"id":"a"}'], holdOpenMs: 400 }) - const probe = ctx.llm.discoverModels('llm-pi-ai', { baseURL: slow.url, signal: controller.signal }) - setTimeout(() => { controller.abort('test cancellation') }, 40) + const bodyRead = Promise.withResolvers() + vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => { + const signal = init?.signal + if (signal === undefined || signal === null) throw new Error('expected a discovery signal') + return new Response(new ReadableStream({ + pull(stream) { + bodyRead.resolve(undefined) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { + stream.error(signal.reason) + resolve() + }, { once: true }) + }) + }, + })) + }) + const probe = ctx.llm.discoverModels('llm-pi-ai', { + baseURL: 'https://slow.example/v1', + signal: controller.signal, + }) + await bodyRead.promise + controller.abort('test cancellation') await expect(probe).rejects.toMatchObject({ code: 'ABORTED' }) }) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 88b2b0144f..24edca4795 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -38,9 +38,9 @@ describe('lsp-local provider resolution', () => { // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it. const bin = join(root, 'bin') await mkdir(bin) - const exe = join(bin, 'fake-lsp') - await writeFile(exe, '#!/bin/sh\nexit 0\n') - await chmod(exe, 0o755) + const exe = join(bin, process.platform === 'win32' ? 'fake-lsp.cmd' : 'fake-lsp') + await writeFile(exe, process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n') + if (process.platform !== 'win32') await chmod(exe, 0o755) const ctx = new Context() await ctx.plugin(Lsp) @@ -49,7 +49,7 @@ describe('lsp-local provider resolution', () => { await expect(ctx.plugin(LspLocal, config('onpath', { command: 'fake-lsp', args: [], - env: { PATH: bin }, + env: { PATH: bin, ...process.platform === 'win32' ? { PATHEXT: '.CMD' } : {} }, extensionToLanguage: { '.ts': 'typescript' }, }))).resolves.toBeDefined() await ctx.fiber.dispose() diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts index 2d181f25b3..51fb6a5083 100644 --- a/packages/lsp/tool-lsp/tests/render.spec.ts +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -55,7 +55,7 @@ describe('renderUri', () => { it('returns an absolute path for a file: URI outside the workspace', () => { const outside = resolve(WS, '..', 'other', 'lib', 'b.ts') const uri = pathToFileURL(outside).href - expect(renderUri(uri, WS_URI)).toBe(outside) + expect(renderUri(uri, WS_URI)).toBe(outside.replaceAll('\\', '/')) }) it('renders the workspace root itself as "."', () => { @@ -83,7 +83,7 @@ describe('renderUri', () => { }) it('preserves backslashes as ordinary POSIX filename characters', () => { - expect(renderUri('file:///home/u/proj/dir%5Cname/a.ts', WS_URI)).toBe('dir\\name/a.ts') + expect(renderUri('file:///home/u/proj/dir%5Cname/a.ts', 'file:///home/u/proj')).toBe('dir\\name/a.ts') }) it('keeps malformed or mismatched URI coordinates verbatim', () => { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 6c7099da9b..58f523c4e3 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -8,7 +8,7 @@ * projection units exist exactly once, keyed per session inside the plugins * themselves (they predate presets and were written for a shared world). An * agent joins by having its scope key parented to the mount's - * ({@link setScopeParent}), which makes the mount's registrations visible to + * ({@link bindScopeParent}), which makes the mount's registrations visible to * that agent's views and the mount's listeners receive that agent's events — * and a host reader with no agent at all (a cold transcript read) resolves * the same standing registrations by preset id. @@ -24,7 +24,7 @@ import { stat } from 'node:fs/promises' import { Context, Service } from 'cordis' import z from 'schemastery' -import { createScope, scopeOf, setScopeParent, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' @@ -202,6 +202,14 @@ export class AgentPresets extends Service { */ private readonly standing = new Map>() + /** + * Parent bindings of the agents this roster composed, keyed by the agent's + * scope key. The binding is dsh-scope's only re-link capability; holding it + * here makes this service the sole authority that can move an agent between + * standing compositions. WeakMap: entries die with their agents. + */ + private readonly bindings = new WeakMap() + /** * Compose one agent from a preset: ensure the preset's standing mount, then * parent the agent's scope key to it so the mount's registrations and @@ -222,7 +230,11 @@ export class AgentPresets extends Service { } const preset = await this.resolveMountable(id) const standing = await this.ensureStanding(preset) - setScopeParent(agentKey, standing.key) + // The one bind of this agent's ancestry. The binding is the only re-link + // authority, held privately so nothing outside this roster can move a + // composed agent to another preset; a later recompose layer re-links + // through it under the caller-owned blank-session contract. + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) return preset } @@ -325,7 +337,10 @@ export class AgentPresets extends Service { * and permanent, so the old composition stays for its other agents and the * new one is ensured BEFORE the link moves. An unknown or unusable preset * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. @@ -338,7 +353,12 @@ export class AgentPresets extends Service { } const preset = await this.resolveMountable(id) const standing = await this.ensureStanding(preset) - setScopeParent(agentKey, standing.key) + const binding = this.bindings.get(agentKey) + if (binding === undefined) { + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + } else { + binding.rebind(standing.key) + } return preset } diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index a1524fcc7a..b4988331cc 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -16,7 +16,7 @@ import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' import type { Config } from '@deepseek-ai/dsh-agent-presets' -import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' declare module 'cordis' { interface Context { @@ -216,7 +216,7 @@ describe('rejecting a composition that cannot be used', () => { const loner = createScope(ctx, { test: 'loner' }) expect(serviceForAgent(ctx, { ctx: loner.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() const orphan = createScope(ctx, { test: 'orphan' }) - setScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' }) + bindScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' }) expect(serviceForAgent(ctx, { ctx: orphan.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() }) @@ -410,8 +410,9 @@ describe('replacing a composition', () => { }) it('composes an agent that had nothing installed', async () => { - // An agent created without a preset has no subtree to discard, so the - // swap is a plain mount rather than a restore-on-failure path. + // An agent created without a preset has no binding to re-link, so the + // switch is its first bind — exactly a mount — and once bound only the + // roster's kept binding can move it again. const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') }) await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 7b789cbd1f..28a950eacf 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -95,7 +95,9 @@ type StubMode = | 'spawn-error' | 'send-error' | 'prompt-after-idle' + | 'incremental-fallback' | 'empty-page-after-latest' + | 'paged-scrollback' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -165,6 +167,10 @@ class StubPtySession implements PtyBackendSession { this.pendingText = '' const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0] const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0] + if (this.mode === 'incremental-fallback') { + const incremental = `${start ?? ''}\nincrement\n${this.motd}` + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) + } if (this.mode === 'torn-status') { const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` this.scrollback += output @@ -211,6 +217,19 @@ class StubPtySession implements PtyBackendSession { return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false } } const lines = this.scrollback.split('\n') + if (this.mode === 'paged-scrollback') { + const offset = request.offset ?? 0 + const end = lines.length - offset + const start = Math.max(0, end - 3) + const returnedLines = end - start + return { + text: lines.slice(start, end).join('\n'), + totalLines: lines.length, + lineBegin: offset, + lineEnd: offset + returnedLines, + truncated: this.historyTruncated, + } + } return { text: this.scrollback, totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length, @@ -237,10 +256,10 @@ class StubPtySession implements PtyBackendSession { return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } } - private operation(done: Promise>): PtySendOperation { + private operation(done: Promise>, delta = ''): PtySendOperation { return { done, - readOutput: () => ({ delta: '', truncated: false }), + readOutput: () => ({ delta, truncated: false }), cancel: () => false, } } @@ -317,6 +336,10 @@ describe('tool-bash-persistent', () => { session.mode = 'idle-then-normal' expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from') + session.mode = 'incremental-fallback' + session.scrollback = '' + expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment') + session.mode = 'prompt-only' const promptFallback = text(await call(ctx, owner, 'bad {')) expect(promptFallback).toContain('bash: synt') @@ -402,6 +425,16 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub') }) + it('assembles retained output across backward scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'paged-scrollback' + session.scrollback = 'older one\nolder two\nolder three\nolder four\n' + + expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub') + }) + it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') diff --git a/packages/scaffold/client/tests/sdk-client.spec.ts b/packages/scaffold/client/tests/sdk-client.spec.ts index 8b76af755e..a710a44c1d 100644 --- a/packages/scaffold/client/tests/sdk-client.spec.ts +++ b/packages/scaffold/client/tests/sdk-client.spec.ts @@ -173,7 +173,8 @@ describe('DeepSeekHarness', () => { it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => { // vitest workers forbid chdir, so derive a RELATIVE path from the real // process cwd to a temp worker dir; resolution is lexical either way. - const dir = await tempDir('sdk-client-relcwd-') + const dir = await mkdtemp(join(process.cwd(), '.dsh-sdk-client-relcwd-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) const recordFile = join(dir, 'init.jsonl') const inner = join(dir, 'worker') await mkdir(inner) @@ -332,7 +333,11 @@ describe('HarnessClient', () => { )) await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) await client.close() - expect((await stat(sigtermFile)).isFile()).toBe(true) + if (process.platform === 'win32') { + await expect(stat(sigtermFile)).rejects.toMatchObject({ code: 'ENOENT' }) + } else { + expect((await stat(sigtermFile)).isFile()).toBe(true) + } }) it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => { diff --git a/packages/scaffold/server/tests/server.spec.ts b/packages/scaffold/server/tests/server.spec.ts index 0acfcfccef..714fc0ada3 100644 --- a/packages/scaffold/server/tests/server.spec.ts +++ b/packages/scaffold/server/tests/server.spec.ts @@ -109,7 +109,7 @@ async function settleSubagent( } describe('HarnessSdkServer', () => { - it('creates a harness agent and calls the configured OpenAI-compatible endpoint', async () => { + it('creates a harness agent and calls the configured OpenAI-compatible endpoint', { timeout: 15_000 }, async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-')) const llmServer = await mockCompletionServer() vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') @@ -295,7 +295,7 @@ describe('HarnessSdkServer', () => { } }) - it('creates an SDK session without an optional system prompt', async () => { + it('creates an SDK session without an optional system prompt', { timeout: 15_000 }, async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-')) const llmServer = await mockCompletionServer() vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 9836e3e84c..3543d0fc98 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -128,7 +128,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async recompose(agentCtx: Context, id: string): Promise', - jsDoc: '/**\n * Re-link one agent to a different preset\'s standing composition.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot\n * make. The CALLER owns that check — this method does not read session\n * history.\n *\n * The swap is a parent re-link, not an unmount: standing mounts are shared\n * and permanent, so the old composition stays for its other agents and the\n * new one is ensured BEFORE the link moves. An unknown or unusable preset\n * therefore throws with the agent exactly as it was — there is no torn-down\n * state to restore.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable.\n */', + jsDoc: '/**\n * Re-link one agent to a different preset\'s standing composition.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot\n * make. The CALLER owns that check — this method does not read session\n * history.\n *\n * The swap is a parent re-link, not an unmount: standing mounts are shared\n * and permanent, so the old composition stays for its other agents and the\n * new one is ensured BEFORE the link moves. An unknown or unusable preset\n * therefore throws with the agent exactly as it was — there is no torn-down\n * state to restore. The re-link runs through the binding this roster kept\n * from the agent\'s mount — dsh-scope\'s only re-link authority. An agent\n * that never composed one has nothing to re-link: the switch is then the\n * agent\'s first bind, exactly a mount.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, { signature: 'async standingKeyFor(id?: string): Promise', diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 96cd94537f..8427ebedc4 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1053,7 +1053,7 @@ describe('SQLite reconciliation and source lifecycle', () => { .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) }) - it('preserves unchanged persisted generations while reconciling new, changed, and deleted rows', async () => { + it('preserves unchanged persisted generations while reconciling new, changed, and deleted rows', { timeout: 20_000 }, async () => { const path = await temporaryPath() const unchanged = header('unchanged') const changed = header('changed') @@ -1239,7 +1239,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => expect(ctx.sessionQuery).toBeUndefined() }) - it('resets a recognized incompatible schema but refuses unknown or foreign tables', async () => { + it('resets a recognized incompatible schema but refuses unknown or foreign tables', { timeout: 20_000 }, async () => { const stalePath = await temporaryPath('stale.db') const staleOwner = await liveContext({ path: stalePath }) await (staleOwner.sessionQuery as SessionQuerySqlite).close() diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index 09c9c2e812..a00049eb3a 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -31,7 +31,7 @@ function fakeAgent(session: Session): Agent { } describe('tool-session-query with the real SQLite provider', () => { - it('searches live prior-step history and a persisted same-workspace log', async () => { + it('searches live prior-step history and a persisted same-workspace log', { timeout: 20_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-')) temporaryDirectories.push(root) const ctx = new Context() diff --git a/packages/session/session-persistence-jsonl/src/win32.ts b/packages/session/session-persistence-jsonl/src/win32.ts index 5b2b034574..ccc86025a8 100644 --- a/packages/session/session-persistence-jsonl/src/win32.ts +++ b/packages/session/session-persistence-jsonl/src/win32.ts @@ -91,7 +91,10 @@ function isEEXIST(error: unknown): boolean { async function assertDirectory(path: string): Promise { try { - const info = await stat(path) + // A bare drive root is already short, and Node rejects its extended-length + // spelling as EISDIR. Descendants retain the namespace for long-path probes. + const probe = path === parse(path).root ? path : toNamespacedPath(path) + const info = await stat(probe) if (info.isDirectory()) return true const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException error.code = 'ENOTDIR' @@ -141,7 +144,7 @@ export async function ensureDurableDirectoryWin32(target: string): Promise async function createLeafDirectoryWin32(parent: string, target: string): Promise { // Keep the staging component independent of the target basename so a legal // 255-byte target component does not make mkdtemp's sibling name too long. - const staging = await mkdtemp(join(parent, '.dsh-mkdir-')) + const staging = await mkdtemp(toNamespacedPath(join(parent, '.dsh-mkdir-'))) try { await publishNewFileWin32(staging, target) } catch (error) { diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index ff4e6cec67..c7b4ab8841 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -62,6 +62,17 @@ async function expectFlushError(promise: Promise, message: RegExp): Pro throw new Error('expected flush to reject') } +async function expectFlushCode(promise: Promise, codes: readonly string[]): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect(codes).toContain((error as NodeJS.ErrnoException).code) + return + } + throw new Error('expected flush to reject') +} + async function freshRoot(): Promise { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-')) dirs.push(dir) @@ -1363,7 +1374,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } }) appendClosedTurn(s) }, { inject: ['sessions'] })) - await expect(ctx2.sessions.flush(s)).rejects.toThrow(/EEXIST|ENOTDIR/) + await expectFlushCode(ctx2.sessions.flush(s), ['EEXIST', 'ENOTDIR']) await ctx2.fiber.dispose() }) diff --git a/packages/session/session-persistence-jsonl/tests/win32.spec.ts b/packages/session/session-persistence-jsonl/tests/win32.spec.ts index 647ff8b292..3b6cfc4f78 100644 --- a/packages/session/session-persistence-jsonl/tests/win32.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/win32.spec.ts @@ -92,11 +92,43 @@ async function importWithFilesystemMove(): Promise { vi.doUnmock('koffi') + vi.doUnmock('node:fs/promises') + vi.doUnmock('node:path') vi.resetModules() for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) describe('Windows durable namespace helpers', () => { + it('keeps drive-root probes native while namespacing descendants', async () => { + const probes: string[] = [] + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: async (path: string) => { + probes.push(path) + return { isDirectory: () => true } + }, + } + }) + vi.doMock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + join: (...paths: string[]) => actual.win32.join(...paths), + parse: (path: string) => actual.win32.parse(path), + resolve: (...paths: string[]) => actual.win32.resolve(...paths), + toNamespacedPath: (path: string) => actual.win32.toNamespacedPath(path), + } + }) + const { ensureDurableDirectoryWin32 } = await import('../src/win32.ts') + + await ensureDurableDirectoryWin32('C:\\existing') + + expect(probes).toEqual(['C:\\', '\\\\?\\C:\\existing']) + }) + it('publishes a new file with write-through MoveFileExW semantics', async () => { const { publishNewFileWin32 } = await importWithFilesystemMove() const root = await tempRoot() diff --git a/packages/session/session-telemetry-otel/tests/user-id.spec.ts b/packages/session/session-telemetry-otel/tests/user-id.spec.ts index 63106c7316..f7abf45f0b 100644 --- a/packages/session/session-telemetry-otel/tests/user-id.spec.ts +++ b/packages/session/session-telemetry-otel/tests/user-id.spec.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -17,7 +17,6 @@ function tempHome(): string { afterEach(() => { for (const dir of dirs.splice(0)) { - chmodSync(dir, 0o700) rmSync(dir, { recursive: true, force: true }) } }) @@ -69,11 +68,10 @@ describe('getOrCreateAnonymousUserId', () => { expect(id).toBe(winner) }) - it('returns a usable id when the home is unwritable, without persisting', () => { + it('returns a usable id when the home cannot contain files, without persisting', () => { const home = tempHome() const blocked = join(home, 'blocked') - mkdirSync(blocked) - chmodSync(blocked, 0o500) + writeFileSync(blocked, 'occupied\n') const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: blocked } }) expect(id).toMatch(UUID) expect(existsSync(join(blocked, USER_ID_FILE_NAME))).toBe(false) diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index 83a2d75b38..339ff6c3f5 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-local/README.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 packages/settings/settings-local/README.md -README.md: d1f3d755f9073acdf6fcfc5d1de883d74cc023c4 -README.zh.md: 2fe97435d56774351343b92fcbd5f80ee2680fd1 +README.md: a0bc94630f78aa7c101e3eb0795a7d07e585e347 +README.zh.md: 5d829effb2c2422e957b7b32c6e8a2cf715af659 diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index d1f3d755f9..a0bc94630f 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -24,6 +24,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension - **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments. - **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. - **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap. +- **The native watcher receives a canonical path.** Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix. File access and user-facing diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form event paths inside libuv. - **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal. - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. - **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index 2fe97435d5..5d829effb2 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -24,6 +24,7 @@ - **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。 - **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 - **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。 +- **原生 watcher 接收规范化路径。** 在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀。文件访问和面向用户的诊断仍使用配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。 - **dispose(资源释放)在每种 watch 模式下都保证完全停稳。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。 - **按内容抑制自写。** 提供方缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 - **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。 diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 0ce07af40c..16bc641f05 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -14,7 +14,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' /** Plugin config: file location and hot-reload behavior. */ @@ -235,7 +235,7 @@ export class SettingsLocal extends Settings { // silently ignored or overwritten. yield* super[Service.init]() const watcher = this.spec.watch - ? chokidarWatch(this.spec.filename, { + ? chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.spec.debounceMs, diff --git a/packages/settings/settings-local/tests/concurrency.spec.ts b/packages/settings/settings-local/tests/concurrency.spec.ts index 1c6996434a..5df2179219 100644 --- a/packages/settings/settings-local/tests/concurrency.spec.ts +++ b/packages/settings/settings-local/tests/concurrency.spec.ts @@ -86,7 +86,7 @@ describe('writer lock', () => { expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n') }, 10_000) - it('surfaces a non-contention lock failure as the write error', async () => { + it.skipIf(process.platform === 'win32')('surfaces a non-contention lock failure as the write error', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') const ctx = await boot({ path, watch: false }) diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 7df16b48d3..8e89cd4643 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' @@ -67,7 +67,7 @@ describe('boot and reads', () => { await expect(ctx.settings.prepareDocument()).resolves.toBe(path) expect(await readFile(path, 'utf8')).toBe('') - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) }) @@ -128,7 +128,7 @@ describe('boot and reads', () => { expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) }) - it('fails loud at boot when the document exists but is unreadable', async () => { + it.skipIf(process.platform === 'win32')('fails loud at boot when the document exists but is unreadable', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') await writeFile(path, 'ui-theme:\n theme: light\n') @@ -137,6 +137,13 @@ describe('boot and reads', () => { await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i) }) + it('fails loud when the document path names a directory', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await mkdir(path) + await expect(boot({ path, watch: false })).rejects.toThrow(/EISDIR|directory/i) + }) + it('fails loud on an unsupported extension', async () => { const dir = await tempDir() await expect(boot({ path: join(dir, 'settings.toml'), watch: false })) @@ -168,7 +175,7 @@ describe('persist', () => { const written = await readFile(path, 'utf8') expect(written).toContain('theme: light') - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) // Atomic replace leaves no temp artifact behind. expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) }) @@ -203,7 +210,7 @@ describe('persist', () => { expect(await readFile(victim, 'utf8')).toBe('precious') expect((await lstat(path)).isSymbolicLink()).toBe(false) - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(await readFile(path, 'utf8')).toContain('theme: light') }) @@ -337,16 +344,18 @@ describe('persist', () => { expect(written).toEqual({ 'ui-theme': { theme: 'light' } }) }) - it('rejects and leaves no temp residue when the directory turns unwritable', async () => { + it('rejects and recovers when the document path becomes a directory', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') + const backup = join(dir, 'settings.committed.yaml') await writeFile(path, 'ui-theme:\n theme: light\n') const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) - await chmod(dir, 0o500) - cleanups.push(() => chmod(dir, 0o700)) + await rename(path, backup) + await mkdir(path) await expect(scope.update({ theme: 'dark' })).rejects.toThrow() - await chmod(dir, 0o700) + await rm(path, { recursive: true }) + await rename(backup, path) expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) expect(scope.get().theme).toBe('light') // The failed persist must not poison the document write chain. @@ -388,7 +397,9 @@ describe('watch', () => { const ctx = await boot({ path, debounceMs: 10 }) const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) - await writeFile(path, 'ui-theme: [unclosed\n') + // Replace the external edit atomically so this case observes one complete + // invalid document instead of a transient empty file during truncation. + await writeFileAtomic(path, 'ui-theme: [unclosed\n', { mode: 0o600 }) // The bad edit must never take the live tree down or reset the value. await new Promise(resolve => setTimeout(resolve, 300)) expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index fb7d31ce14..ed7a501145 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.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 packages/skill/skill-local/README.md -README.md: c130c8b525367d7a80e32f0684cd05c931b35944 -README.zh.md: 878725e64413d3e17baf7693ee72b192a23292e6 +README.md: aa25278750b5a1577eb567e50344fb3af425d71a +README.zh.md: 59abd5623da189d0b5d739eec56e034b553690b9 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index c130c8b525..aa25278750 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -44,11 +44,11 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read ## Catalog Change Detection -Existing skill roots are watched with Chokidar. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation. +Existing skill roots are watched with Chokidar. Before opening a native watcher, the provider realpaths the existing root or ancestor and restores the next missing segment; when `watchFollowSymlinks` is false and the root itself is a symbolic link, it preserves that final link so Chokidar can enforce the configured boundary. Discovery and diagnostics retain the configured path, while Windows cannot otherwise mix an 8.3 alias with long-form libuv events. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation. A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery. -The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks. +The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Existing-root watchers remain persistent until effect teardown so Chokidar owns asynchronous native error events; startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks. ## Skill Format diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 878725e644..59abd5623d 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -44,11 +44,11 @@ ## 目录变更检测 -现有 skill 根由 Chokidar 监视。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。 +现有 skill 根由 Chokidar 监视。打开原生 watcher 前,提供方会对现有根或祖先执行 realpath 解析,并拼回下一个缺失路径段;当 `watchFollowSymlinks` 为 false 且根本身是符号链接时,提供方不会展开最后这一级链接,使 Chokidar 能够强制执行配置边界。发现与诊断仍保留配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。 不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。 -如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。 +如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。现有根的 watcher 会保持持久状态直至 effect 释放,使 Chokidar 能够接管异步原生错误事件;watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。 ## skill 格式 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 02a3b4fc39..996a12329f 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-skill-local */ -import { access, readdir, readFile, stat } from 'node:fs/promises' +import { access, lstat, readdir, readFile, stat } from 'node:fs/promises' import { unwatchFile, watchFile, type Stats } from 'node:fs' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' @@ -19,7 +19,7 @@ import z from 'schemastery' import type Schema from 'schemastery' import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { BUNDLED_SKILL_RANK, isSkillName, @@ -394,7 +394,7 @@ class SkillWatchManager { private async ensureCurrentWatcher(state: RootWatchState): Promise { const watcher = state.watcher if (watcher !== undefined && !state.unhealthy) { - const current = await resolveRootWatchMode(state.root.path) + const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks) // A child unlink can publish an empty catalog before root unlinkDir arrives. // Discovery therefore revalidates the retained handle independently. // oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits @@ -436,11 +436,11 @@ class SkillWatchManager { // service; keep skill filtering and invalidation here. private async openStableWatcher(state: RootWatchState): Promise { while (!this.closing && state.owners.size > 0) { - const mode = await resolveRootWatchMode(state.root.path) + const mode = await resolveRootWatchMode(state.root.path, this.config.followSymlinks) const watcher = mode.kind === 'ancestor' ? this.openAncestorWatcher(state, mode) : await this.openRootWatcher(state, mode) - const current = await resolveRootWatchMode(state.root.path) + const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks) /* v8 ignore else -- A host path transition between the two probes is timing-dependent. */ if (sameWatchMode(mode, current)) return watcher /* v8 ignore next -- Covered by the same host path transition guard. */ @@ -472,7 +472,7 @@ class SkillWatchManager { ): Promise { let current: RootWatchMode try { - current = await resolveRootWatchMode(state.root.path) + current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks) } catch (error) { /* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */ if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error) @@ -487,7 +487,9 @@ class SkillWatchManager { private async openRootWatcher(state: RootWatchState, mode: Extract): Promise { const watcher = chokidar.watch(mode.anchor, { - persistent: false, + // Chokidar owns late native fs.watch errors only for persistent watchers; + // this provider's effect explicitly closes every handle at teardown. + persistent: true, ignoreInitial: true, depth: 1, followSymlinks: this.config.followSymlinks, @@ -525,7 +527,7 @@ class SkillWatchManager { readiness.resolve(undefined) }) for (const event of ['add', 'addDir', 'change', 'unlink', 'unlinkDir'] as const) { - watcher.on(event, (path) => { this.handleWatchEvent(state, event, path) }) + watcher.on(event, (path) => { this.handleWatchEvent(state, mode, event, path) }) } try { await readiness.promise @@ -540,12 +542,14 @@ class SkillWatchManager { private handleWatchEvent( state: RootWatchState, + mode: Extract, event: SkillWatchEvent, path: string, ): void { - if (this.closing || !isRelevantWatchEvent(state.root, event, resolve(path))) return + const target = resolve(path) + if (this.closing || !isRelevantWatchEvent({ ...state.root, path: mode.anchor }, event, target)) return this.queueInvalidation() - if (resolve(path) === state.root.path && event === 'unlinkDir') { + if (target === mode.anchor && event === 'unlinkDir') { state.unhealthy = true this.scheduleRewatch(state) } @@ -619,17 +623,21 @@ function resolveWatchConfig(config: Config): ResolvedWatchConfig { } } -async function resolveRootWatchMode(root: string): Promise { +async function resolveRootWatchMode(root: string, followSymlinks: boolean): Promise { let candidate = root while (true) { try { const info = await stat(candidate) if (info.isDirectory()) { - if (candidate === root) return { kind: 'root', anchor: root } + const preserveRootLink = candidate === root + && !followSymlinks + && (await lstat(candidate)).isSymbolicLink() + const anchor = preserveRootLink ? resolve(candidate) : await canonicalizeWatchPath(candidate) + if (candidate === root) return { kind: 'root', anchor } const firstSegment = relative(candidate, root).split(sep)[0] /* v8 ignore next -- candidate is a strict ancestor of root. */ - if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor: root } - return { kind: 'ancestor', anchor: candidate, nextPath: join(candidate, firstSegment) } + if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor } + return { kind: 'ancestor', anchor, nextPath: join(anchor, firstSegment) } } } catch (error) { /* v8 ignore next -- Non-absence stat failures are platform/permission-specific and propagate as incomplete discovery. */ diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index bebf4378cf..6aa0ef5ca4 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import type { Stats } from 'node:fs' -import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +11,7 @@ interface FakeWatcherControl { emitter: EventEmitter closeCalls: number options: Record + path: string } interface FakeWatchFileControl { @@ -63,9 +64,9 @@ vi.mock('node:fs/promises', async (importOriginal) => { vi.mock('chokidar', () => ({ default: { - watch(_path: unknown, options: Record) { + watch(path: unknown, options: Record) { const emitter = new EventEmitter() as EventEmitter & { close(): Promise } - const control: FakeWatcherControl = { emitter, closeCalls: 0, options } + const control: FakeWatcherControl = { emitter, closeCalls: 0, options, path: String(path) } emitter.close = async () => { control.closeCalls += 1 if (watcherHarness.closeErrors > 0) { @@ -114,6 +115,53 @@ beforeEach(() => { }) describe('skill-local watcher failures', () => { + it('canonicalizes an existing root before opening its native watcher', async () => { + const target = await tempDir('skill-watch-canonical-target') + const aliasParent = await tempDir('skill-watch-canonical-alias') + const alias = join(aliasParent, 'alias') + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const root = join(alias, '.dsh/skills') + await writeSkill(root, 'canonical-skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(alias, '.dsh'), + agentsHome: join(alias, '.agents'), + watch: true, + }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['canonical-skill']) + expect(watcherHarness.watchers[0]?.path).toBe(await realpath(root)) + expect(watcherHarness.watchers[0]?.options.persistent).toBe(true) + await fiber.dispose() + }) + + it('preserves a symlink root when link following is disabled', async () => { + const target = await tempDir('skill-watch-link-target') + const aliasParent = await tempDir('skill-watch-link-alias') + const alias = join(aliasParent, 'skills') + await writeSkill(target, 'linked-skill') + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + includeDefaultRoots: false, + customSkillDirs: [alias], + watch: true, + watchFollowSymlinks: false, + }) + + try { + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-skill']) + expect(watcherHarness.watchers[0]?.path).toBe(alias) + expect(watcherHarness.watchers[0]?.options.followSymlinks).toBe(false) + } finally { + await fiber.dispose() + await rm(aliasParent, { recursive: true, force: true }) + await rm(target, { recursive: true, force: true }) + } + }) + it('ignores missing-path probes until the observed path actually changes', async () => { const home = await tempDir('skill-watch-missing-stable') const ctx = new Context() @@ -205,24 +253,22 @@ describe('skill-local watcher failures', () => { const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected a root watcher') - first.emitter.emit('change', join(root, 'notes.txt')) + first.emitter.emit('change', join(first.path, 'notes.txt')) first.emitter.emit('change', join(home, 'outside.md')) - first.emitter.emit('change', join(root, 'watched-skill/references.md')) - first.emitter.emit('change', join(root, '.system/SKILL.md')) + first.emitter.emit('change', join(first.path, 'watched-skill/references.md')) + first.emitter.emit('change', join(first.path, '.system/SKILL.md')) await settle() expect(invalidations).toBe(0) - first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) - first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md')) + first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md')) await settle() expect(invalidations).toBe(1) watcherHarness.closeErrors = 1 watcherHarness.startupErrors.push(new Error('runtime rewatch failed')) first.emitter.emit('error', new Error('runtime watch failed')) - await settle() - await settle() - expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2) + await vi.waitFor(() => { expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2) }) expect(invalidations).toBeGreaterThanOrEqual(2) expect(await ctx.skills.snapshot()).toMatchObject({ skills: [{ name: 'watched-skill' }], @@ -230,7 +276,7 @@ describe('skill-local watcher failures', () => { }) await fiber.dispose() - first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md')) first.emitter.emit('error', new Error('late error')) await settle() }) @@ -254,9 +300,11 @@ describe('skill-local watcher failures', () => { if (original === undefined) throw new Error('expected a root watcher') await rm(root, { recursive: true }) - original.emitter.emit('unlinkDir', root) + original.emitter.emit('unlinkDir', original.path) await vi.waitFor(() => { expect(original.closeCalls).toBeGreaterThan(0) }) - expect(watcherHarness.watchFiles.some(control => control.path === root)).toBe(true) + await vi.waitFor(() => { + expect(watcherHarness.watchFiles.some(control => control.path === original.path)).toBe(true) + }) await fiber.dispose() }) @@ -280,11 +328,11 @@ describe('skill-local watcher failures', () => { if (original === undefined) throw new Error('expected a root watcher') await rm(root, { recursive: true }) - original.emitter.emit('unlink', join(root, 'old-skill/SKILL.md')) + original.emitter.emit('unlink', join(original.path, 'old-skill/SKILL.md')) await settle() expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) - const missingRoot = watcherHarness.watchFiles.find(control => control.path === root) + const missingRoot = watcherHarness.watchFiles.find(control => control.path === original.path) expect(missingRoot).toBeDefined() await writeSkill(root, 'recreated-skill') missingRoot!.listener({} as Stats, {} as Stats) diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 103e34166c..a5e03b610a 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import SkillService, { isModelInvocable, isUserInvocable, @@ -1183,10 +1183,11 @@ describe('SkillService scoped layers', () => { }) } const agentKey = {} - setScopeParent(agentKey, scopeOf(presetA.ctx) as object) + const binding = bindScopeParent(agentKey, scopeOf(presetA.ctx) as object) expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-a']) - // A blank-session recompose re-parents the same key without any registry write. - setScopeParent(agentKey, scopeOf(presetB.ctx) as object) + // A blank-session recompose re-links the same key through its binding + // without any registry write. + binding.rebind(scopeOf(presetB.ctx) as object) expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-b']) await presetA.dispose() await presetB.dispose() diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index 2f2fff90fc..b8d37eabb9 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' @@ -88,19 +88,23 @@ describe('json backend specifics', () => { const unit = await backend.kv.open(descriptor) await unit.putRecord('t', 'k', { v: 'committed' }) await unit.setGlobal({ g: 'committed' }) - // Make every publish fail: revoke write permission on the root. - await chmod(root, 0o500) + const path = join(root, 'shape.json') + const backup = join(root, 'shape.committed.json') + // A directory at the publish target rejects atomic replacement on every host. + await rename(path, backup) + await mkdir(path) await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow() await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow() await expect(unit.deleteRecord('t', 'k')).rejects.toThrow() await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow() - await chmod(root, 0o700) + await rm(path, { recursive: true }) + await rename(backup, path) const snapshot = await unit.loadAll() expect(snapshot.tables['t']).toEqual({ k: { v: 'committed' } }) expect(snapshot.global).toEqual({ g: 'committed' }) // The next successful publish must not carry rejected writes to disk. await unit.putRecord('t', 'k3', { v: 'later' }) - const text = await readFile(join(root, 'shape.json'), 'utf8') + const text = await readFile(path, 'utf8') expect(text).not.toContain('rejected') await backend.close() }) diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index 8e64fb40f2..37cf7ba122 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -217,6 +217,13 @@ describe('sqlite backend specifics', () => { await chmod(dir, 0o700) }) + it('propagates an invalid database filename before opening SQLite', async () => { + const path = await freshDbPath() + const backend = backendAt(`${path}\0invalid`) + await expect(backend.kv.open(DESCRIPTOR)).rejects.toThrow(/null bytes/i) + await backend.close() + }) + it('preserves the mode of an existing database file', async () => { if (process.platform === 'win32') return const path = await freshDbPath() diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 6c6c238e74..122d781b6c 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' @@ -108,14 +109,19 @@ describe('child env layering (through the subprocess seam)', () => { // The spec.env layer merges after the seam's scrub, so the child's own // explicitly-forwarded key survives while ambient credentials do not. const running = spawnSubprocess({ - argv: ['bash', '-c', 'echo "[${ACP_TEST_AMBIENT_SECRET_TOKEN:-absent}|$DEEPSEEK_API_KEY]"'], + argv: [ + process.execPath, + '--input-type=module', + '--eval', + 'process.stdout.write(JSON.stringify([process.env.ACP_TEST_AMBIENT_SECRET_TOKEN ?? "absent", process.env.DEEPSEEK_API_KEY]))', + ], cwd: process.cwd(), stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, graceMs: 1000, env: { DEEPSEEK_API_KEY: 'explicit' }, }) await running.done - expect(running.collected.stdout!.readFrom(0).text.trim()).toBe('[absent|explicit]') + expect(running.collected.stdout!.readFrom(0).text).toBe('["absent","explicit"]') } finally { delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN } @@ -139,42 +145,50 @@ describe('child env layering (through the subprocess seam)', () => { }) describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => { - const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({ - argv: ['bash', '-c', command], + const node = (source: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({ + argv: [process.execPath, '--input-type=module', '--eval', source], cwd: process.cwd(), stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, graceMs: 200, }) + const expectHostTermination = (outcome: SubprocessOutcome, posixSignal: NodeJS.Signals): void => { + if (process.platform === 'win32') { + expect(outcome.signal).toBeNull() + expect(outcome.exitCode).not.toBe(0) + } else { + expect(outcome.signal).toBe(posixSignal) + } + } it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { - const child = bash('read -r line; exit 0') + const child = node('process.stdin.resume(); process.stdin.on("end", () => process.exit(0))') await disposeAcpChild(child, 5_000) const outcome = await child.done expect(outcome.exitCode).toBe(0) expect(outcome.signal).toBeNull() }) - it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => { - const child = bash('sleep 60') + it('tier 2: an EOF-deaf child reaches the host terminate outcome', async () => { + const child = node('setInterval(() => {}, 60_000)') await disposeAcpChild(child, 100) const outcome = await child.done - expect(outcome.signal).toBe('SIGTERM') + expectHostTermination(outcome, 'SIGTERM') }) - it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => { - const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore') + it('tier 3: a TERM-trapping child reaches the host force-termination outcome', async () => { + const child = node('process.on("SIGTERM", () => {}); process.stdout.write("armed\\n"); setInterval(() => {}, 60_000)', 'ignore') // Wait for the trap to arm so SIGTERM cannot race the default handler. while (!child.collected.stdout!.readFrom(0).text.includes('armed')) { await new Promise(resolve => setTimeout(resolve, 10)) } await disposeAcpChild(child, 50) const outcome = await child.done - expect(outcome.signal).toBe('SIGKILL') + expectHostTermination(outcome, 'SIGKILL') }) it('observes a spawn-level rejection and returns without a process to reap', async () => { const child = spawnSubprocess({ - argv: ['bash', '-c', 'true'], + argv: [process.execPath, '--input-type=module', '--eval', ''], cwd: '/nonexistent-dir-dsh-acp-ladder-test', stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, graceMs: 200, diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 3c73db7a32..213166861b 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -4,10 +4,10 @@ import { mkdirSync, mkdtempSync, readFileSync, - rmSync, symlinkSync, writeFileSync, } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -92,7 +92,7 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } observedSdkMessages.length = 0 }) diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 1880fc4359..1cfb9645c2 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.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 packages/subagent/subagent-codex/README.md -README.md: f10ebe0448b2942e2cad8efecb6be4681cf601a6 -README.zh.md: ef107577afcdc81a64ea46b2e996d46a562d1505 +README.md: 3d59ca1eaf3db9dd9d9d2cd451692ebd2a956ef4 +README.zh.md: b60cb1bba9b2d7b3f61c544c1600862a0ad6ce5b diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index f10ebe0448..3d59ca1eaf 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -10,7 +10,7 @@ This package registers the fixed `codex` subagent provider. Each accepted run st The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. -For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. +For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. @@ -48,7 +48,7 @@ Shipped profiles load this provider once on the host and start no Codex process ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. ## Model Experience @@ -84,7 +84,7 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. -- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. +- **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. - **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index ef107577af..b60cb1bba9 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -10,7 +10,7 @@ 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 -对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 +对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。`dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 @@ -48,7 +48,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。 ## 模型体验 @@ -84,7 +84,7 @@ Codex 子任务会在一个全新的临时线程中,以单个轮次接收这 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 -- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 +- **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 - **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 873cf1d77c..30788aa2e5 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@openai/codex": "0.146.0", + "@openai/codex": "0.147.0", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 51be212841..bc00ff0acf 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -1,5 +1,5 @@ /** - * Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC + * Minimal Codex app-server 0.147.0 protocol adapter. The shared JSON-RPC * transport owns framing and request correlation; this module owns only the * product methods, current thread/turn association, unattended approval * responses, and terminal-answer selection. diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index 29c5536bc0..5c39ebfb3d 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -109,8 +109,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { env: { ...process.env, ...env }, }) - expect(codexPackage.version).toBe('0.146.0') - expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + expect(codexPackage.version).toBe('0.147.0') + expect(version.stdout.trim()).toBe('codex-cli 0.147.0') const parent = { id: 'deepseek-e2e-parent', diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 5f73adaf7e..c39093b041 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -27,6 +27,7 @@ import { const execFileAsync = promisify(execFile) const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexEntry = join(packageRoot, 'node_modules', '@openai', 'codex', 'bin', 'codex.js') const codexPackage = JSON.parse(readFileSync( join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), 'utf8', @@ -40,7 +41,7 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) } }) @@ -139,18 +140,18 @@ function responseInputTexts(body: Record): string[] { }) } -describe('real @openai/codex 0.146.0 product', () => { +describe('real @openai/codex 0.147.0 product', () => { it('passes the exact task and fake authentication to local Responses and returns exact text', async () => { - const sentinel = 'REAL_CODEX_SENTINEL_0_146_0' + const sentinel = 'REAL_CODEX_SENTINEL_0_147_0' const task = 'Return the fixture sentinel exactly.' const { harness, fixture } = await realHarness([ { kind: 'complete', text: sentinel }, ]) - expect(codexPackage.version).toBe('0.146.0') - const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + expect(codexPackage.version).toBe('0.147.0') + const version = await execFileAsync(process.execPath, [codexEntry, '--version'], { env: { ...process.env, ...harness.env }, }) - expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + expect(version.stdout.trim()).toBe('codex-cli 0.147.0') const run = await harness.ctx.subagents.start('codex', { prompt: [{ type: 'text', text: task }], @@ -173,16 +174,32 @@ describe('real @openai/codex 0.146.0 product', () => { }, 60_000) it('cancels a real app-server command approval without executing the command', async () => { - const { harness, fixture } = await realHarness([ + const command = process.platform === 'win32' + ? 'cmd /c type nul > approval-side-effect' + : 'touch approval-side-effect' + const commandCalls = [ { - kind: 'functionCall', name: 'exec_command', arguments: { - cmd: 'touch approval-side-effect', + cmd: command, sandbox_permissions: 'require_escalated', justification: 'exercise the unattended approval boundary', }, }, + { + name: 'shell_command', + arguments: { + command, + sandbox_permissions: 'require_escalated', + justification: 'exercise the unattended approval boundary', + }, + }, + ] as const + const { harness, fixture } = await realHarness([ + { + kind: 'advertisedFunctionCall', + choices: commandCalls, + }, ]) const sideEffect = join(harness.workspace, 'approval-side-effect') const run = await harness.ctx.subagents.start('codex', { @@ -199,9 +216,9 @@ describe('real @openai/codex 0.146.0 product', () => { expect(existsSync(sideEffect)).toBe(false) expect(fixture.requests).toHaveLength(1) const tools = fixture.requests[0]!.body.tools as Array> - expect(tools).toEqual(expect.arrayContaining([ - expect.objectContaining({ type: 'function', name: 'exec_command' }), - ])) + expect(commandCalls.some(call => tools.some(tool => ( + tool.type === 'function' && tool.name === call.name + )))).toBe(true) expect(fixture.requests.every(requestEntry => requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', )).toBe(true) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index cac49b9158..2b6e5868ae 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -22,6 +22,13 @@ export type ResponsesBehavior = readonly name: string readonly arguments: Record } + | { + readonly kind: 'advertisedFunctionCall' + readonly choices: readonly { + readonly name: string + readonly arguments: Record + }[] + } | { readonly kind: 'hold' } /** Running package-private Responses fixture. */ @@ -86,7 +93,7 @@ function responseObject(text: string): Record { } /** - * Build the minimal Responses SSE event sequence consumed by Codex 0.146.0. + * Build the minimal Responses SSE event sequence consumed by Codex 0.147.0. * @param text - exact assistant answer. * @returns ordered response lifecycle events. */ @@ -218,6 +225,18 @@ function closeServer(server: Server): Promise { }) } +function advertisedFunctionNames(body: Record): Set { + if (!Array.isArray(body.tools)) return new Set() + return new Set(body.tools.flatMap((tool): string[] => ( + tool !== null + && typeof tool === 'object' + && (tool as Record).type === 'function' + && typeof (tool as Record).name === 'string' + ? [(tool as Record).name as string] + : [] + ))) +} + /** * Start a loopback-only Responses SSE fixture. * @param script - one behavior per expected Responses request. @@ -234,11 +253,12 @@ export async function startResponsesFixture( openResponses.add(response) response.on('close', () => { openResponses.delete(response) }) void readRequest(request).then((body) => { + const parsedBody = JSON.parse(body) as Record requests.push({ method: request.method, path: request.url, headers: request.headers, - body: JSON.parse(body) as Record, + body: parsedBody, }) started.resolve(undefined) const behavior = behaviors.shift() @@ -247,6 +267,14 @@ export async function startResponsesFixture( response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } })) return } + const advertisedCall = behavior.kind === 'advertisedFunctionCall' + ? behavior.choices.find(choice => advertisedFunctionNames(parsedBody).has(choice.name)) + : undefined + if (behavior.kind === 'advertisedFunctionCall' && advertisedCall === undefined) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } })) + return + } response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', @@ -254,9 +282,15 @@ export async function startResponsesFixture( 'x-request-id': 'req_fixture', }) if (behavior.kind === 'hold') return - const events = behavior.kind === 'complete' - ? completeResponsesEvents(behavior.text) - : functionCallEvents(behavior.name, behavior.arguments) + let events: Record[] + if (behavior.kind === 'complete') { + events = completeResponsesEvents(behavior.text) + } else { + const call = behavior.kind === 'functionCall' + ? behavior + : advertisedCall! + events = functionCallEvents(call.name, call.arguments) + } for (const event of events) { response.write(`data: ${JSON.stringify(event)}\n\n`) } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index de89aa4854..9318c59787 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -199,7 +199,7 @@ async function initializeWire(): Promise<{ wire.start() const initializing = wire.initialize(new AbortController().signal) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await initializing expect(await child.peer.nextMethod('initialized')).toEqual({ jsonrpc: '2.0', @@ -219,7 +219,7 @@ async function publishRun( ) { const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides)) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) @@ -380,7 +380,7 @@ describe('CodexAppServerWire', () => { requestAttestation: false, }, }) - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await initializing await child.peer.nextMethod('initialized') @@ -855,7 +855,7 @@ describe('run lifecycle and quiescence', () => { void starting.then(() => { published = true }) const initialize = await child.peer.nextMethod('initialize') expect(published).toBe(false) - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') expect(published).toBe(false) @@ -962,7 +962,7 @@ describe('run lifecycle and quiescence', () => { runSpec(child), ) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) @@ -1032,7 +1032,7 @@ describe('run lifecycle and quiescence', () => { signal: new AbortController().signal, }) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index af37aba5f4..fa2db5fd04 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -1055,7 +1055,7 @@ describe('SubagentService.listDescendants', () => { }) - it('walks a deeply nested ordinary-session chain without consuming the call stack', async () => { + it('walks a deeply nested ordinary-session chain without consuming the call stack', { timeout: 20_000 }, async () => { const { ctx, parent } = await setup([]) const depth = 10_000 let parentId = parent.id @@ -1078,7 +1078,7 @@ describe('SubagentService.listDescendants', () => { }]) }) - it('discovers continuable descendants below ordinary and one-shot intermediates', async () => { + it('discovers continuable descendants below ordinary and one-shot intermediates', { timeout: 20_000 }, async () => { const { ctx, parent } = await setup([textResponse('one shot')]) // An ordinary fork has no descriptor: omitted itself, subtree still walked. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 17d5025c61..23757c2b54 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -219,7 +219,9 @@ describe('dsh-tool-subagent-report', () => { expect((await callReport(ctx, child, 'DURABLE_SELECTION')).isError).toBe(false) adapter.release() - await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) + await vi.waitFor(() => { + expect(ctx.agents.get(started.childId) === undefined).toBe(true) + }, { timeout: 5_000 }) expect(reports(parent).map(report => report.text)).toEqual([ `Background subagent ${started.childId} reported:\nDURABLE_SELECTION`, ]) @@ -421,7 +423,9 @@ describe('dsh-tool-subagent-report result independence', () => { const { ctx, parent, adapter } = await setup() const { started } = await startChild(ctx, parent) adapter.release() - await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) + await vi.waitFor(() => { + expect(ctx.agents.get(started.childId) === undefined).toBe(true) + }, { timeout: 5_000 }) expect(reports(parent)).toEqual([]) expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task']) diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 68736f0b3a..9f4d3e68e5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -2507,9 +2507,10 @@ function mergeWorkspaceModels(models: readonly WorkspaceModel[]): WorkspaceModel } function parseConfig(path: string): ParsedConfig { - const read = ts.readConfigFile(path, file => ts.sys.readFile(file)) + const compilerPath = path.split(sep).join('/') + const read = ts.readConfigFile(compilerPath, file => ts.sys.readFile(file)) if (read.error !== undefined) throw new TypertAnalysisError(formatDiagnostic(read.error)) - const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(path), undefined, path) + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(compilerPath), undefined, compilerPath) if (parsed.errors.length > 0) throw new TypertAnalysisError(parsed.errors.map(formatDiagnostic).join('\n')) return { path, parsed } } diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 0e62a56bf4..a41f0c0cd0 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -10,6 +10,10 @@ import { WorkspaceTypertGenerator } from '../src/workspace.ts' const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model') const temporaryRoots: string[] = [] +function normalizedPath(path: string): string { + return path.replaceAll('\\', '/') +} + interface RuntimeSchema { safeParse(value: unknown): { readonly success: boolean } } @@ -646,7 +650,8 @@ void navigated const navigation = 'ctx.remote.goals.create' const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 const definitions = languageService.getDefinitionAtPosition(consumerPath, position) - const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) + const generatedDefinition = definitions?.find(candidate => + normalizedPath(candidate.fileName) === normalizedPath(declarationPath)) if (generatedDefinition === undefined) { throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`) } @@ -661,7 +666,7 @@ void navigated pos: generatedDefinition.textSpan.start, }) languageService.dispose() - if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) { + if (definition === undefined || !normalizedPath(definition.fileName).endsWith('/packages/remote/src/index.ts')) { throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`) } const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8') diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index f8c41fb157..13771d546e 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -18,6 +18,11 @@ import { WorkspaceTypertGenerator } from '../src/workspace.ts' const fixtureRoot = resolve(import.meta.dirname, 'fixtures/type-model') const temporaryRoots: string[] = [] + +function normalizedPath(path: string): string { + return path.replaceAll('\\', '/') +} + const parseConfigHost: ts.ParseConfigFileHost = { ...ts.sys, onUnRecoverableConfigFileDiagnostic(diagnostic) { @@ -733,8 +738,8 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { rootNames: packageConfig.fileNames, options: aggregateConfig.options, }) - expect(diagnosticProgram.getSourceFiles().map(source => source.fileName)) - .toContain(join(externalRoot, 'index.d.ts')) + expect(diagnosticProgram.getSourceFiles().map(source => normalizedPath(source.fileName))) + .toContain(normalizedPath(join(externalRoot, 'index.d.ts'))) const targets = new WorkspaceAnalyzer({ root }).analyze().faces .flatMap(face => face.graph.nodes) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 750cc92e57..af5d451351 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' @@ -114,9 +114,9 @@ async function boot(): Promise { async function linkZod(base: string): Promise { const { symlink } = await import('node:fs/promises') const target = join(base, 'node_modules', 'zod') - const source = new URL(import.meta.resolve('zod/package.json')).pathname.replace(/\/package\.json$/, '') + const source = fileURLToPath(new URL('.', import.meta.resolve('zod/package.json'))) await mkdir(join(base, 'node_modules'), { recursive: true }) - await symlink(source, target, 'dir') + await symlink(source, target, process.platform === 'win32' ? 'junction' : 'dir') } function mountTypertLoader(ctx: Context, config: typertLoader.Config = {}): ReturnType { diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index 2bc9d3ab6a..e71e5b7abd 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -2,7 +2,7 @@ import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } fr import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { writeFileAtomic } from '../src/index.ts' +import { withFileLock, writeFileAtomic } from '../src/index.ts' async function scratch(): Promise { return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) @@ -14,7 +14,7 @@ describe('writeFileAtomic', () => { const target = join(dir, 'nested', 'deep', 'doc.yaml') await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) expect(await readFile(target, 'utf8')).toBe('a: 1\n') - expect((await stat(target)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600) }) it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { @@ -23,7 +23,7 @@ describe('writeFileAtomic', () => { await writeFile(target, 'old', { mode: 0o644 }) await writeFileAtomic(target, 'new', { mode: 0o600 }) expect(await readFile(target, 'utf8')).toBe('new') - expect((await stat(target)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600) }) it('replaces a symlinked target itself without writing through to the referent', async () => { @@ -46,3 +46,17 @@ describe('writeFileAtomic', () => { expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) }) }) + +describe('withFileLock', () => { + it('rejects an invalid parent hierarchy before running the operation', async () => { + const dir = await scratch() + const parent = join(dir, 'not-a-directory') + await writeFile(parent, 'occupied') + let called = false + + await expect(withFileLock(join(parent, 'document'), async () => { + called = true + })).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i) + expect(called).toBe(false) + }) +}) diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml index f79f6b2d73..6bed54f5c3 100644 --- a/packages/util/paths/README.i18n.yaml +++ b/packages/util/paths/README.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 packages/util/paths/README.md -README.md: 2b3272e019ef2f37386da9156b06a5c151836d8c -README.zh.md: 7fe0ec04117ae439ade653cefd1c8f5da094d8fd +README.md: 8d10ed855a37f1205f87420b3f36f45b10e65bd3 +README.zh.md: ed3ca377bd48252fe0ef3f95186dc6eb1fb6e6a0 diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index 2b3272e019..8d10ed855a 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -18,9 +18,13 @@ Shared filesystem path helpers for DeepSeek Harness user data. `expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched. +## Watch paths + +`canonicalizeWatchPath()` gives a native filesystem watcher one stable spelling of its target. It resolves the deepest existing ancestor through `fs.realpath()` and restores any missing suffix, so a file or directory may still be watched before it is created. In particular, Windows 8.3 aliases cannot be mixed with the long paths emitted by the native watcher backend. + This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another. ## Known Limitations and Deferred Work - **Expansion is deliberately narrow** — only bare `~`, `~/...`, and `~\...` use the current operating-system home; named-user forms such as `~alice/...`, environment variables, and shell expressions remain unchanged. -- **Helpers do not touch the filesystem** — callers still own directory creation, existence checks, permissions, and trust policy for the resulting path. +- **Canonicalization reads but never mutates** — `canonicalizeWatchPath()` performs `realpath` probes and propagates errors other than absence; callers still own directory creation, permissions, and trust policy for the resulting path. diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md index 7fe0ec0411..ed3ca377bd 100644 --- a/packages/util/paths/README.zh.md +++ b/packages/util/paths/README.zh.md @@ -18,9 +18,13 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 `expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。 +## 监听路径 + +`canonicalizeWatchPath()` 为原生文件系统 watcher 提供一种稳定的目标路径表示。它通过 `fs.realpath()` 解析层级最深的现有祖先路径,再拼回缺失的后缀,因此即使文件或目录尚未创建也仍可监听。尤其是,Windows 8.3 别名不能与原生 watcher 后端发出的长路径混用。 + 该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 ## 已知限制与暂缓事项 - **展开范围刻意保持狭窄**:只有单独的 `~`、`~/...` 和 `~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。 -- **辅助工具不会操作文件系统**:调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。 +- **规范化会读取,但绝不修改**:`canonicalizeWatchPath()` 会执行 `realpath` 探测,并传播除路径不存在以外的错误;调用方仍负责目录创建、权限,以及对结果路径应用信任策略。 diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index a8f9245c72..122408509a 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -4,8 +4,9 @@ * @module @deepseek-ai/dsh-paths */ +import { opendir, realpath } from 'node:fs/promises' import { homedir } from 'node:os' -import { join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' /** Directory name for the default DeepSeek Harness home under the OS home. */ export const DSH_HOME_DIR_NAME = '.dsh' @@ -16,6 +17,43 @@ export const DEFAULT_DSH_HOME_DISPLAY = `~/${DSH_HOME_DIR_NAME}` /** Environment variable that overrides the default DeepSeek Harness home. */ export const DSH_HOME_ENV = 'DSH_HOME' +/** + * Give a native filesystem watcher one canonical spelling of a path, even + * when its final components do not exist yet. The deepest existing ancestor + * is resolved through {@link realpath}; when a suffix is missing, that + * ancestor is also proved to be an enumerable directory before the suffix is + * restored. This prevents Windows from treating a regular-file ancestor as + * ordinary absence, and prevents short-name aliases from being mixed with + * long paths emitted by the native watcher backend. + * @param path - Watch target or root, resolved against the current directory. + * @returns the target with its existing ancestor canonicalized. + * @throws when ancestor traversal encounters an error other than absence, or + * the existing ancestor of a missing suffix is not an enumerable directory. + */ +export async function canonicalizeWatchPath(path: string): Promise { + let current = resolve(path) + const missing: string[] = [] + while (true) { + try { + const canonical = await realpath(current) + if (missing.length > 0) { + // A Windows file-as-parent probe reports ENOENT. Opening the resolved + // ancestor preserves the cross-platform directory requirement. + const directory = await opendir(canonical) + await directory.close() + } + return join(canonical, ...missing.reverse()) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(current) + /* v8 ignore next -- a filesystem root exists, so traversal resolves before this guard */ + if (parent === current) throw error + missing.push(basename(current)) + current = parent + } + } +} + /** * Resolve the default DeepSeek Harness home using Node's platform path rules. * @returns the absolute default harness home path. diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 08336bbcce..df5555f910 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -1,9 +1,11 @@ -import { homedir } from 'node:os' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_DSH_HOME_DISPLAY, DSH_HOME_DIR_NAME, + canonicalizeWatchPath, defaultDshHome, dshHomeDisplay, dshHomePath, @@ -53,4 +55,22 @@ describe('dsh path helpers', () => { expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh') expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') }) + + it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-')) + const target = join(root, 'target') + const alias = join(root, 'alias') + try { + await mkdir(target) + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + await expect(canonicalizeWatchPath(join(alias, 'later', 'config.yml'))).resolves.toBe( + join(await realpath(target), 'later', 'config.yml'), + ) + const file = join(root, 'file') + await writeFile(file, 'not a directory') + await expect(canonicalizeWatchPath(join(file, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 79aad3af42..de190349a4 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1239,22 +1239,18 @@ describe('dsh-workflow-workerthread', () => { await ctx.plugin(WorkerWorkflowEngine, { provider: 'doomed', maxConcurrentAgents: 2 }) const runEnds: WorkflowResultInfo[] = [] ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) + const childStarted = Promise.withResolvers() + ctx.on('workflow/agent-start', () => { childStarted.resolve(undefined) }) const handle = ctx.workflows.start({ - // The stray child's start RPC reaches the host, then the script kills - // its own worker through the documented vm escape — the host must - // settle `error` with the exit diagnostics and wind the child down. - ...scripted(` - agent('doomed') - const proc = ${ESCAPE} - const st = globalThis.constructor.constructor('return setTimeout')() - await new Promise(resolve => st(resolve, 200)) - proc.exit(7) - `), + ...scripted("return await agent('doomed')"), parent: fakeParent(), }) + const worker = (handle as unknown as { worker: Worker }).worker + await childStarted.promise + await worker.terminate() const result = await handle.result expect(result.stopReason).toBe('error') - expect(result.error).toContain('exit code 7') + expect(result.error).toContain('exit code 1') expect(result.agentsStarted).toBe(1) // A worker death is a stop reason like any other: workflow/end fires // with the error outcome — for a bus observer it is the only obituary. @@ -1306,26 +1302,22 @@ describe('dsh-workflow-workerthread', () => { }) ctx.on('workflow/end', () => { order.push('run-end') }) const handle = ctx.workflows.start({ - // Same choreography as the force-settle pairing test, but the worker - // DIES (the documented vm escape) instead of being terminated: the - // exit path must close slow's pair from the ledger too. The escaped - // setTimeout lets the already-posted messages flush before the kill. ...scripted(` const p = agent('slow') await agent('fast') - const proc = ${ESCAPE} - const st = globalThis.constructor.constructor('return setTimeout')() - await new Promise(resolve => st(resolve, 150)) - proc.exit(7) + await new Promise(() => {}) `), parent, }) + const worker = (handle as unknown as { worker: Worker }).worker await waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) }) const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')! fast.settle(text('fast done')) + await waitFor(() => { expect(ends).toContainEqual({ seq: 2, outcome: 'completed' }) }) + await worker.terminate() const result = await handle.result expect(result.stopReason).toBe('error') - expect(result.error).toContain('exit code 7') + expect(result.error).toContain('exit code 1') expect(ends).toEqual([ { seq: 2, outcome: 'completed' }, { seq: 1, outcome: 'cancelled' }, @@ -1340,21 +1332,22 @@ describe('dsh-workflow-workerthread', () => { // guard in post()). const { ctx, parent, provider } = await setup({ disposeDelayMs: 300 }) const handle = ctx.workflows.start({ - // The STRAY child settles instantly, so its wrapper starts the slow - // host-side disposal concurrently while the script goes on to kill - // its own worker — the ack then resolves into a dead thread. ...scripted(` agent('stray, never awaited') - const proc = ${ESCAPE} - const st = globalThis.constructor.constructor('return setTimeout')() - await new Promise(resolve => st(resolve, 150)) - proc.exit(5) + await new Promise(() => {}) `), parent, }) + const worker = (handle as unknown as { worker: Worker }).worker + await waitFor(() => { + expect(provider.runs).toHaveLength(1) + expect(provider.runs[0]!.disposeCalls).toBe(1) + expect(provider.runs[0]!.disposed).toBe(false) + }) + await worker.terminate() const result = await handle.result expect(result.stopReason).toBe('error') - expect(result.error).toContain('exit code 5') + expect(result.error).toContain('exit code 1') // Result already settled — this is the reap's promptness (bounded // above the mock's fixed 300ms dispose delay, not a cold-start race); // tight explicit bound (see the helper's doc comment). @@ -1366,20 +1359,19 @@ describe('dsh-workflow-workerthread', () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } }) const handle = ctx.workflows.start({ ...scripted(` - const proc = ${ESCAPE} - const st = globalThis.constructor.constructor('return setTimeout')() log('armed') - await new Promise(resolve => st(resolve, 400)) - proc.exit(3) + await new Promise(() => {}) `), parent, }) + const worker = (handle as unknown as { worker: Worker }).worker const logs: string[] = [] ctx.on('workflow/log', (_info, message) => { logs.push(message) }) await waitFor(() => { expect(logs).toContain('armed') }) handle.cancel('stop it') - // The grace is deliberately huge: only the worker's own death (exit 3, - // unreachable by the cancel — the script ignores hooks) settles this. + // The grace is deliberately huge: only the host-triggered worker death, + // not the cancellation timer, settles this. + await worker.terminate() const result = await handle.result expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('stop it') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d98cc8c053..7799fddf56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6302,8 +6302,8 @@ importers: specifier: workspace:^ version: link:../../util/timeout '@openai/codex': - specifier: 0.146.0 - version: 0.146.0 + specifier: 0.147.0 + version: 0.147.0 cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -9025,43 +9025,43 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} - '@openai/codex@0.146.0': - resolution: {integrity: sha512-yG3sPWNda/2YAIQIDq9MrrjoCTIQ7rxYM5IasrG3VBcuhCLTkgeg/JzqmJq1V98RE4MJ5jCxDXXQlOjrditFRw==} + '@openai/codex@0.147.0': + resolution: {integrity: sha512-EQLEXecAG2ptxI7UpBMo2TR/ga5596/c/OsYF/0LoUDh5JANZ7IoGqlzBEWbuEVQ76JePIbtTW/ihCkp1a7Z3w==} engines: {node: '>=16'} hasBin: true - '@openai/codex@0.146.0-darwin-arm64': - resolution: {integrity: sha512-nb61yX4r5L6Z0dlC4o3u0GAK1YCd4TUvjaB382bajDoh84V+uv2hTBIVZ++fgXWV9yoeuNrNnNcn7GoTGOe2Tg==} + '@openai/codex@0.147.0-darwin-arm64': + resolution: {integrity: sha512-BEUVkiOW7kLcRyrMLfAr/h9wF8sRVJyZDy6OHtVn6QGDXiv3BvAZVTY1Pu9xF7KdIdkYXbp4uayN0aDQQaAUJw==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@openai/codex@0.146.0-darwin-x64': - resolution: {integrity: sha512-hTQR5jy/ObfTf1MDnuJCZJAe+SljKE8DDwQWN6lDFgjsPhMQz852U2tILt8Ei+G5GkQSzemHYKl2AYPwW0Y5xw==} + '@openai/codex@0.147.0-darwin-x64': + resolution: {integrity: sha512-Tb8McE5SvJIH0Vs5R6sq7u+quiC931yan2KOOl6km1OdZ82+Wi7eF5XrSFPs5CF7xCgoIK4Vs+byMbT5hN+ZUw==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@openai/codex@0.146.0-linux-arm64': - resolution: {integrity: sha512-qiYDxkkEFnXG7joadJW6Q+XcgyDXCpGdpa9nk/c+i0gEomur1j7bHvx12NfWWCF/y8Tqri6ay+FLuC2MjdehtA==} + '@openai/codex@0.147.0-linux-arm64': + resolution: {integrity: sha512-SLC1JXw2TYfr/c3HhrJubyyLelq7vTOLWVmiThFA+z0+WgzCPmaseJ/kzDD3Gge/TO7fCnnj7UcPmC0d2c8XAg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@openai/codex@0.146.0-linux-x64': - resolution: {integrity: sha512-fswvyGprAPCMiOEue/7MKMk7pCjh9kZIJfJX5i9atmfnmGYbYCcUhZsEH9LEP0+0t5xyPqDbfNXY7NSxIVuXxA==} + '@openai/codex@0.147.0-linux-x64': + resolution: {integrity: sha512-0W9MBxPpWW0cSkNqrTDN2jR7rzzT7oNMhQY5446lT2Lw5cz5yhDTck4Va9rjkQEm+HlFzP/dmEMSZbXfJsINmw==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@openai/codex@0.146.0-win32-arm64': - resolution: {integrity: sha512-EW6zdjDe+SLX2Iw+xymJ5+Pz2+DGexdstfFHXh4Ub+TfJsQPiMjGfZfNaoWgdJ2FsqSIzVKu2+G0KCMGYz2W8g==} + '@openai/codex@0.147.0-win32-arm64': + resolution: {integrity: sha512-e2ZstJ8zT8Rm1nvR7CUVO+Gr3cTChE41+VfOzGhynzDXEoW0wfbjUQbc2bWbh1arG94LMm4y3dqBtUIbSrfeGA==} engines: {node: '>=16'} cpu: [arm64] os: [win32] - '@openai/codex@0.146.0-win32-x64': - resolution: {integrity: sha512-b3lxMYeR0+IhstNo4JjX1P9cPc1xwVcCVkPd1lD1wpWPJ0SBhpIkPczwbu3ZRkJcdyl342+rgyf4DUrbZLdrGA==} + '@openai/codex@0.147.0-win32-x64': + resolution: {integrity: sha512-oT7Ss5fAPf2fiWE9QNURqZcQGAAawSVxmIUdgPzckq4KFZAM+pRz9JbM4Rr498CjtbNgTOjWvDJ+DXvIBSfOPA==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -14350,31 +14350,31 @@ snapshots: '@nodable/entities@2.2.0': {} - '@openai/codex@0.146.0': + '@openai/codex@0.147.0': optionalDependencies: - '@openai/codex-darwin-arm64': '@openai/codex@0.146.0-darwin-arm64' - '@openai/codex-darwin-x64': '@openai/codex@0.146.0-darwin-x64' - '@openai/codex-linux-arm64': '@openai/codex@0.146.0-linux-arm64' - '@openai/codex-linux-x64': '@openai/codex@0.146.0-linux-x64' - '@openai/codex-win32-arm64': '@openai/codex@0.146.0-win32-arm64' - '@openai/codex-win32-x64': '@openai/codex@0.146.0-win32-x64' + '@openai/codex-darwin-arm64': '@openai/codex@0.147.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.147.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.147.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.147.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.147.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.147.0-win32-x64' - '@openai/codex@0.146.0-darwin-arm64': + '@openai/codex@0.147.0-darwin-arm64': optional: true - '@openai/codex@0.146.0-darwin-x64': + '@openai/codex@0.147.0-darwin-x64': optional: true - '@openai/codex@0.146.0-linux-arm64': + '@openai/codex@0.147.0-linux-arm64': optional: true - '@openai/codex@0.146.0-linux-x64': + '@openai/codex@0.147.0-linux-x64': optional: true - '@openai/codex@0.146.0-win32-arm64': + '@openai/codex@0.147.0-win32-arm64': optional: true - '@openai/codex@0.146.0-win32-x64': + '@openai/codex@0.147.0-win32-x64': optional: true '@opentelemetry/api-logs@0.220.0': diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index a9ba98407b..423df6ea6b 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -97,14 +97,14 @@ function repositoryState(root: string): Record { } describe('change-scope', () => { - it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => { + it('uses an explicit base on a fresh branch without a same-name remote and after its first push', { timeout: 20_000 }, () => { const { root } = fixture() git(root, ['switch', '-c', 'feature']) git(root, ['branch', '--set-upstream-to=origin/master']) const headSha = commit(root, 'feature.txt', 'feature\n') const fresh = jsonReport(root, 'origin/master') - expect(fresh.repositoryRoot).toBe(realpathSync(root)) + expect(realpathSync.native(fresh.repositoryRoot)).toBe(realpathSync.native(root)) expect(fresh.resolved).toEqual({ baseSha: git(root, ['rev-parse', 'origin/master']), headSha, @@ -122,7 +122,7 @@ describe('change-scope', () => { const { root } = fixture('worktree ') const report = jsonReport(root, 'HEAD') - expect(report.repositoryRoot).toBe(realpathSync(root)) + expect(realpathSync.native(report.repositoryRoot)).toBe(realpathSync.native(root)) expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] }) }) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 0395fe8c36..2269c7cbee 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -26,6 +26,63 @@ describe('CI workflow', () => { }) } }) + + it('keeps Wine blocking while native Windows reports independently', () => { + const workflow = loadWorkflow('.github/workflows/ci.yml') + if (!isRecord(workflow.jobs) + || !isRecord(workflow.jobs.windows) + || !isRecord(workflow.jobs['windows-native']) + || !isRecord(workflow.jobs['all-checks-passed'])) { + throw new TypeError('CI workflow must define Wine, native Windows, and aggregate jobs') + } + + const windows = workflow.jobs.windows + const windowsNative = workflow.jobs['windows-native'] + const aggregate = workflow.jobs['all-checks-passed'] + if (!Array.isArray(windows.steps) || !Array.isArray(windowsNative.steps) || !Array.isArray(aggregate.needs)) { + throw new TypeError('Windows jobs must define steps and the aggregate must define needs') + } + const nativeCommandSteps = windowsNative.steps.filter((step): step is Record & { run: string } => ( + isRecord(step) && typeof step.run === 'string' + )) + + expect(windows['runs-on']).toBe('ubuntu-latest') + expect(windows.name).toBe('windows node 24 / wine blocking') + expect(windows.if).toBe("github.event_name == 'pull_request'") + expect(JSON.stringify(windows)).toContain('bash scripts/wine-windows-gates.sh') + expect(workflow.jobs).toHaveProperty('wine-apt-cache') + expect(windowsNative['runs-on']).toBe('dsh-windows-2025-16core') + expect(windowsNative.name).toBe('windows node 24 / native complete') + expect(windowsNative['timeout-minutes']).toBe(60) + expect(windowsNative.if).toBe("github.event_name == 'pull_request'") + expect(windowsNative.env).toMatchObject({ + DSH_COVERAGE_MAX_WORKERS: '2', + DSH_GATE_CONCURRENCY: '2', + DSH_PUBLINT_CONCURRENCY: '8', + }) + expect(windowsNative).not.toHaveProperty('continue-on-error') + expect(nativeCommandSteps).toHaveLength(3) + expect(nativeCommandSteps.every(step => step.shell === 'pwsh')).toBe(true) + expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') + expect(JSON.stringify(windowsNative)).not.toMatch(/wine/i) + expect(aggregate.needs).toContain('windows') + expect(aggregate.needs).not.toContain('windows-native') + }) + + it('keeps supported LSP source under native Windows coverage', () => { + const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8') + + expect(config).not.toContain('packages/lsp/lsp-local/src/connection.ts') + expect(config).not.toContain('packages/lsp/lsp-local/src/index.ts') + expect(config).not.toContain('packages/lsp/lsp-local/src/instance.ts') + }) + + it('keeps every Vitest project process-isolated on native Windows', () => { + const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8') + + expect(config).not.toContain("pool: process.platform === 'win32' ? 'threads' : 'forks'") + expect(config.match(/pool: 'forks'/g)).toHaveLength(2) + }) }) describe('E2B e2e workflow', () => { diff --git a/scripts/client-tsconfig.spec.ts b/scripts/client-tsconfig.spec.ts index ed445aa80f..8e82e52353 100644 --- a/scripts/client-tsconfig.spec.ts +++ b/scripts/client-tsconfig.spec.ts @@ -1,7 +1,7 @@ /** Regression coverage for source declarations owned by the client test aggregate. */ import { existsSync, readdirSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' import { describe, expect, it } from 'vitest' @@ -14,6 +14,7 @@ function clientCssDeclarations(): string[] { .filter(entry => entry.isDirectory()) .map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts')) .filter(existsSync) + .map(file => file.replaceAll(sep, '/')) .sort() } @@ -26,6 +27,7 @@ describe('client TypeScript aggregate', () => { } const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root) const loaded = parsed.fileNames + .map(file => file.replaceAll(sep, '/')) .filter(file => file.endsWith('/src/css-modules.d.ts')) .sort() expect(loaded).toEqual(clientCssDeclarations()) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 4d5b585213..2c429bba25 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -610,7 +610,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { expect(result.status).toBe(1) expect(result.stderr).toContain('sibling dormant worktree config') - expect(result.stderr).toContain(linkedConfig) + expect(result.stderr).toContain(JSON.stringify(linkedConfig)) expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1) expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks) diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index ccb226fb41..5126fe0986 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -249,7 +249,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }) + }, 20_000) it('accepts an ignored-only staged selection', () => { const result = runOxlint([ diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index bca06c89ac..a8f339cec7 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -148,7 +148,7 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - const name = absPath.split('/').pop() ?? '' + const name = basename(absPath) placed.push(name) return `./${name}` }, @@ -167,7 +167,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + placeImage: absPath => `./${basename(absPath)}`, })).toBe('![logo](./logo.svg#view)\n') }) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 5c4ba9899a..7c85d8ff48 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -83,6 +83,15 @@ describe('gate graph validation', () => { expect(ids).toContain('public-repository-links') }) + it('keeps native Windows coverage blocking while portability inventory remains observational', () => { + const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')) + const byId = new Map(gates.map(subject => [subject.id, subject])) + + expect(byId.get('coverage')?.allowFailure).not.toBe(true) + expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true) + expect(byId.get('duplication')?.allowFailure).toBe(true) + }) + it.each([ ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 3bd0e11987..cbcf8504d5 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -435,6 +435,7 @@ function ciWindowsCompleteGates(): Gate[] { return [ pnpmScript('build', 'build'), pnpmScript('windows-site', 'docs:build', { label: 'production site' }), + ...coverageGates(), ...observational, ] } @@ -442,7 +443,7 @@ function ciWindowsCompleteGates(): Gate[] { function ciWindowsObservationalGates(): Gate[] { return [ ...ciStaticGates({ ownsBuild: true }), - // Linux owns required lint, coverage, and snapshots; Windows omits those duplicates. + // Linux owns required lint and snapshots; Windows omits those duplicates. pnpmScript('duplication', 'duplication'), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 073e22ff89..0ee78a11b7 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/scripts/translation-pairing-merge.spec.ts @@ -19,7 +19,7 @@ import { const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url)) const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url)) const workspaceRoot = fileURLToPath(new URL('../', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx/esm')) +const tsxLoader = import.meta.resolve('tsx/esm') const fixtures: string[] = [] interface Fixture { @@ -231,7 +231,7 @@ function expectMergedPair(fixture: Fixture): void { ) } -describe('translation pairing merge composition', () => { +describe('translation pairing merge composition', { timeout: 15_000 }, () => { it('rejects a pairing-record path outside the repository', () => { const fixture = createFixture(false) diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index b6d8ffffb0..5e46b75f91 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -2,8 +2,8 @@ # Run the blocking Windows gates (workspace build, production site) with real # win-x64 Node.js under Wine — the same script the pull-request `windows` job # in ci.yml executes and the optional local gate `pnpm run check:windows-wine` -# wraps. Owning rationale, fidelity limits, and measured timings: -# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +# wraps. Owning rationale and fidelity limits: +# .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md # # The working tree is never mutated: tracked plus untracked-unignored files # are snapshotted into a scratch directory, the Wine-specific pnpm overrides @@ -74,19 +74,54 @@ trap cleanup EXIT mkdir -p "$cache_dir" "$scratch/logs" # ---- provision Windows Node, boot Wine, snapshot + install concurrently ---- +curl_metadata_args=( + --fail --silent --show-error --location + --retry 3 --retry-all-errors --retry-delay 2 + --http1.1 --connect-timeout 10 --max-time 30 --retry-max-time 120 +) + +download_node_archive() { + local version="$1" output="$2" attempt status=0 + local archive="node-$version-win-x64.zip" + local primary_url="https://nodejs.org/dist/$version/$archive" + local mirror_url="https://npmmirror.com/mirrors/node/$version/$archive" + + if curl --fail --silent --show-error --location --http1.1 \ + --connect-timeout 10 --max-time 300 --speed-limit 1024 --speed-time 30 \ + -o "$output" "$primary_url"; then + return 0 + fi + echo 'wine-windows-gates: nodejs.org archive transfer stalled; resuming from the checksum-untrusted transport mirror' >&2 + for attempt in 1 2 3; do + if curl --fail --silent --show-error --location --http1.1 \ + --continue-at - --connect-timeout 10 --max-time 300 \ + --speed-limit 1024 --speed-time 30 \ + -o "$output" "$mirror_url"; then + return 0 + else + status=$? + fi + (( attempt < 3 )) || break + echo "wine-windows-gates: mirror transfer failed (exit $status) on attempt $attempt; resuming partial download" >&2 + done + return "$status" +} + provision_node() { # Latest release of the primary line, checksum-verified against the same - # dist directory. Offline runs fall back to the newest cached zip, loudly. + # dist directory. Bound and retry every transfer so a stalled nodejs.org + # response cannot consume the entire CI job. Offline runs fall back to the + # newest cached zip, loudly. local version zip - version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \ + version="$(curl "${curl_metadata_args[@]}" https://nodejs.org/dist/index.json 2> /dev/null \ | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \ || true)" if [ -n "$version" ]; then zip="$cache_dir/node-$version-win-x64.zip" if [ ! -f "$zip" ]; then - curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip" + download_node_archive "$version" "$zip.tmp" local expected - expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \ + expected="$(curl "${curl_metadata_args[@]}" "https://nodejs.org/dist/$version/SHASUMS256.txt" \ | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')" [ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; } verify_sha256 "$expected" "$zip.tmp" diff --git a/vendor/README.md b/vendor/README.md index 684c2c6363..75f93b2b88 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,12 +38,13 @@ Keep this log exhaustive — every divergence from upstream must be listed. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. -9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper makes pnpm's nested Git-package install reinvoke the same bundled entry with `--ignore-workspace`, so the selected package installs its own manifest dependencies instead of joining an enclosing source workspace. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper and exported `PNPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace. The child retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT` so a later inherited pnpm executable cannot outrank the wrapper on Windows. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile, obtains both its build and prepare commands from declared dependencies, and rejects an inherited shadow pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. 14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. +15. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. ## Sync procedure diff --git a/vendor/hmr/README.md b/vendor/hmr/README.md index db5ac1a49a..eec6ff940f 100644 --- a/vendor/hmr/README.md +++ b/vendor/hmr/README.md @@ -7,6 +7,12 @@ module caches, and reloads only the plugin entries that depend on changed application files. Changes to framework-level dependencies fall back to `loader.exit()`, letting the host process restart. +Module watches canonicalize their existing base directory before opening +Chokidar. Exact config watches likewise canonicalize the deepest existing +ancestor, then restore any missing suffix. Callbacks and diagnostics retain the +requested absolute filename, while the native backend receives one filesystem +spelling even when Windows supplied an 8.3 alias. + ## Requirements - `@cordisjs/plugin-loader` diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 00864cd865..169ee2e00a 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -4,7 +4,7 @@ import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plug import type { Include } from '@cordisjs/plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { dirname, relative, resolve } from 'node:path' -import { stat } from 'node:fs/promises' +import { realpath, stat } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -61,13 +61,18 @@ interface ConfigRegistration { watcher: FSWatcher } -async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> { +async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> { let root = dirname(filename) let depth = 0 while (true) { try { if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) - return { root, depth } + const canonicalRoot = await realpath(root) + return { + filename: resolve(canonicalRoot, relative(root, filename)), + root: canonicalRoot, + depth, + } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error const parent = dirname(root) @@ -129,9 +134,11 @@ class Hmr extends Service { async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { if (!this.watcher) throw new Error('HMR is not active') filename = resolve(this.baseDir, filename) - if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`) + const target = await findWatchRoot(filename) + const watchFilename = target.filename + if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`) - const { root, depth } = await findWatchRoot(filename) + const { root, depth } = target const watcher = watch(root, { ...this.config, cwd: undefined, @@ -140,9 +147,10 @@ class Hmr extends Service { ignoreInitial: false, }) const registration = { watcher } - this.configs.set(filename, registration) + this.configs.set(watchFilename, registration) const onChange = (path: string) => { - if (resolve(path) !== filename) return + const observed = resolve(path) + if (observed !== filename && observed !== watchFilename) return this.refreshConfig(registration, filename, refresh) } watcher.on('add', onChange) @@ -167,12 +175,12 @@ class Hmr extends Service { try { await ready.promise return this.ctx.effect(() => async () => { - if (this.configs.get(filename) === registration) this.configs.delete(filename) + if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename) await watcher.close() await this.configRefreshes.get(registration)?.running }, 'hmr.registerConfig()') } catch (error) { - this.configs.delete(filename) + this.configs.delete(watchFilename) await watcher.close() throw error } @@ -205,10 +213,22 @@ class Hmr extends Service { } const match = picomatch(ignored) + const watchBaseDir = await realpath(this.baseDir) + + // Collect externals before opening the watcher so every post-ready change + // is observed by listeners that already have their classification state. + const mainUrl = pathToFileURL(resolve(process.argv[1])).href + const mainJob = this.internal.loadCache.get(mainUrl) + if (mainJob) { + this.externals = await loadDependencies(mainJob) + } else { + this.externals = new Set() + } + this.watcher = watch(root, { ...this.config, - cwd: this.baseDir, - ignored: path => match(relative(this.baseDir, path)), + cwd: watchBaseDir, + ignored: path => match(relative(watchBaseDir, path)), // The initial scan re-announces files the boot just consumed: an `add` // for a config file refreshes an include whose initial apply may still // be in flight, and a failing apply then rolls this plugin back while @@ -219,26 +239,17 @@ class Hmr extends Service { ignoreInitial: true, }) - // Collect externals: framework modules reachable from the main entry. - // Changes to these files require a full process restart, not HMR. - const mainUrl = pathToFileURL(resolve(process.argv[1])).href - const mainJob = this.internal.loadCache.get(mainUrl) - if (mainJob) { - this.externals = await loadDependencies(mainJob) - } else { - this.externals = new Set() - } - const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce) const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { this.ctx.logger.debug('%s detected at %C', kind, path) - const filename = resolve(this.baseDir, path) + const filename = resolve(watchBaseDir, path) + const configuredFilename = resolve(this.baseDir, path) // Config reload: the file is a loader config file (e.g. cordis.yml). for (const entry of loader.entries()) { const include = entry.subtree as Include | undefined - if (include?.filename !== filename) continue - this.refreshConfig(include, filename, () => include.refresh()) + if (include?.filename !== filename && include?.filename !== configuredFilename) continue + this.refreshConfig(include, include.filename, () => include.refresh()) return } @@ -261,6 +272,26 @@ class Hmr extends Service { this.watcher.on('add', path => onChange('add', path)) this.watcher.on('change', path => onChange('change', path)) this.watcher.on('unlink', path => onChange('unlink', path)) + + const ready = Promise.withResolvers() + let readyState: 'pending' | 'resolved' | 'rejected' = root.length === 0 ? 'resolved' : 'pending' + if (root.length === 0) { + ready.resolve() + } else { + this.watcher.once('ready', () => { + readyState = 'resolved' + ready.resolve() + }) + } + this.watcher.on('error', (error) => { + if (readyState === 'pending') { + readyState = 'rejected' + ready.reject(error) + } else { + this.ctx.logger.warn(error) + } + }) + await ready.promise } private refreshConfig(key: object, filename: string, refresh: () => Promise | void) { diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 5eece997c9..59d34c24a3 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -2,6 +2,7 @@ import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader' import { Context, Service } from 'cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' +import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath, pathToFileURL } from 'node:url' import * as yaml from 'js-yaml' @@ -31,6 +32,14 @@ const writable: Record = { const supported = new Set(Object.keys(writable)) +const WRITE_RETRY_LIMIT = 10 +const WRITE_RETRY_DELAY_MS = 50 + +function retryableWriteError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM' +} + /** * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling @@ -171,6 +180,8 @@ export class Include extends EntryTree { private content?: string private data?: EntryOptions[] private writeTask?: NodeJS.Timeout | undefined + private pendingWrite?: EntryOptions[] + private writeQueue: Promise = Promise.resolve() private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { @@ -272,6 +283,7 @@ export class Include extends EntryTree { async stop() { await this.root.stop() + await this.flushWrite() } /** @@ -311,17 +323,43 @@ export class Include extends EntryTree { this.content = JSON.stringify(config, null, 2) } await writeFile(this.filename + '.tmp', this.content!) - await rename(this.filename + '.tmp', this.filename) + for (let retry = 0; ; retry++) { + try { + await rename(this.filename + '.tmp', this.filename) + return + } catch (error) { + if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error + await delay((retry + 1) * WRITE_RETRY_DELAY_MS) + } + } } private writeFile(config: EntryOptions[]) { clearTimeout(this.writeTask) + this.pendingWrite = config this.writeTask = setTimeout(() => { - this.writeTask = undefined - this._writeFile(config) + void this.flushWrite() }, 0) } + private flushWrite(): Promise { + clearTimeout(this.writeTask) + this.writeTask = undefined + const config = this.pendingWrite + this.pendingWrite = undefined + if (config === undefined) return this.writeQueue + const run = this.writeQueue.then( + () => this._writeFile(config), + () => this._writeFile(config), + ) + this.writeQueue = run + void run.catch((error) => { + this.ctx.root.logger?.('loader').warn('failed to write config file %C', this.filename) + this.ctx.root.logger?.('loader').warn(error) + }) + return run + } + /** Schedule a write of the current root entry data. */ write() { this.context.emit('loader/config-update') diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts index 5cb6ec387b..c85b84ccfc 100644 --- a/vendor/loader/src/repository.ts +++ b/vendor/loader/src/repository.ts @@ -36,13 +36,36 @@ function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS. return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) } +function normalizedEnvironmentPath(value: string): string { + const unquoted = value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value + const normalized = resolve(unquoted) + return process.platform === 'win32' ? normalized.toUpperCase() : normalized +} + function installEnvironment(commandDirectory: string): NodeJS.ProcessEnv { const scrubbed = scrubEnvironment() const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1] - const withoutPath = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => name.toUpperCase() !== 'PATH')) + const pathExt = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATHEXT')?.[1] + const pnpmHome = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PNPM_HOME')?.[1] + const normalizedPnpmHome = pnpmHome === undefined ? undefined : normalizedEnvironmentPath(pnpmHome) + const inheritedPath = path === undefined ? [] : path.split(delimiter).filter((entry) => { + return normalizedPnpmHome === undefined || normalizedEnvironmentPath(entry) !== normalizedPnpmHome + }) + const pathExtensions = pathExt?.split(';') + const prioritizedPathExt = pathExtensions === undefined ? undefined : [ + ...pathExtensions.filter(extension => extension.toUpperCase() === '.CMD'), + ...pathExtensions.filter(extension => extension.toUpperCase() !== '.CMD'), + ].join(';') + const withoutOverrides = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => { + return !['PATH', 'PATHEXT', 'PNPM_CONFIG_IGNORE_WORKSPACE'].includes(name.toUpperCase()) + })) return { - ...withoutPath, - PATH: [commandDirectory, ...(path === undefined ? [] : [path])].join(delimiter), + ...withoutOverrides, + PATH: [commandDirectory, ...inheritedPath].join(delimiter), + // cmd.exe tests PATHEXT before later PATH entries, so the transaction's + // pnpm.cmd must precede an inherited pnpm executable from PNPM_HOME. + ...(prioritizedPathExt === undefined ? {} : { PATHEXT: prioritizedPathExt }), + PNPM_CONFIG_IGNORE_WORKSPACE: 'true', } } diff --git a/vitest.config.ts b/vitest.config.ts index e8bae9560f..b6a75bf1d3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,18 +37,8 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] -// These files retain 100% per-file coverage on POSIX, where their process-pipe and terminal timing -// tests are deterministic; Windows skips those cases and must not fail solely on their uncovered paths. -const windowsCoverageExclusions = process.platform === 'win32' - ? [ - 'packages/lsp/lsp-local/src/connection.ts', - 'packages/lsp/lsp-local/src/index.ts', - 'packages/lsp/lsp-local/src/instance.ts', - ] - : [] - -// Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites -// self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file +// pwsh-local's run/start/lifecycle suites self-skip without a real pwsh +// (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts // green while CI runners ship pwsh and still enforce the full bar. The probe // runs the suites' own resolution (the dependency-free resolve.ts module), @@ -80,6 +70,8 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ + 'packages/session/session-persistence-jsonl/tests/jsonl.spec.ts', + 'packages/subagent/subagent-acp/tests/subagent-acp.spec.ts', 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', @@ -94,9 +86,8 @@ export default defineConfig({ // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), - // One coverage invocation aggregates both projects. Regular suites fork on - // POSIX for Node stability and use threads on Windows; process-bound suites - // always fork. + // One coverage invocation aggregates both projects. Every suite forks for + // Node stability; process-bound suites stay separate for inventory control. projects: [ { plugins: [pathsPlugin(), standardDecoratorPlugin()], @@ -104,11 +95,9 @@ export default defineConfig({ name: 'thread-safe', execArgv: vitestExecArgv, // Node 24 has aborted in its CJS lexer (v8::ToLocalChecked Empty - // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS - // arm64 and later on Linux. A fork contains that external runtime - // failure to the test process; Windows keeps the thread pool, where - // the abort has not reproduced and process spawn is costlier. - pool: process.platform === 'win32' ? 'threads' : 'forks', + // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS, + // Linux, and Windows. Forked workers avoid that shared thread path. + pool: 'forks', setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ @@ -226,7 +215,6 @@ export default defineConfig({ 'packages/interaction/commands/src/invariant.ts', 'packages/session/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), - ...windowsCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome).