Skip to content

feat: macOS 原生适配——daemon 内置 loopback NFSv3 导出,普通用户 mount_nfs 挂载 Workspace(无 Docker / 无 macFUSE / 无 root) - #427

Open
jiangkoumo wants to merge 72 commits into
Ephemeral-AI-Lab:mainfrom
jiangkoumo:feat/macos-native-on-main
Open

jiangkoumo wants to merge 72 commits into
Ephemeral-AI-Lab:mainfrom
jiangkoumo:feat/macos-native-on-main

Conversation

@jiangkoumo

@jiangkoumo jiangkoumo commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

jiangkoumo:feat/macos-native-on-main → main。本 PR 只做 macOS 原生适配,自成一体:它用到的本地 daemon 运行时(最早在已关闭的 #402 里提出)已经作为本 PR 的一部分收进来,不依赖其他 PR。
分支头 d55acc15f,已 merge 到 main 582bc786f。历史只用 merge,不 rebase、不 force-push。

这个 PR 做什么

LayerFS 在 Linux 上用 FUSE 把 Workspace 呈现为普通目录。macOS 没有内置 FUSE,现在的选择是 macFUSE(要内核扩展,Apple Silicon 上要降低安全级别)、FUSE-T(挂载不稳定)或 Docker Desktop 里的 Linux(要装 Docker,I/O 全过虚拟机文件共享)。

本 PR 走另一条路:Sandbox 的 daemon 作为当前用户的普通进程运行,自己提供一个只监听 127.0.0.1 的 NFSv3 服务,macOS 自带的 /sbin/mount_nfs 以普通用户身份把 Workspace 挂到 Sandbox 目录下。 和 Meta EdenFS 在 macOS 上的做法一致。不需要 root、内核扩展、虚拟机或 Docker。

  • 开关:LAYERFS_SANDBOX=native(仅 macOS),另需 LAYERFS_DAEMON、LAYERFS_LOCAL_DIR(0755、属于当前用户)和放在本地 apfs/hfs 上的 LAYERFS_STORE_PATH;不读任何 Docker 设置。
  • 命令以当前用户身份运行,沿用调用方的 PATH(Homebrew、~/.local/bin、nvm 的 node 都能找到)。它不做隔离,隔离交给调用方自己的沙箱(例如 Seatbelt)。
  • Commit 前 daemon 先把客户端缓冲的写回;Commit/merge 换根后让客户端立即丢掉被替换文件的属性缓存。
  • 记录、空闲 2 分钟关闭、回收、创建意图、sandbox.close 等语义和 Docker Sandbox 一致;daemon 被杀后,下一次 sandbox.create / runtime.sandbox.reclaim 会强制卸掉它留下的挂载再删目录。
  • 文档:api/docs/agent.md 的 "Native Sandboxes (macOS)";生命周期设计:core/docs/architecture/nfs-native-lifecycle.md。

平台范围

  • macOS:唯一的新部署方式 native。
  • Linux:保持 main 的 Docker 路线不变。LAYERFS_SANDBOX 只接受不设、docker、native;native 在非 macOS 上由 sandbox.create 在做任何事之前拒绝为 Unsupported;其他值(包括早先草案里的 local)拒绝为 InvalidInput,不会被悄悄当成 Docker。没有任何部署会在 Linux 上启动本地 daemon 或启用 daemon 的 NFS 路线,Linux daemon 仍走 FUSE。
  • 早先草案(feat(sandbox): 本地进程 Sandbox 后端 LAYERFS_SANDBOX=local——在没有 Docker 的 Linux VM / CI 里使用 workspace.* #402)里的 Linux 对外部署模式 local 已经去掉。为了不重写已验证的代码,engine/crates/layerfs-sandbox/src/backend/local/ 这个本地进程后端整体保留(native 用的就是它的进程、exec、进程组、挂载和记录代码),里面 Local::new 和两个 Linux 测试(local_runtime、local_stop_mount)只作为这份共享代码的测试留着,不再有部署入口。
  • 旧记录:runtime: "local" 的 Sandbox 记录(只会来自早先未合并的构建)任何部署都不使用,返回 InvalidInput("never mixed … neither used nor deleted"),不删除它指向的任何东西,需要手动清理。

和 2.0 统一 provider 设计的关系

作者提到 2.0 要把 Docker、原生 Windows/macOS/Linux 和其他沙箱 provider 一起设计。本 PR 可以当作其中 macOS 原生 provider 的参考实现:它把"provider 怎么启动 daemon、怎么挂载、怎么跑命令、怎么记录和回收"在 macOS 上完整走通了一遍,NFS 导出(engine/crates/layerfs-nfs、daemon 的 control/export.rs)和 provider 外壳是分开的。2.0 的 provider 接口定下来以后,我愿意按新接口调整或拆分本 PR。

主要改动

  • engine/crates/layerfs-nfs(新):NFSv3/MOUNT 协议服务、AppleDouble 处理、写回与属性刷新,只听 loopback。
  • engine/crates/layerfs-daemon:control/export.rs、export_helper.rs 把 Workspace 以 NFS 导出并调用挂载/卸载助手;Commit 前 flush、换根后刷新;macOS 上 daemon 的进程保护(umask 077、禁 core dump)。
  • engine/crates/layerfs-node:runtime.rs(Docker / 本地 daemon 两种运行时)、local.rs(native Sandbox 的启动、失败清理)、discovery.rs(native 记录的读写与安全校验)、unmount.rs(Workspace 级卸载租约:daemon 等待卸载助手时不占住整个 Sandbox 的锁)。
  • engine/crates/layerfs-sandbox/src/backend/local/:本地进程后端(macOS 用 proc_pidinfo/sysctl/mount 读进程和挂载,umount -f 强制卸载)。
  • engine/crates/layerfs-store-service:macOS 上 Store 只允许放在本地 apfs/hfs;native 的安装者就是 daemon 的用户。
  • SDK api/agent/src:LAYERFS_SANDBOX / LAYERFS_LOCAL_DIR。
  • 生产代码行数(tools/production_loc.py --root .):本分支 109,142(去掉 local 部署的提交 −16)。

验证

上游 CI(本 PR 头 d55acc15f):fmt and guards、ruff、clippy、test 全部通过。

Linux(x86_64,cargo 1.85.1 --offline --locked,Node 22.23.3),同一台机器上对照 main 582bc786f:

检查 本分支 main
cargo fmt --all --check ✅ ✅
tools/check_product_boundary.py(完整 / --skip-sdk) ✅ / ✅ ✅ / ✅
check_fuser_integrity.py、tools 单测 ✅ ✅
CI clippy(--workspace --exclude layerfs-node --all-targets -D warnings)、node clippy ✅ ✅
Darwin 交叉 clippy(--target aarch64-apple-darwin,nfs/sandbox/node/daemon/store-service,-D warnings) ✅ —
fast lane(tools/ci_tests.py targets fast,测试二进制 / 通过 / 失败 / 忽略) 256 / 1160 / 2* / 12 247 / 1136 / 1* / 12
node 测试 native_local(native 记录、旧 local 记录拒绝) 7/7 不适用
JS(假 binding):native_deployment、calls、exec_output、trace×9、runtime×11、cli、doc-tables、backend×4 全过 全过

* 失败都与本 PR 无关:layerfs-api::sdk_method_golden(两边都失败:测试机 umask 002,文件 mode 436≠420)、layerfs-node 的 native_admission 2 例和 native_discovery 3 例(mock Engine 连接被拒,main 同样)、packaging.mjs(两边都失败,需要 Node 24)。本分支多出的 1 个失败是 layerfs-persistence::concurrent_first_installs,已有的并发抖动:各跑 30 次,本分支 3/30、main 5/30 失败。

macOS(Apple silicon,macOS 27.2,CARGO_PROFILE_RELEASE_STRIP=none):

  • release 构建 daemon + node addon + daemon_nfs 示例:✅,addon 可加载(5 个导出)。
  • 单测:layerfs-nfs 17 / layerfs-sandbox 37(1 个 Linux 限定忽略)/ layerfs-store-service 7,全过;layerfs-node 测试按上游设计只在 Linux 运行。
  • Mac clippy -D warnings(nfs/sandbox/node/daemon/store-service/persistence/overlay,--all-targets):✅ 0 警告。
  • SDK smoke LAYERFS_SANDBOX=native:48/48(连跑两次),无挂载、无 daemon 残留。
  • PATH 检查 11/11(沿用 PATH 7/7,无 PATH 回落 4/4);卸载租约真机回归 api/agent/test/verify/native_unmount_real.mjs PASS。
  • 第一次 smoke 是在 release 构建刚结束、负载很高时跑的,workspace.unmount 遇到一次 macOS 客户端返回 Resource busy(之后的连带失败都源于这个挂载没卸掉);之后两次完整重跑均 48/48。这个偶发情况我会继续观察。

复现命令:

export CARGO_PROFILE_RELEASE_STRIP=none   # macOS 27 + rustc 1.85.1 必须关 strip,否则 dyld 拒绝加载 layerfs.node
cargo +1.85.1 build --release --locked -p layerfs-daemon -p layerfs-node
cargo +1.85.1 test --locked -p layerfs-nfs -p layerfs-sandbox -p layerfs-store-service
mkdir -p ~/lfs-try/local && chmod 755 ~/lfs-try/local
export LAYERFS_SANDBOX=native LAYERFS_LOCAL_DIR=~/lfs-try/local LAYERFS_HOME=~/lfs-try/home \
       LAYERFS_STORE_PATH=~/lfs-try/vol/global/store.sqlite \
       LAYERFS_DAEMON=$PWD/target/release/layerfs-daemon LAYERFS_NATIVE=$PWD/layerfs.node
cp target/release/liblayerfs_node.dylib layerfs.node   # Node 只加载 .node 后缀
# 然后用 api/agent/src 的 LayerFS:init → branch.fork → sandbox.create → workspace.mount(sandbox, branch) → exec → commit → unmount → sandbox.close

问题反馈请附:sw_vers、uname -m、rustc +1.85.1 -V、node -v、失败输出、mount | grep 127.0.0.1、pgrep -lf layerfs-daemon、LAYERFS_LOCAL_DIR/<sandbox>/daemon.log 末尾。

已知限制

  • macOS 上后台进程(exec 的 background)仍依赖 /proc,暂不可用。
  • 大量小文件写入比直接写 APFS 慢约 6 倍。
  • @layerfs/runtime 的 Docker 侧 runtime.sandbox.list/sweep 看不到 native Sandbox。
  • 只在 Apple silicon + macOS 27.2 上验证过。

…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/Cargo.toml (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 108,451 -> 108,451 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/mod.rs (production).
No tests changed or deleted.

Production LOC: 108,451 -> 108,460 (delta +9)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/process.rs (production).
No tests changed or deleted.

Production LOC: 108,460 -> 108,505 (delta +45)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/mounts.rs (production).
No tests changed or deleted.

Production LOC: 108,505 -> 108,576 (delta +71)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/create.rs (production).
No tests changed or deleted.

Production LOC: 108,576 -> 108,834 (delta +258)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/sandbox.rs (production).
No tests changed or deleted.

Production LOC: 108,834 -> 109,057 (delta +223)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/exec.rs (production).
No tests changed or deleted.

Production LOC: 109,057 -> 109,166 (delta +109)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/mod.rs (production).
No tests changed or deleted.

Production LOC: 109,166 -> 109,167 (delta +1)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/tests/local_runtime.rs (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 109,167 -> 109,167 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/runtime.rs (production).
No tests changed or deleted.

Production LOC: 109,167 -> 109,274 (delta +107)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/local.rs (production).
No tests changed or deleted.

Production LOC: 109,274 -> 109,527 (delta +253)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/registry.rs (production).
No tests changed or deleted.

Production LOC: 109,527 -> 109,527 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/discovery.rs (production).
No tests changed or deleted.

Production LOC: 109,527 -> 109,644 (delta +117)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/exec.rs (production).
No tests changed or deleted.

Production LOC: 109,644 -> 109,633 (delta -11)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/sandbox.rs (production).
No tests changed or deleted.

Production LOC: 109,633 -> 109,641 (delta +8)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/workspace.rs (production).
No tests changed or deleted.

Production LOC: 109,641 -> 109,636 (delta -5)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/lib.rs (production).
No tests changed or deleted.

Production LOC: 109,636 -> 109,638 (delta +2)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/tests/unit/runtime.rs (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 109,638 -> 109,638 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/src/client.js (production).
No tests changed or deleted.

Production LOC: 109,638 -> 109,656 (delta +18)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/src/index.js (production).
No tests changed or deleted.

Production LOC: 109,656 -> 109,658 (delta +2)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/test/local_deployment.mjs (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 109,658 -> 109,658 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/README.md (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 109,658 -> 109,658 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/process.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,658 -> 109,696 (delta +38)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/groups.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,696 -> 109,818 (delta +122)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/exec.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,818 -> 109,835 (delta +17)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/sandbox.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,835 -> 109,868 (delta +33)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/create.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,868 -> 109,882 (delta +14)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/mounts.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,882 -> 109,870 (delta -12)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/mod.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,870 -> 109,871 (delta +1)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/mod.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,871 -> 109,872 (delta +1)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
@jiangkoumo

Copy link
Copy Markdown
Contributor Author

新提交 a700af8:macOS 上 ._*(AppleDouble)文件改为只放在导出进程内存里,不再拒绝

问题
macOS 的 NFSv3 客户端会把文件的扩展属性写进 ._<文件名>。之前导出端直接拒绝创建这类文件(NFS3ERR_ACCES),导致只要源文件带扩展属性,复制就会失败。macOS 27 下几乎所有下载来的文件都带 com.apple.provenance,包括整个 cargo registry,所以把 target 放在工作区里跑 cargo build 时,libsqlite3-sys、zstd-sys 的 build script 会在 std::fs::copy 处报 Operation not permitted。

改法(新模块 core/crates/layerfs-nfs/src/apple_double.rs)
• .* 普通文件的 CREATE 成功,但文件只存在导出进程的内存里,用独立的序号段(≥ 1<<62),不会进引擎。
• LOOKUP / GETATTR / ACCESS / READ / WRITE / SETATTR / COMMIT / REMOVE 都能正常用,也可以 RENAME 成另一个 .
* 名字;READDIR 不列出。
• 因为不进引擎,工作区不会被标成有改动(unmount 不会报 Dirty),Commit 也不会带上它;导出释放时一起丢弃。
• 有上限:最多 4096 个,单个最大 1 MiB,总共 64 MiB;超出数量仍按原来的方式拒绝。
• 目录、symlink、硬链接、把普通文件 rename 成 ._* 仍然拒绝。

验证
• Linux:cargo fmt --check、clippy -D warnings、layerfs-nfs 全部测试通过(新增 apple_double_files_stay_out_of_the_workspace,覆盖创建、读回、再次打开截断、GUARDED 冲突、READDIR 不可见、改名限制、REMOVE、Commit 隔离、释放计数)。
• Mac(M2,macOS 27.2):release 构建通过,单测全过,smoke 48/48。
• 真实场景:target 放在 LayerFS 工作区内跑 cargo build

  • 冷编译 14.8 s(target 478 MB),提交 4.4 s(Store 增加约 184 MB)
  • 从它 fork 新工作区 + 挂载 0.02 s,新工作区直接复用产物,编译 0.6 s
  • 在新工作区注入语法错误,正确报 unclosed delimiter;原工作区不受影响,仍是 Finished
  • 挂载里看不到任何 ._ 文件
    对照 git worktree(每个 worktree 独立 target):每个约 11 s 冷编译,额外占 126 MB 源码 + 约 490 MB 产物。

Replace the unconditional wait after helper KILL with a five-second
try_wait observation; retain unreaped children and report their PID.
Read helper pipes nonblockingly with capped diagnostics, and keep the
export and engine custody when a mount helper times out after spawn.

Detach local Sandbox mounts while the daemon still serves NFS. Continue
stopping after detach failure; keep Delete's checked detach as its fence.
Bound macOS force-unmount helpers too, including stderr capture.
Keep mount-table process imports macOS-only so Linux has no unused imports.

Give discovery ownership acquisition one 70-second deadline, covering a
single 60+5-second helper and return headroom. Preserve lock scope, ordering
and Busy refusal; do not release ownership around an in-flight mutation.
A real SDK lock test refuses at 60s before and succeeds after release at
62s with the new budget. Multiple queued holders can still yield Busy.

Add process-helper regression tests and an opt-in real macOS mount proof.
Mark the actual 60-second deadline test slow. No mount-option changes.
Original kernel U-state hang remains unreproduced on this Mac.

Validation: daemon/NFS/sandbox all-target tests 102 passed, 0 failed,
4 ignored; native smoke 48/48; macOS all-target Clippy including binding
and Rust formatting pass. Prior real Stop fixture failed before and passed
after. Existing product guard reports five binding inline-test violations.
Linux cross-Clippy could not build SQLite/zstd without x86_64-linux-gnu-gcc;
the cfg correction still needs native Linux confirmation. Fresh-context
read-only review of the teardown fix found no P0/P1.

Production LOC: 108058 -> 108240 (delta +182)
Scope: first-party core Rust/runtime SQL plus api Rust/JS; core 101993 ->
102175, api 6065 unchanged. Daemon 7058 -> 7157 (+99), sandbox 4476 ->
4559 (+83). Other packages unchanged; helper relocation included.
Method: tools/production_loc.py at a700af8, same counter over git archive
parent a700af8 and exact staged tree 0edfb78.
Excludes tests/docs/tooling and test-only source. Reference absent (retired
R9c; last counted 65,417 at tree 498dd19).
Register a Workspace-scoped unmount intent and exclusive flock under the
Sandbox guard, then release Sandbox ownership during the daemon exchange.
Publish the known result under the Workspace lease before shared-record
cleanup. Repeated requests read progress or the saved outcome, never resend.

Use existing Busy/Unknown refusals with Unmounting/Retained state and elapsed
milliseconds. Gate token/directory and backend reads for that Workspace while
other Workspaces and discovery reads proceed. Keep the 60+5-second helper
budget and restore the discovery readiness wait to 60 seconds.

Explicit Sandbox close may take over orphaned or retained unmounts but still
refuses unrelated active Workspaces. Preserve Stop's detach-before-termination
and Delete's checked detach. Keep known daemon success on cleanup failure and
report cleanupError separately. Nonblocking shared lease probes cannot block
one another; known-token lookup avoids scanning historical result records.

Add a real native SDK regression with paused helpers, cross-process progress,
Busy/Dirty reversibility, publication during a held discovery lock, caller
crash, saved failure, and explicit close takeover. The old binding fails its
900ms discovery-read assertion; the new binding completes unrelated calls in
milliseconds. Update lifecycle documentation and describe interface details.

Validation: daemon/NFS/sandbox all-target tests 102 passed/0 failed/4 ignored;
binding 9/9; native smoke 48/48; real concurrency fixture PASS; macOS Clippy
(all targets, including binding), fmt, JS syntax and diff checks pass.
Linux cross-Clippy blocked by missing x86_64-linux-gnu-gcc in SQLite/zstd;
existing five binding product-boundary violations remain unchanged in kind.
Fresh-context review identified two recovery issues, both fixed and verified.
No public method or daemon wire change; new detail shapes and close takeover
semantics require maintainer alignment before upstream publication.

Production LOC: 108240 -> 108557 (delta +317)
Scope: first-party core Rust/runtime SQL plus api Rust/JS; core 102175 unchanged,
api 6065 -> 6382, binding 3412 -> 3729; other packages unchanged.
Method: tools/production_loc.py over git archive first parent 4c1648e and
exact staged tree 5394d16, same counter,
excluding tests/docs/tooling and test-only source. Reference absent (retired
R9c; last counted 65,417 at tree 498dd19).
The Workspace unmount lease opened its record with a hand-written
O_NOFOLLOW: 0x100 on macOS and 0x20000 on Linux. 0x20000 is O_NOFOLLOW
only on x86_64 Linux; on aarch64 Linux O_NOFOLLOW is 0x8000 and 0x20000 is
O_LARGEFILE, so the lease would silently follow a symlink there. Take the
constant from libc (already in the lock at =0.2.189 through other crates)
so every target gets its own value.

No behavior change on macOS or x86_64 Linux. The binding's existing
discovery::no_follow() carries the same hand-written values and is left
as it is (it predates this branch).
Sandbox::stop() ended the daemon first and left the Sandbox's mounts in
place until Delete detached them. Detach them first, while the daemon can
still answer, then stop the daemon as before. A failed detach is reported
and does not block the crash teardown; Delete keeps its own checked detach
before removing anything through a mount.

On Linux FUSE this is ordering only: Stop now returns with the mounts
already gone (measured 21.6 ms with a live holder and a dirty writer; before
the change the mounts stayed until Delete), and reclaim is unchanged
(0.09-0.10 s). MNT_DETACH does not flush dirty pages, so it adds no data
guarantee; Stop stays the crash teardown it was.

Same hunk as the local-backend part of the macOS native branch's NFS
unmount-hang fix, so merging this branch there stays clean.
@jiangkoumo

Copy link
Copy Markdown
Contributor Author

新提交(3 个,叠在 a700af8 上):修复 macOS NFS 卸载卡住时 daemon 跟着挂死、并拖住整个 Sandbox 的问题

• 4c1648e fix(native): bound NFS helper reaping and detach before daemon stop
• b3f8f80 fix(binding): release Sandbox ownership while unmount waits
• d3a2a8c fix(binding): use libc::O_NOFOLLOW for the unmount lease

问题

  1. 卸载 NFS 时调用的 umount helper 如果卡在内核等待里(SIGKILL 也杀不掉),daemon 超时 KILL 后会无条件 wait(),线程永久卡死。export.rs 只在本 PR 里,这是本 PR 引入的问题。
  2. 本地后端的 Sandbox::stop() 先停 daemon,挂载要等 Delete 才摘(backend/local/sandbox.rs,feat(sandbox): 本地进程 Sandbox 后端 LAYERFS_SANDBOX=local——在没有 Docker 的 Linux VM / CI 里使用 workspace.* #402/本 PR 引入)。
  3. Node binding 在整个控制操作期间都持有 Sandbox 的 ownership.lock(上游原有设计)。卸载一卡,同一个 Sandbox 上的 mount / close / get_log 等全部排队,60 秒后报 Busy。Docker 路线上卸载很快所以不明显,NFS 路线上会暴露出来。

改法
• helper 等待有上限:60 秒,超时 KILL 后再观察 5 秒;仍未退出就返回 Retained(TeardownCustody, stage Detach),helper 由 daemon 托管,下次调用 helper 时顺带回收。挂载 helper 超时同样返回 Retained 并保留 export。
• Stop 先 detach 挂载再停 daemon;detach 失败不阻止崩溃式回收,Delete 仍保留原来的带检查 detach。
• binding 新增 api/binding/src/unmount.rs:在 Sandbox 锁内登记 Workspace 级的卸载意图(workspace-.unmount)并拿该 Workspace 的独占 flock(workspace-.unmount-lock),然后放开 Sandbox 锁去等 daemon;完成后先在 Workspace 锁下保存结果,再重新拿 Sandbox 锁清理共享记录。观察者只用非阻塞共享 flock 探测,不会互相等。discovery 等待上限保持 60 秒。
• 锁文件用 libc::O_NOFOLLOW 打开(原先手写 0x20000 只在 x86_64 Linux 正确,aarch64 Linux 上是 O_LARGEFILE)。顺带:discovery::no_follow() 里也是同样的手写值(上游原有代码),这次没动,要不要一起改听你的。

需要你确认的三处对外行为变化(没有新方法、新错误码,daemon 协议不变)

  1. 错误 detail 新增字段:卸载进行中,同一 Workspace 的调用返回已有的 Busy,detail 带 state: "Unmounting" 和 elapsedMs;调用方崩溃或卸载失败后返回 Unknown(或原错误码),detail 带 state: "Retained"、elapsedMs、original;daemon 已成功但 SDK 清理记录失败时仍返回成功,另带 cleanupError。
  2. 重复 unmount 返回上一次的结果,不重发:上一次还在进行就返回 Unmounting;已经有结果就直接返回保存的结果。原因是卡住的卸载是否已在内核生效无法判断,重放不安全。
  3. sandbox.close 可以接管"失主"或 Retained 的卸载(发起方进程已不在),走已有的 Stop/Delete 收掉整个 Sandbox,不重放那次卸载;还有其他活着的 Workspace 时照旧返回 MountsActive。

复现
• Linux(测试机内核没有 NFSv3 客户端,用假挂载 + 不可杀 helper):daemon 环境设 LAYERFS_KERNEL=nfs、LAYERFS_NFS_MOUNT="/bin/true {dir}"、LAYERFS_NFS_UNMOUNT="/usr/bin/touch /x", 是一个先 fsfreeze -f 冻住的 16 MiB loop ext4(touch 进程是 D 态,SIGKILL 无效)。然后从 SDK 调 workspace.unmount。
修复前:不解冻就永远不返回(150 秒被 timeout 杀掉);解冻后 112–127 秒才返回;daemon 线程停在 wait4。
修复后:65.01 秒返回 Retained(Detach),detail 带 pid、KILL=Ok、"exit not observed after 5s";daemon 没有线程卡在 wait4;重复 unmount 0.05–0.09 秒返回同样结果,不起新 helper。
• macOS:api/binding/tests/unmount-concurrency.mjs(真实 SDK + 真实 NFS + 暂停的 helper)
node api/binding/tests/unmount-concurrency.mjs <隔离根目录> <layerfs.node>
旧 binding(4c1648ed2):第一个需要 Sandbox 锁的 get_log 超过 900 ms 断言失败。
新 binding:PASS。helper 卡住期间,同 Sandbox 的 get_log 1 ms,另一个 Workspace 的 status 5 ms / exec 12 ms,新建第三个 Workspace 28 ms;当前 Workspace 的调用立即返回 Busy/Unmounting;跨进程重复 unmount 不重发;Busy/Dirty 可恢复;卸载调用方 SIGKILL 后 sandbox.close 72 ms 接管成功;helper exit 1 后重复 unmount 返回保存的同一错误,close 66 ms 接管成功。

验证(头 d3a2a8c)
• Mac:cargo fmt --check 通过;cargo clippy -p layerfs-daemon -p layerfs-nfs -p layerfs-sandbox -p layerfs-node --all-targets -- -D warnings 通过;cargo test -p layerfs-node 9/9;unmount-concurrency.mjs PASS;native smoke 48/48,无挂载/daemon 残留。daemon/nfs/sandbox 全部测试(b3f8f80a5 时)102 通过/0 失败/4 忽略。
• Linux x86_64:
cargo +1.85.1 fmt --manifest-path core/Cargo.toml --all -- --check → 通过
cargo +1.85.1 clippy --manifest-path core/Cargo.toml --offline --locked -p layerfs-nfs -p layerfs-daemon -p layerfs-sandbox -p layerfs-store-service -p layerfs-overlay -p layerfs-persistence -p layerfs-node --all-targets -- -D warnings → 通过
同上加 --target aarch64-apple-darwin → 通过
同上加 --target aarch64-unknown-linux-gnu(不含 --all-targets) → 通过
cargo test -p layerfs-nfs / -p layerfs-sandbox / -p layerfs-node --all-targets → 17/17(含 helper_deadline 3 个,约 60 秒)、39/39、12/12
core/tools/check_product_boundary.py → 5 条,均为 binding 里已有的 test-only 标注,和 main 上一致,非新增

没覆盖的
• 真实 Linux NFSv3 卸载(测试机内核无 NFSv3 客户端)。
• 之前在 Mac 上遇到的一次 10–40 分钟 U 态长挂,根因没能复现确认,这组提交只是保证 daemon 不再跟着卡死。
• unmount-concurrency.mjs 不在 cargo test 里,需要 macOS 单独用 node 跑。
• helper_deadline 会让 CI 多约 60 秒(已登记到 ci_tests.py 的 SLOW)。

#402 那边只放了本地后端 "Stop 先 detach" 这一段(和这里的 hunk 完全相同),NFS helper 和 binding 放锁的部分只在本 PR。

@jiangkoumo
jiangkoumo marked this pull request as ready for review October 26, 2026 07:04
@jiangkoumo
jiangkoumo marked this pull request as draft October 26, 2026 07:04
yifanxuaaa added a commit that referenced this pull request Oct 26, 2026
Lists herdr-layerfs (jiangkoumo) at plugins/herdr-layerfs/: versions 0.2.1
through 0.3.3 (latest d2bf1a854b), all on LayerFS ad78914 (v1.1.0 plus the
#436 binding fix). Linux docker mode only, status preview; the macOS native
mode waits for #427. plugin_check.py --online prints ok. The listing matches
layerfs.ai.

Production LOC: 102162 -> 102162 (delta 0)
  First-parent comparison (main 0f7054e -> this merge). core 38241, engine
  61288, api 2633, unchanged; reference absent (retired R9c; last counted
  65,417 at tree 498dd19). Counted with tools/production_loc.py;
  listing files only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…meral-AI-Lab#402)

Upstream now keeps the Node binding's tests outside the crate source: they
load the ordinary built addon and call its exported methods, never including
private binding source (engine/AGENTS.md, engine/crates/layerfs-node/tests).
The Ephemeral-AI-Lab#402 unit tests that were included by runtime.rs and sandbox.rs
(tests/unit/{runtime,reclaim}.rs before D8) are rewritten as
engine/crates/layerfs-node/tests/native_local.rs, which writes a Sandbox
record as an earlier call would and calls the public
`runtime.sandbox.reclaim` with a local deployment:

- a local record naming any other directory (a data directory, another
  LAYERFS_LOCAL_DIR, another Sandbox or name, a relative or `..` path, `/`)
  is refused InvalidInput, "neither used nor deleted", and nothing is
  removed;
- a local record is used only with an absolute, plain deploy.localDir;
- a stopped local Sandbox where this deployment puts it is deleted and its
  number freed; a record from before boot ids is read without one; a
  damaged boot or port is refused and removes nothing;
- a Docker record is not read as a local Sandbox.

The Docker reclaim cases of the old reclaim.rs are covered by upstream's
native_discovery.rs. Selection of a misspelled deploy.sandbox is refused only
by sandbox.create after the project's Store is installed, so it is not
reachable without a Store and is left to the SDK test (local_deployment.mjs).

Production LOC: 103762 -> 103762 (delta 0)
(tools/production_loc.py --root ., core+engine+api; test-only commit)
…e built addon) into PR Ephemeral-AI-Lab#427 (local port)

Brings Ephemeral-AI-Lab#402's engine/crates/layerfs-node/tests/native_local.rs. One
conflict: api/binding/tests/unit/runtime.rs, deleted on Ephemeral-AI-Lab#402 and kept by
Ephemeral-AI-Lab#427's D8 merge, is deleted here too. api/binding/ no longer exists after
D8, the file was included by nothing, and its cases move to native_local.rs
in the next commit.

Production LOC: first-parent comparison 1931032 -> this merge, see
tools/production_loc.py --root .; tests only, delta 0.
Production LOC: 103762 -> 103762 (delta 0)
(tools/production_loc.py --root ., core+engine+api; formatting of a test file only)
…fter D8 (Ephemeral-AI-Lab#427)

- engine/crates/layerfs-node/tests/native_local.rs gains
  local_and_native_sandboxes_are_never_mixed: through the built addon's
  public runtime.sandbox.reclaim, a native record is refused (InvalidInput,
  "never mixed") by a local deployment and a local record by a native one,
  nothing is removed, and a native deployment reclaims its own stopped
  record. It replaces the native cases of the old included
  api/binding/tests/unit/runtime.rs (removed in the previous merge).
- The real native SDK regression of the unmount lease moves from
  api/binding/tests/unmount-concurrency.mjs (api/binding/ is gone after D8)
  to api/agent/test/verify/native_unmount_real.mjs, next to upstream's other
  *_real.mjs verifications, with its imports updated to D8's layout
  (api/agent/src, api/backend/src). It stays macOS-only and explicit.
- discovery.rs: the "never mixed" refusal lost its line continuation in the
  v1.1 port and carried a run of spaces inside the message; restored.

Production LOC: 108595 -> 108596 (delta +1)
(tools/production_loc.py --root ., core+engine+api; the restored message
continuation splits one source line in two)
…Ephemeral-AI-Lab#427)

D8 (Ephemeral-AI-Lab#465) made tools/check_product_boundary.py fail closed on any LayerFS
crate missing from its core/engine/api layer map. Ephemeral-AI-Lab#427's layerfs-nfs (the
loopback NFSv3 export, beside layerfs-fuse under engine/crates) was not in
it, so the full and the CI (--skip-sdk) runs reported
"crate missing from layer map: layerfs-nfs" for layerfs-nfs and for
layerfs-daemon, which depends on it. It is an engine crate like
layerfs-fuse; its dependencies (content, overlay, workspace, fuse) are at
or below that layer.

Production LOC: 108596 -> 108596 (delta 0)
(tools/production_loc.py --root ., core+engine+api; development tool only)
…-Lab#402)

Production LOC: 103762 -> 103762 (delta 0)
(tools/production_loc.py --root ., core+engine+api; documentation only)
…nto PR Ephemeral-AI-Lab#427 (local port)

Production LOC: 108596 -> 108596 (delta 0), first-parent comparison
(tools/production_loc.py --root ., core+engine+api; documentation only)
…b#427)

Production LOC: 108596 -> 108596 (delta 0)
(tools/production_loc.py --root ., core+engine+api; documentation only)
@jiangkoumo

Copy link
Copy Markdown
Contributor Author

同步一下:本分支已跟上 D8 三层结构(main caebd26,#465),#402 也同步更新了。

主要变化
• layerfs-nfs 整个 crate 搬到 engine/crates/layerfs-nfs,路径依赖改指 core/crates;在 tools/check_product_boundary.py 的分层表里登记为 engine 层(不登记的话完整检查和 CI 检查会各报 2 条)。
• 按 engine/AGENTS.md 的新规则,测试不再 include 私有源码:原来的 tests/unit/{reclaim,runtime}.rs 改写成 engine/crates/layerfs-node/tests/native_local.rs,通过公开的 runtime.sandbox.reclaim 走 addon 验证(#402 上 6 个用例,本 PR 7 个)。原 reclaim 里的 Docker 用例已由上游 native_discovery.rs 覆盖,就没有重复写。
• unmount 租约的真实回归测试搬到 api/agent/test/verify/native_unmount_real.mjs(D8 后原位置已不存在)。
• 生产代码只改了 1 行:恢复 discovery.rs 里 "never mixed" 报错丢掉的续行反斜杠。

验证
• Linux:fmt / clippy / 边界检查全过;node 测试 15/15;CI fast lane 1098 过,唯一 1 个失败是 concurrent_first_installs,在干净 main 上交替跑 50 次同样失败 4 次,属于原有的偶发问题;local smoke 48/48。
• macOS(M2,macOS 27.2):release 构建通过;nfs/sandbox/store-service 单测 53/53;clippy -D warnings 0 警告;native smoke 48/48、pathcheck 11/11、unmount 租约回归通过,无残留挂载或进程。

有一处想请教:deploy.sandbox 拼错(例如 Local)时的拒绝,在 binding 里只有带 Store 的 sandbox.create 才会检查,addon 测试不带 Store 走不到,目前只由 SDK 的 local_deployment.mjs 覆盖。这样是否可以,还是希望在 binding 这层补一个测试?

…-AI-Lab#427)

Brings main up to 582bc78 (Ephemeral-AI-Lab#488, Ephemeral-AI-Lab#481 forced unmount after a stalled
Join, Ephemeral-AI-Lab#479, Ephemeral-AI-Lab#471, Ephemeral-AI-Lab#473, Ephemeral-AI-Lab#445 fs modes, Ephemeral-AI-Lab#475, Ephemeral-AI-Lab#478, Ephemeral-AI-Lab#476 Docker API
negotiation, Ephemeral-AI-Lab#396, Ephemeral-AI-Lab#419, Ephemeral-AI-Lab#418, Ephemeral-AI-Lab#415, Ephemeral-AI-Lab#477, Ephemeral-AI-Lab#468).

Two conflicts, both from Ephemeral-AI-Lab#481 (forced unmount of a normal unmount stalled
at Join):
- engine/crates/layerfs-daemon/src/control/force.rs: guard() keeps this
  branch's ExportKept check (an NFS export kept after a failed teardown
  answers with its stored custody first) and takes main's comment and its
  forceable() exception for a retained FUSE session. The non-Linux
  `let _ = token;` stays removed: ExportKept uses the token on every
  platform.
- engine/crates/layerfs-node/src/workspace.rs: unmount() keeps this
  branch's per-Workspace unmount lease (repeat a published outcome, drop
  the admission guard before the daemon call, publish the outcome before
  cleanup) and takes main's `force` option inside it: forced, one
  api.unmount(token, true) whose None answer is Unmounted::Closed;
  otherwise unmount_checked with `discard` as before.

Production LOC: first-parent 9478190 108596 -> 109158
(tools/production_loc.py --root .; the delta is main's own changes)
…er selectable (Ephemeral-AI-Lab#427)

Ephemeral-AI-Lab#427 is the macOS native adaptation. The local-daemon runtime it builds
on (first proposed in the closed Ephemeral-AI-Lab#402) is now part of this PR, and its
only selectable form is `native`: on macOS a daemon of this user serves
its Workspaces over loopback NFS to the host's own client. Linux keeps
the Docker route exactly as on main.

- deploy.sandbox (LAYERFS_SANDBOX) accepts unset, `docker` or `native`;
  `local` is refused InvalidInput like any other value, in the SDK
  (api/agent/src/client.js) and in the binding (runtime::local).
- local::start refuses `native` off macOS as Unsupported before any
  effect, so no deployment starts a local daemon on Linux. The root
  check and the Local::new (Linux FUSE) branch go: native always takes
  Local::native, LAYERFS_KERNEL=nfs and this user's identity.
  discovery::root() had no other caller and goes too.
- Records: a new record's runtime is always `native`. A native record is
  used only by a native deployment; a record of the former `local`
  runtime (an earlier, unmerged build) is used by no deployment, refused
  InvalidInput ("never mixed", "neither used nor deleted"), and nothing
  it names is deleted.
- Tests: engine/crates/layerfs-node/tests/native_local.rs writes native
  records (record handling is the same code on Linux) and gains
  a_record_of_the_former_local_runtime_is_never_used_nor_deleted;
  api/agent/test/local_deployment.mjs becomes native_deployment.mjs with
  `local` among the refused values.
- api/docs/agent.md drops the "Local Sandboxes" section and keeps its
  layout, record and reclaim facts under "Native Sandboxes (macOS)".

The local backend in layerfs-sandbox (engine/crates/layerfs-sandbox/src/
backend/local) is kept as it is: native uses its process, exec, group,
mount and record code; Local::new and its Linux tests stay as tests of
that shared code.

Production LOC: 109158 -> 109142 (delta -16)
(tools/production_loc.py --root .)
@jiangkoumo jiangkoumo changed the title [Draft] feat: macOS 原生支持(长期适配分支)——内置 loopback NFSv3 导出,普通用户 mount_nfs 挂载 Workspace(无 Docker / 无 macFUSE / 无 root) feat: macOS 原生适配——daemon 内置 loopback NFSv3 导出,普通用户 mount_nfs 挂载 Workspace(无 Docker / 无 macFUSE / 无 root) Oct 28, 2026
@jiangkoumo
jiangkoumo marked this pull request as ready for review October 28, 2026 03:14
@jiangkoumo

Copy link
Copy Markdown
Contributor Author

新提交 d55acc1:改成独立的 macOS 原生适配,并 merge 到 main 582bc78

• 不再依赖 #402:它用到的本地 daemon 运行时已经作为本 PR 的一部分收进来。
• 去掉了 Linux 对外的 local 部署:LAYERFS_SANDBOX 只接受不设、docker、native;native 只在 macOS 可用,其他平台由 sandbox.create 直接拒绝为 Unsupported。Linux 仍是 main 的 Docker 路线,没有变化。
• 旧的 runtime:"local" 记录任何部署都不使用、也不删除,返回 InvalidInput。
• merge main 的两处冲突来自 #481(强制卸载):force.rs 先查 NFS 的 ExportKept 再走新的强制卸载例外;workspace.rs 保留卸载租约,把 force 选项放进发给 daemon 的请求。

验证:上游 CI 全过;Linux 上 fmt / 边界检查 / clippy / fast lane / node 与 JS 测试和 main 对照,没有新增失败;Mac(Apple silicon,macOS 27.2)上 release 构建、单测、clippy、native smoke 48/48、PATH 检查 11/11、卸载租约回归都通过。详细结果已更新到 PR 描述。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants