spatiotemporal
时空可组合性演算的 Rust 实现:可撤销 effect、响应式 coeffect、fiber 惯性生命周期
- Stars
- 2
- Language
- Rust
- Created
- Aug 17, 2026
- Updated
- Aug 18, 2026
Introduction
spatiotemporal
时空可组合性演算的 Rust 实现。
论文《A Programming Paradigm for Spatiotemporal Composability》第 5 章给出了一个核心库,把可撤销 effect 与响应式 coeffect 实现成可用的编程抽象;原文的参考实现 Cordis 是 TypeScript 写的。这里是同一套语义的 Rust 版本:核心库(5.1 节)加声明式配置层(5.2.1 节)。
一句话概括它解决什么问题:让组件可以在运行中被装上和拆掉,而拆得干净这件事由抽象保证,不依赖每个组件作者的勤谨程度。
// 组件只声明「我需要 storage」,不关心是谁在提供。
impl Component for Worker {
fn inject(&self) -> Vec<KeyId> { vec![KeyId::of::<StorageKey>()] }
fn apply(&self, ctx: Context, steps: Steps) -> LocalBoxFuture<'_, Result<()>> {
Box::pin(async move {
let place = ctx.resolve::<StorageKey>()?.place();
steps.step(move || async move { leave(place).await })?; // 登记这一步的逆
Ok(())
})
}
}
提供者被换掉时,这个组件自己会去激活、重新激活,且顺序有保证——它不需要写任何卸载路径,也不需要监听任何事件。cargo run --example swap_provider 能看到全过程。
试试 Agent
spatiotemporal-agent/ 是演算之上的插件化 agent harness,形态对齐 DeepSeek Harness (dsh):文档、工具、LLM、界面全是 cordis.yml 里的一行行 fiber,换实现 = disabled + insert,不必改宿主代码。

