Merge commit '404501a41ccf2a3b638b1b737087948fe08d5c4c' into codex/product-subagent-presets
# Conflicts: # packages/subagent/subagent-claude-code/tests/real-product.spec.ts
This commit is contained in:
@@ -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",
|
||||
|
||||
+2
-2
@@ -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
|
||||
+2
-1
@@ -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
|
||||
|
||||
+4
-3
@@ -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` 探索;与本通道互补而非竞争。
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 用例覆盖用户可见的进程状态;模型输出快照没有变化。
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
+3
-3
@@ -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.
|
||||
|
||||
|
||||
+3
-3
@@ -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,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -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.
|
||||
|
||||
+3
-3
@@ -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 及其独立参考流程都必须仍可运行。
|
||||
|
||||
+2
-2
@@ -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
|
||||
+1
-1
@@ -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:
|
||||
|
||||
|
||||
+1
-1
@@ -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 聚合流程:
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -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.
|
||||
+4
-4
@@ -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 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。
|
||||
+2
-2
@@ -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
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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,不传输托管缓存归档。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 运行器上使用不可变工作区。任一作业独有的失败都必须依据该边界分类,不得削弱或静默跳过。
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 把重新生成与重新记录的表面固定下来。覆盖缺口:没有门禁会拒绝新的序号引用,这条规则由评审把关。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
+1
-1
@@ -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).
|
||||
+54
-11
@@ -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
|
||||
|
||||
@@ -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<void>
|
||||
/** 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<void>,
|
||||
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<void> | undefined
|
||||
let timeout: ReturnType<typeof setTimeout> | 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<void> => {
|
||||
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<void> => {
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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<typeof startProfileLifecycle>,
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"]',
|
||||
|
||||
@@ -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
|
||||
@@ -480,7 +480,10 @@ serviceFor<K extends string & keyof Context>(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.
|
||||
|
||||
@@ -488,7 +488,10 @@ serviceFor<K extends string & keyof Context>(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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<Context> {
|
||||
async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<string> {
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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(<MarkdownText text="" streaming />)
|
||||
for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) {
|
||||
const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length))
|
||||
|
||||
@@ -445,7 +445,7 @@ describe('MarkdownText', () => {
|
||||
const startedAt = performance.now()
|
||||
const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
|
||||
|
||||
expect(performance.now() - startedAt).toBeLessThan(1_000)
|
||||
expect(performance.now() - startedAt).toBeLessThan(3_000)
|
||||
expect(container.querySelector('.katex')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<void>` 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.
|
||||
|
||||
@@ -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<void>`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。
|
||||
|
||||
@@ -38,25 +38,49 @@ const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
|
||||
*/
|
||||
const scopeParents = new WeakMap<ScopeKey, ScopeKey>()
|
||||
|
||||
/**
|
||||
* 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<void> | 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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ OPENAI_API_KEY: sk-…
|
||||
|
||||
## 热重载
|
||||
|
||||
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
|
||||
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
|
||||
|
||||
<a id="security-boundary"></a>
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
/* 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,
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<typeof import('node:fs/promises')>()
|
||||
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<unknown>)(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<void> {
|
||||
return writeFile(file, text, { mode: 0o600 })
|
||||
@@ -48,6 +67,7 @@ const KEY = credentialRef('DSH_CRED_PIPE')
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 []
|
||||
|
||||
@@ -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<void> } = {},
|
||||
) {
|
||||
|
||||
@@ -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<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async rename(oldPath: string, newPath: string): Promise<void> {
|
||||
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
|
||||
})
|
||||
})
|
||||
@@ -272,7 +272,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
await Promise.race([
|
||||
server.responseClosed,
|
||||
new Promise<never>((_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)
|
||||
}),
|
||||
])
|
||||
|
||||
|
||||
@@ -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<undefined>()
|
||||
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<Uint8Array>({
|
||||
pull(stream) {
|
||||
bodyRead.resolve(undefined)
|
||||
return new Promise<void>((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' })
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, Promise<StandingMount>>()
|
||||
|
||||
/**
|
||||
* 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<ScopeKey, ScopeParentBinding>()
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<ReturnType<StubPtySession['result']>>): PtySendOperation {
|
||||
private operation(done: Promise<ReturnType<StubPtySession['result']>>, 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')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -128,7 +128,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async recompose(agentCtx: Context, id: string): Promise<AgentPreset>',
|
||||
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<ScopeKey>',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -91,7 +91,10 @@ function isEEXIST(error: unknown): boolean {
|
||||
|
||||
async function assertDirectory(path: string): Promise<boolean> {
|
||||
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<void>
|
||||
async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
|
||||
// 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) {
|
||||
|
||||
@@ -62,6 +62,17 @@ async function expectFlushError(promise: Promise<unknown>, message: RegExp): Pro
|
||||
throw new Error('expected flush to reject')
|
||||
}
|
||||
|
||||
async function expectFlushCode(promise: Promise<unknown>, codes: readonly string[]): Promise<void> {
|
||||
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<string> {
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -92,11 +92,43 @@ async function importWithFilesystemMove(): Promise<typeof import('../src/win32.t
|
||||
|
||||
afterEach(async () => {
|
||||
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<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
stat: async (path: string) => {
|
||||
probes.push(path)
|
||||
return { isDirectory: () => true }
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.doMock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:path')>()
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,也绝不提交文件系统目标。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 格式
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<WatchHandle | undefined> {
|
||||
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<void> {
|
||||
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<RootWatchMode, { kind: 'root' }>): Promise<WatchHandle> {
|
||||
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<RootWatchMode, { kind: 'root' }>,
|
||||
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<RootWatchMode> {
|
||||
async function resolveRootWatchMode(root: string, followSymlinks: boolean): Promise<RootWatchMode> {
|
||||
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. */
|
||||
|
||||
@@ -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<string, unknown>
|
||||
path: string
|
||||
}
|
||||
|
||||
interface FakeWatchFileControl {
|
||||
@@ -63,9 +64,9 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
|
||||
vi.mock('chokidar', () => ({
|
||||
default: {
|
||||
watch(_path: unknown, options: Record<string, unknown>) {
|
||||
watch(path: unknown, options: Record<string, unknown>) {
|
||||
const emitter = new EventEmitter() as EventEmitter & { close(): Promise<void> }
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user