rustup target add wasm32-wasip2
./spatiotemporal-agent/scripts/build-guests.sh # outline.wasm
export DEEPSEEK_API_KEY=sk-…
cargo run -p spatiotemporal-agent -- --creation # http://127.0.0.1:8787
推荐首轮:切创造模式 → define_script 安装 code-stats → 批准 → 「统计代码行数」。script 叶子通过 host.callTool 编排 native 的 bash / read,结果见 DEMO.md。
| 能力 | 说明 |
|---|---|
| 四种基质 | 同一棵 fiber 树里 native / wasm / script / process 叶子并存(outline、cite、stats、bash…) |
| 三档 profile | 标准 demo → 编码(--coding,更多 tool 轮次、注入 CODING.prompt.md)→ 创造(热装 script、define_script 走审批) |
| 工具链路可视化 | 每轮展示思考步骤 + 工具调用时间线;步骤持久化到 session JSONL |
| 工作区切换 | 浏览器下拉或 API 切换项目目录;fs/bash 沙箱根热更新;最近目录记在 .agent/workspaces.json |
| 多会话 + 会话级隔离 | 每工作区独立 .agent/sessions/;聊天 JSONL + 会话 patch JSON;创造模式热装只进当前会话 |
| 配置热对账 | 浏览器或 POST /api/mode 切换 profile,无需重启进程 |
无 API key 时用 cargo run -p spatiotemporal-agent -- --smoke(LLM 换 echo、界面换 probe,CI 同命令)。启动细节、环境变量、试玩脚本见 spatiotemporal-agent/README.md 与 DEMO.md。
三个概念
可撤销 effect。 每个改动上下文的动作都配一个逆,逆是值(Inverse),卸载时按 LIFO 回放。这是上下文被改动的唯一原语:coeffect 供给与组件实例化都归约到它,所以经由上下文做的任何事都被自动追踪。
响应式 coeffect。 组件声明它需要哪些键;提供者出现即激活它,提供者离开即去激活它,与它无关的变化不打扰它。依赖不可用不是错误,只是保持非活动。
惯性。 一次转换(加载或卸载)一旦开始就跑到完成,期间的目标变化被记下但不打断它;完成时若目标已变,立刻链接进下一次转换。这是并发正确性的来源,也是最容易实现错的一处。
快速开始
[dependencies]
spatiotemporal = "0.4"
完整的最小例子见 src/lib.rs 顶部的文档,cargo test --doc 会真的把它跑一遍。
仓库结构
Cargo.toml # 内核,同时是 workspace 根
src/ tests/ examples/
crates/spatiotemporal-wasm/ # wasm 基质适配器,独立版本、独立发布
crates/spatiotemporal-script/ # QuickJS 脚本基质,模型现写的代码走这里
crates/spatiotemporal-process/ # 子进程(NDJSON stdio)基质
spatiotemporal-agent/ # 插件化 agent harness + Web UI(不发布 crates.io)
cordis.yml / cordis.*.yml # 组合与 profile patch(标准 / 编码 / 创造 / smoke)
assets/index.html # 浏览器 UI(工作区、多会话、工具链路)
.agent/
workspaces.json # 当前 / 最近工作区(启动 cwd 下,不入库)
sessions/ # 每工作区:{id}.jsonl(聊天)+ {id}.patch.json(会话 patch)
default-members 只含内核,所以 cargo test 不会去编 wasmtime 或 QuickJS——内核有 5 个依赖,而 wasmtime / rquickjs 各自再带一大坨。Agent 单独编:cargo run -p spatiotemporal-agent(见上一节)。整个 workspace 的 MSRV 统一为 1.94(wasmtime 47 的要求)。
配置热重载
Loader 把一棵配置树对账成一组活着的 fiber。配置变了就把差异增量地施加上去——没有任何新机制:每次变更最终都是 use_component 与 dispose,所以配置热重载是可撤销 effect 的一个应用,而不是它之外的另一套东西。
一份基础配置加一层用户 patch,形状照 dsh 的 cordis.yml + cordis.patch.yml:
# cordis.yml
- id: sandbox
name: dsh-sandbox-local
- id: tool-bash # 它声明了 sandbox,但不知道是谁在提供
name: dsh-tool-bash
- id: tool-web
name: dsh-tool-web
config:
fetch: false
# cordis.patch.yml —— 用户层,叠加在上面
- id: tool-web
config:
fetch: true # 按 id 的 patch 替换整个 config,没改的字段也要重述
searchTimeoutMs: 60000
- id: sandbox
disabled: true # 换实现 = 关掉旧行
- insert:
- id: sandbox-remote # 加上新行
name: dsh-sandbox-remote
保存之后,tool-web 那一行重挂,sandbox 换成远端,而 tool-bash 自己去激活再重新激活——它没有写任何重连逻辑。cargo run --example watch_config 用 notify 把这套跑起来,包括「写坏配置不会杀死运行中的树」那一支。文件监听刻意留在库外面:它属于宿主的职责。
这一层有五个值得单独说的决定:
先构造,再拆除。 注册表里没有的名字、不合法的配置,都在任何 fiber 被动过之前同步失败。所以一次写坏的编辑不会先把系统拆一半——这是 dsh「先导入变化后的模块名,再 dispose 活动 fiber」的同一条。
补偿事务,不是不可见的原子替换。 任意组件的 effect 无法快照,所以不承诺中间状态不可见。候选失败时拆掉候选、重建先前的行,并如实报告 Error::Rollback 而不是声称树被完好保留。重建出来的是新的 fiber:可撤销性保证逆会被运行,不保证时间被倒流。
串行且合并。 同一个 loader 上的并发 apply 不会交错,第二个调用只更新「期望状态」然后立刻返回,由正在跑的那一轮拾取。这不是吞吐取舍而是正确性要求:对账过程中会 await,两轮交错的对账会在同一行上交叉执行 create 与回滚。dsh 在这里踩过一个三方死锁(回滚等 HMR 拆卸、HMR 等自己的 refresh、refresh 排在正在回滚的 apply 后面),tests/loader.rs 里那条测试就是钉这个的。
patch 里的 name 是断言而不是赋值。 对不上就整条跳过并留一条警告。理由是一层 patch 可能是为另一套组合写的,而 id 撞车时静默地重配了另一个插件,比这条 patch 不生效危险得多。这条也照 dsh。
注册表必须显式建立。 Rust 没有运行时模块注册表(论文 6.4 节把这条列为原生语言的固有差异),所以 name → 构造器 这张表要手写。交换条件是:加一个新组件要动宿主一行代码并重新编译,但已注册组件的开关、重配、插入、移除全都不需要重启——而那正是配置热重载所要的全部。
给动态基质留的三个口子
原生组件全是编译期的:名字是字面量,依赖写成 KeyId::of::<K>(),执行器用库自带的那个。一个 wasm 组件、一段模型现写的代码、一个子进程都不是。
要紧的是基质不需要是内核概念。Registry 已经是 name → 构造器,wasm、script、remote 插件都只是 Component 的不同实现——各自的 apply 去调 wasmtime、QuickJS 或子进程,把注册动作用 steps.step 登记逆。所以适配器属于独立的 crate(wasmtime 一家就带上百个依赖,而这个 crate 现在只有四个),内核只让出三处:
名字可以是运行时的。 Component::name() 返回 &str 而不是 &'static str。静态名字照常写字面量,动态的可以来自 wasm 文件名或直接拼出来。
依赖可以用字符串声明。 KeyId 的同一性依据是 TypeId,运行时凭字符串构造不出来,所以有一张 KeyRegistry 做翻译:
let mut keys = KeyRegistry::new();
keys.add::<Tools>().add::<Shell>();
// guest 的 WIT 导入报上来的字符串,在这里变成能填进 inject 的键
let declared = keys.resolve_all(&["tools", "shell"])?;
这张表跟 Registry 是一对:那张说「哪些组件可以被装上」,这张说「哪些能力可以被按名字声明」。两张都由宿主显式建立,于是guest 说不出宿主没登记的键——能力面的边界就在这里,而不在 guest 的诚实程度上。同名不同键会 panic,因为静默顶掉前一个等于让一个 guest 拿到别人的能力。
执行器可以是宿主自己的。 论文注 2 说任务创建是宿主的职责,现在它是一个可注入的 Spawn。内核本身不含任何 IO,要让子进程或套接字成为一等 fiber,就得把带 IO 的执行器接进来:
struct LocalSetSpawner(tokio::task::LocalSet); // 示意
impl Spawn for LocalSetSpawner {
fn spawn(&self, task: LocalBoxFuture<'static, ()>) {
self.0.spawn_local(task);
}
}
let kernel = Kernel::new(Rc::new(spawner)); // App 是自带执行器的那个便利壳
一处值得知道的实现细节:inertia 是 Shared,谁 await 谁承担 poll,所以 quiesce() 会把它等的那次转换就地驱动完,不依赖宿主执行器是否勤快。丢弃任务的实际后果是「没有任何依赖方去等的转换会一直停在飞行中」,而不是死锁。
wasm 插件
crates/spatiotemporal-wasm 把这三个口子用上了:一个 WebAssembly 组件成为一等 fiber,跟原生组件受同一套规则约束。
// 宿主暴露它愿意让 guest 看见的能力,每一项都要写出投影。
let mut caps = Capabilities::new();
caps.expose::<Db, _>(|db| db.dsn());
// 授予哪些能力由宿主的配置决定,不是 guest 报上来的。
let plugin = WasmPlugin::open("plugins/tool-fs.wasm", Rc::new(caps), vec!["db".into()])?;
let handle = ctx.use_component(Rc::new(plugin));
装上时 guest 的 load 跑一遍,unload 被登记成这个 fiber 的逆——于是它跟原生组件的逆排在同一个 LIFO 序列里,由同一套惯性状态机调度。db 的提供者被换掉,这个 wasm 插件自己会去激活再重新激活,跟原生组件的行为逐字相同。
四个决定值得单独说:
能力由宿主授予,不由 guest 申请。 granted 来自配置,而不是去问 guest 要什么。这样 inject 属于配置的一部分、能被静态检视,不必先把 guest 跑起来才知道它要什么;也让它成为一个授权模型——第三方送来一个 .wasm,是运维决定它能看见什么。授予了一个宿主没暴露的名字就整体拒绝,绝不静默丢掉那一项。
能力在加载时刻取一次快照。 这不是对动态性的妥协,恰好就是这套语义:论文里一个 fiber 的 committed view 在它整段活跃期内是固定的,依赖一变它就被重载。所以「装上时取一次」和「每次调用都去查」在可观察行为上没有差别。快照顺带挡掉了重入——store 里不放 Context,guest 就没法在自己的转换还没结束时回头调内核。这一条还是被类型系统逼出来的:wasmtime_wasi 的 WasiView: Send,而 Rc 不是 Send。
每一项能力都要宿主写出投影。 跨 WIT 边界的值只能是 WIT 类型,而原生 coeffect 是 Rc<dyn Trait>。所以「guest 不能引入新的 coeffect 种类」这条限制,在代码里就落成了 Capabilities 那张投影表——表里没有的东西,guest 连名字都报不出来。
guest 的逆有燃料上限。 论文承诺逆会被调用,可没承诺逆自己规矩。一个 unload 里死循环的 guest 会把整次卸载拖死,而卸载没有别的出路。用燃料而不是墙钟期限,是因为它不需要另起线程去推 epoch,而且确定性——同一个 guest 每次都在同一条指令上耗尽。guests/runaway 就是这么一个赖着不走的 guest,对应的测试能跑完本身就是结论。
测试要真的 .wasm 产物:
cd crates/spatiotemporal-wasm
./scripts/build-guests.sh # 需要 rustup target add wasm32-wasip2
cargo test -p spatiotemporal-wasm
产物不入库——预编译的二进制没法评审也没法复现。测试找不到它会直接失败并让你回来跑这个脚本,而不是静默跳过;跳过会得到一个「绿了但什么都没测」的测试。
脚本插件
crates/spatiotemporal-script 是同一套口子的另一面:guest 不是 .wasm 文件,是一段字符串。这是「模型这一轮现写一段代码、当场装上、用完拆掉」那条自进化路径。
let plugin = ScriptPlugin::from_source(
"dyn-3",
r#"
export function load() { host.log("装上了 " + host.capability("db")); }
export function unload() { host.log("拆掉了"); }
"#,
Rc::new(caps),
vec!["db".into()],
)?;
能力面与 wasm 那份同形:host.log、host.capability,guest 导出 load / unload。语法错误在 from_source 就失败,任何 fiber 都还没被动过——这是 loader「先构造再拆除」在脚本基质上的落点。缺 unload 当成空操作:模型现写的代码经常忘了清理,fiber 仍须能拆掉。
抢占机制不同。QuickJS 没有指令燃料,用的是 Runtime::set_interrupt_handler:引擎定期问一次「该停了吗」,返回 true 就抛出不可捕获的异常。额度是回调次数而不是指令数,所以和 wasm 的 fuel 没有换算关系。对应的测试同样能跑完本身就是结论。
cargo test -p spatiotemporal-script
guest 还可以 host.registerTool(name, description, fn) 和 host.registerLlm(model, fn)(wasm 侧是 WIT 的 register-tool / register-llm + 导出 invoke)。登记本身的逆由宿主持有:脚本把自己的 unload 删掉,工具和 LLM 绑定照样会从桌上消失。
guest 还可 host.callTool(name, argsJson)(script)或 WIT call-tool(wasm),在叶子内编排宿主工具表里的其它 tool——与 LLM 调 tool 同路径,无需 grant fs/shell。
子进程插件
crates/spatiotemporal-process 把 MCP 类可执行 guest 接成一等 fiber。宿主与 guest 用 NDJSON(一行一个 JSON)说 load / invoke / unload;跨边界仍是字符串,能力由宿主授予。
let plugin = ProcessPlugin::open("plugins/mcp-bridge", Rc::new(caps), vec!["markdown".into()])?
.with_args(vec!["--stdio".into()]);
let handle = ctx.use_component(Rc::new(plugin));
unload 不回应时宿主会在墙钟超时后 kill 子进程——子进程没有 wasm 燃料,测试里 guests/runaway 就是用来钉这条的。
测试要真的 guest 产物:
cd crates/spatiotemporal-process
./scripts/build-guests.sh
cargo test -p spatiotemporal-process
Agent 细节
spatiotemporal-agent/README.md 列出完整 cordis.yml 插件表、创造模式审批与 compaction 配置。下面是常用 name 与基质对照(换实现见 cordis.smoke.yml):
配置里的 name | 基质 | 提供 |
|---|---|---|
doc / read-doc | native | markdown 能力,登记「读全文」 |
wasm(outline) | wasm | 抽标题大纲 |
script(cite / stats) | script | 引用原文、统计字数 |
deepseek / echo | native / script | llm(HTTP 或 smoke echo) |
web / probe | native | surface(浏览器或 CLI 探测) |
creation-tools | native | 创造模式元工具(inspect_*、define_script…) |
API Key 只放进环境变量,不要写进 yaml 或提交到 git。
与论文的对应
| 论文 | 这里 |
|---|---|
| $\Gamma_\infty$,一等上下文 | Context |
| $\mathrm{effect}_\Gamma(e)$(算法 1) | Context::effect |
| $\mathfrak{E}^{\mathrm{iter}}_\Gamma$(定义 51) | Steps::step |
| $\mathrm{set}(k,v)$、$\mathrm{get}(k)$(算法 2) | Context::set、Context::lookup |
notify(算法 3) | Runtime::notify |
| $\mathrm{isolate}(k,r)$(定义 29) | Context::isolate |
ctx.use(算法 4) | Context::use_component |
refresh / reload / unload(算法 5) | 同名私有函数 |
| proxy 中介的上下文访问(算法 6) | Context::resolve |
fiber.state(定义 44 的 $\theta$) | State |
fiber.uid、fiber.committed、fiber.inertia | 代际索引、已提交视图、Shared future |
| O-Insert / O-Retire / O-Remove | use_component / FiberHandle::dispose / 卸载完成后移出竞技场 |
| L-Leave 与 L-Unload 上的守卫 | refresh 先标记 Unloading,unload 先排空依赖方 |
| 5.2.1 节的组件加载器 | Loader、Registry、compose |
注 2 的 create_task | Spawn、Kernel(宿主可自带执行器) |
Rust 里的七个设计决定
论文 6.4 节说这套范式与语言无关,但要求宿主语言在两个维度上满足若干条件。Rust 满足它们,只是路径和 TypeScript 不同。
1. 逆用 FnOnce,于是「恢复至多一次」是类型保证。 TypeScript 版需要一个 armed 布尔来防止逆被跑两次;这里所有权移动本身就是那个保证。
2. fiber 存在竞技场里,互相只存 key。 不用 Rc<RefCell<Fiber>>,原因有两个:父子双向引用会构成 Rc 环,而算法 3 的 notify 要在遍历 fiber 的同时改它们,共享可变借用必然在运行时炸。竞技场把两个问题一起消掉,代价是所有访问都要过 with_fiber。
3. uid 用代际索引。 论文要求 uid「新鲜取得且永不复用」,好让被替换的提供者不与替换者混同。slotmap 的代际正是这个语义,于是这条要求由类型系统兜住,而不是靠自增计数器的纪律。
4. 惯性句柄用 Shared 而不是 JoinHandle。 算法 5 第 25 行要求多个依赖方同时等待同一次转换,而 Rust 的 future 是单消费者、JoinHandle 不能克隆。JavaScript 的 promise 可以被任意多次 await,这个差异必须显式处理。
5. 取消是协作式的,绝不 abort。 守卫在每个 step 边界检查 target,语义是「停在边界、保留已累积的逆」。在任意 await 点砍断任务会让已产出的逆丢失,直接违反定理 64 的部分回滚。
6. 效应迭代器改成登记面。 gen / async gen 块至今仍是 nightly,所以不用「yield 出逆」,而是让组件往 Steps 上推——每次 step 就是一个步骤边界。这里比论文更保守一点:先登记逆再检查守卫,因此被中断的那一步同样会被回滚。
7. 没有 Proxy,所以访问走类型化访问器。 论文算法 6 用 JavaScript 的 Proxy 中介 ctx[key],Rust 没有对等物。Context::resolve::<K>() 做同一件事:沿 fiber 链向上走,在第一个已提交该键的 fiber 处授权,未声明就是 Undeclared。论文 6.4 节预言的另一条路——用过程宏把 inject 声明提升到编译期检查——尚未实现,但接口是照着那个方向留的。
另外一处不得不显式化:Rust 没有稳定的 async Drop(async_drop 仍在 nightly,dyn 支持正是当前的阻塞项),而 unload 必须 await 各个逆。所以撤回是显式的 dispose().await,不能挂在 Drop 上。
测试对着定理写
cargo test 跑 54 个(内核与配置层),cargo test -p spatiotemporal-wasm 另跑 7 个,cargo test -p spatiotemporal-script 另跑 8 个。核心库那部分每个都指向论文的一条性质:
| 测试 | 论文 |
|---|---|
inverses_run_in_lifo_order | 定理 61,恢复精确性 |
recovery_is_at_most_once | 算法 1 的自我释放 |
failure_rolls_back_completed_steps | 4.3.4 节失败,L-Raise |
disposing_parent_cascades_to_children | 定义 47,实例化是父级的普通 effect |
transition_is_inert_while_in_flight | 4.3.3 节惯性 + 定理 64 终结恢复 |
activation_follows_the_provider | 定义 26,变化的三种分类 |
switching_provider_reloads_the_consumer | 定义 46,用提供者而非值标识绑定 |
isolate_splits_the_binding | 定义 29,隔离 |
access_is_mediated_by_the_declaration | 5.1.4 节 + 6.3 节,基于能力的访问控制 |
dependency_is_readable_during_own_teardown | 定理 63,coeffect 定序 |
teardown_cascades_from_the_far_end | 定理 66,终止性 |
insertion_order_does_not_affect_the_end_state | 定理 68,合流性 |
dependency_cycle_leaves_both_inactive | 6.5 节,环只是永不被满足 |
其中定理 63 那条最值得看:一个「因为依赖走了才被拆解」的组件,在自己的拆解过程中仍然能读到那个正在离去的依赖。论文说这条性质由三行代码的位置保证,测试就是在钉这三行的位置。
配置层那部分(tests/loader.rs、tests/config.rs)钉的是对账语义:
| 测试 | 钉的是 |
|---|---|
unchanged_rows_are_left_alone | 增量对账的全部意义:改一行不该让整棵树重启 |
a_config_change_reloads_only_that_row | 只有那一行动,而且先拆后装 |
an_unknown_name_fails_before_touching_anything | 先构造再拆除 |
a_failing_row_rolls_back_to_the_previous_tree | 补偿事务 |
a_row_waiting_for_its_dependency_is_not_a_failure | 依赖不可用只是非活动,是有效的 pending 配置项 |
swapping_a_provider_row_reloads_its_consumers | 改一行配置换掉实现,消费者自己跟上 |
concurrent_applies_are_serialized_and_coalesced | 串行化是正确性要求 |
a_name_in_a_patch_is_an_assertion_not_an_assignment | patch 的 name 是断言 |
tests/dynamic.rs 钉的是给动态基质留的那三个口子:运行时名字能一路带到 fiber 上、按字符串声明的依赖与 KeyId::of 声明的行为完全一致(包括提供者走了就去激活)、以及转换只在宿主真的驱动任务之后才推进。
crates/spatiotemporal-wasm/tests/wasm.rs 钉的是跨语言边界之后这些性质还在:
| 测试 | 钉的是 |
|---|---|
a_wasm_component_lives_and_dies_like_any_fiber | guest 的 unload 就是这个 fiber 的逆 |
the_name_comes_from_the_file | 运行时名字的实际用处 |
only_granted_capabilities_are_visible | 能力边界不在 guest 的诚实程度上 |
granting_an_unexposed_capability_is_refused | 全有或全无,绝不静默降级 |
a_wasm_plugin_waits_for_its_dependency | 定义 26 那三种分类对 wasm 插件同样成立 |
a_runaway_inverse_is_bounded | 卡住的逆会被燃料抢占,卸载仍然完成 |
the_adapter_reports_errors_as_component_failures | wasm 侧的问题以组件失败出现,不是运行时崩溃 |
crates/spatiotemporal-script/tests/script.rs 钉的是同一组性质在字符串 guest 上还在,外加一条脚本特有的:
| 测试 | 钉的是 |
|---|---|
a_script_lives_and_dies_like_any_fiber | guest 的 unload 就是这个 fiber 的逆 |
the_name_is_whatever_the_host_called_it | 运行时名字,模型现写的代码没有文件名 |
only_granted_capabilities_are_visible | 能力边界不在 guest 的诚实程度上 |
a_script_waits_for_its_dependency | 定义 26 对脚本同样成立 |
a_runaway_inverse_is_bounded | 卡住的逆会被 interrupt handler 抢占 |
the_adapter_reports_errors_as_component_failures | 语法错误在构造时失败,fiber 还没被动过 |
a_script_without_load_fails_as_a_component | 没有 load 就是普通的组件失败 |
还没有做的
这是 0.4,范围到论文 5.1 节加 5.2.1 节。以下都是明确的缺口,不是疏漏:
- 单线程。
Rc+RefCell+LocalPool。多线程版本要把所有 disposer 与 apply 的返回 future 加上Send + 'static,组件作者会明显感到约束。 Context::effect在调用点跑到完成,不作为并发任务。所以「飞行中的 effect 被 dispose 中止」这一支没实现;组件层(apply)的守卫与部分回滚是完整的。- 没有拦截(定义 31 的
@@intercept)。访问控制元数据与细粒度策略还没有对应物。 - 配置树是平的。 没有 group/嵌套子树,因此
insert只能追加到顶层。dsh 的组合包用嵌套行来把一组能力归到一个宿主行之下,那需要「一个组件持有自己的子对账器」,还没做。 - 没有热模块替换(5.2.2 节)。这在 Rust 里没有好答案,见论文 6.4 节:原生代码没有模块注册表,
dlopen/dlclose会撞上TypeId跨编译单元不一致、卸载时悬垂 vtable 等问题;wasm 组件模型更干净但要付序列化边界的代价。注意这跟配置热重载是两件事——后者已经在了,而且它才是长驻进程真正需要的那件(dsh 在两个发行形态里都把宿主端模块 HMR 关掉了,却给配置层补挂一个只看配置的 watcher)。 - 没有服务代理(6.2 节),因此没有负载均衡、滚动更新与跨进程调用。
- 没有过程宏,
inject仍是运行时声明。 - wasm / 脚本 / 子进程适配器只到叶子服务。 guest 可以登记工具或把自己登记成 LLM,还不能引入新的 coeffect 种类给原生插件消费,也不能收事件。子进程基质在
crates/spatiotemporal-process;MCP 桥接仍要宿主自己写 guest 可执行文件。
许可
MIT。
这是对论文的一次独立实现,与论文作者、Cordis 项目均无隶属关系。概念、算法与定理编号均出自该论文(Yifan Shi、Wei Zhang、Tianyi Cui)。想先把演算本身搞懂的话,有一门配套的通俗课:可组合性课堂。