跳到主要内容

活的系统:发现、运行时扩展与可观测性

30 秒导读: 前面几章把 iii 当"通信引擎"讲——worker 连上来、注册 function、被调用。 本章讲它更独特的一面:iii 是一个活的系统。系统的当前状态(有哪些 worker、哪些 function、哪些 trigger 在订阅什么)本身就是一组可以被调用的 engine::* function; 新能力可以在运行时被孵化进来、旧能力可以被热替换;而每一次调用都带着一条能跨进程边界 连续下去的 trace。合起来,这就是一个 agent 能自己走完 add worker → discover → call → trace 闭环所需的全部机器接口。

本章聚焦"系统级活性",不重复 03 章里"一次调用怎么走"的细节, 也不重复 04 章的 trigger 分发。读完你应能说清:agent 怎么 问系统"你现在都有什么"、怎么在运行时给系统"长出"新东西"、以及这套东西为什么全程可观测


1. 为什么这章重要:agent 需要的不是 API,是"活目录"

先建立直觉。一个普通的 RPC 框架给你一份编译期固定的接口清单:你提前知道有哪些方法, 调就是了。但一个 agent 运行时的要求不一样——

  • agent 事前不知道系统里有什么能力(能力会在运行时增删);
  • agent 需要系统"你现在都有什么、每个能力吃什么参数";
  • agent 可能需要自己动手给系统加一个当前缺的能力,然后立刻用上;
  • 事后 agent 要能回看这次调用到底发生了什么。

iii 把这四件事都做成了机器接口,而且用的是和普通业务调用完全相同的 function 调用通道。 这就是本章的主角——一个叫 iii-engine-functions内省 worker,它把"系统自己"暴露成一批 engine::* function(engine/src/workers/engine_fn/mod.rs:1354 register_worker!("iii-engine-functions", …, mandatory))。

一句话类比: 别的框架给你一本印死的电话簿;iii 给你一个会实时更新、还能自己往里 登记新号码的前台总机——而且"问总机"这个动作,本身就是打给总机的一通电话。

这条闭环的四个环节,分别对应本章四节:

┌───────────────────────────────────────────────────────────┐
│ agent 的一次自主扩展闭环 │
│ │
│ ① add ② discover ③ call ④ trace │
│ ────────▶ ─────────────▶ ─────────────▶ ──────────▶ │
│ 运行时孵化 engine::* 普通 function W3C │
│ 一个 worker 内省函数问系统 调用新能力 traceparent │
│ (§4 §5) (§2) (见 03 章) 贯穿 (§6) │
│ ▲ │ │
│ └──── functions-available 广播通知订阅方 ◀──────┘ │
│ (§3:系统"活"起来的心跳) │
└───────────────────────────────────────────────────────────┘

怎么读这张图: 从左到右是一次闭环;下方那条回边是"系统状态一变,就广播给关心的人", 这是让整个目录"活"起来的心跳。§2 讲怎么问,§3 讲怎么被通知,§4/§5 讲怎么加,§6 讲怎么追。


2. 发现:把"系统自己"做成可调用的 function

这节讲第一件事——agent 怎么问系统"你现在都有什么"

2.1 一个内省 worker,一批 engine::* function

iii 引擎内部挂着一个强制(mandatory)worker EngineFunctionsWorker。它和别的 worker 没有本质区别——只不过它注册的 function 描述的是"引擎自己的当前状态"。这些 function 用一个 Rust 宏块统一声明(engine/src/workers/engine_fn/mod.rs:1034 #[service(name = "engine")]), 每个 #[function(id = "engine::…")] 就是一个可被调用的内省接口。

按"问什么"分成四组,外加两个动作型接口:

function id干什么(白话)源码符号
engine::functions::list列出所有 function,带归属 worker 名functions_list(mod.rs:1058)
engine::functions::info查单个 function:入参/出参 schema、归属、绑定的 triggerfunctions_info(mod.rs:1132)
engine::triggers::list列出所有 trigger 类型(HTTP/cron/…)triggers_list(mod.rs:1172)
engine::triggers::info查单个 trigger 类型:配置 schema、当前实例数triggers_info(mod.rs:1206)
engine::registered-triggers::list列出所有已绑定的订阅实例(哪个 trigger 连着哪个 function)registered_triggers_list(mod.rs:1224)
engine::registered-triggers::info查单个订阅实例,反查出完整的 trigger + function 详情registered_triggers_info(mod.rs:1272)
engine::workers::list列出连着引擎的 workerworkers_list(mod.rs:1290)
engine::workers::info查单个 worker 的全貌(它的 function、trigger 类型、订阅、指标)workers_info(mod.rs:1317)
engine::channels::create创建一对流式通道(streaming channel)create_channel(mod.rs:1040)
engine::workers::registerworker 握手时上报自己的元数据register_worker(mod.rs:1335)

一句话:"list/info × functions/triggers/registered-triggers/workers" 是一张规整的内省矩阵—— list 便宜地铺开全貌,info 深挖一个对象。这正是授权标准 §1.C 的「渐进式披露」在 运行时的翻版:agent 先 list 低成本判断相关性,再对感兴趣的那一个 info

2.2 为什么 info 对 agent 特别关键:它带 schema

engine::functions::info 返回的不只是名字,而是这个 function 的入参和出参 JSON schema (mod.rs:191 struct FunctionDetail,字段 request_schema / response_schema)。真实实现里, 这两份 schema 直接取自 function 注册时登记的格式:

// engine/src/workers/engine_fn/mod.rs:649 build_function_detail —— 摘要
Some(FunctionDetail {
function_id: function_id.to_string(),
description: function._description.clone(),
request_schema: function.request_format.clone(), // 入参 schema
response_schema: function.response_format.clone(), // 出参 schema
registered_triggers: self.registered_trigger_refs_for_function(function_id), // 谁在触发它
..
})

这一步的意义:agent 不必事先知道 orders::validate 长什么样,它可以先 functions::info 拿到 schema,据此构造一个合法的调用参数,再去 call。这就是"发现即可用"——发现的产物 直接是调用所需的契约。

2.3 两个必须知道的细节:内部过滤 与 归属解析

(1) 默认藏起引擎内部 function。 engine::* 这类内部实现默认不出现在 functions::list 里, 免得污染 agent 的视野——但有一个精心开的口子:engine::queue::* 属于公开的队列/死信 API, agent 合法需要发现它,所以被特意放行(mod.rs:1065-1086 functions_listretain 逻辑)。 另外,任何被打上 metadata.internal == true 标签的 handler(如 iii-http::on-config-change 这种"配置变更 fan-out 目标")也默认隐藏。想看全部,传 include_internal: true

(2) "这个 function 归哪个 worker" 是算出来的。 function id 形如 state::get,但归属的 worker 名并不直接存在 function 上——它由一个索引现算:先扫运行时 worker、再扫 WS 连接注册表, 先到先得(mod.rs:433 function_owner_index;解析函数 mod.rs:455 worker_name_for_function_id, 兜底取 :: 前第一段)。

诚实提醒(源码里明写的信任边界): worker 名是 worker 自报的,引擎当前不做身份认证。 一个 worker 若冒用另一个 worker 的名字,它的 trigger 类型会在内省视图里被并入那个名字下 ——这一点在 build_worker_detail 的长注释里被明确标注为"identity/auth 的活,不是归属逻辑 的活"(mod.rs:806-823)。agent 在信任内省结果时应知道这个前提。


3. 心跳:functions-available / workers-available 广播

上一节是 agent 主动拉(pull)。但"活的系统"还需要(push)——当目录发生变化时, 关心的人应该被通知,而不用一直轮询。iii 用两个内建的引擎 trigger 类型做这件事:

常量触发时机源码
engine::functions-availablefunction 注册表发生变化时TRIGGER_FUNCTIONS_AVAILABLE(mod.rs:26)
engine::workers-availableworker 连接/上报元数据时TRIGGER_WORKERS_AVAILABLE(mod.rs:27)

这两个 trigger 类型在内省 worker 初始化时就注册进引擎(mod.rs:948 initialize,里面 register_trigger_type 两次)。任何 worker 都可以订阅它们——订阅后,系统一变,它就收到事件。

3.1 function 广播:靠 5 秒轮询一个"函数表指纹"

function 变化怎么被侦测到?靠一个后台任务,每 5 秒算一次函数表的指纹,变了就广播:

// engine/src/workers/engine_fn/mod.rs:970 start_background_tasks —— 主干摘要
let mut current_functions_hash = engine.functions.functions_hash();
loop {
// 每 5 秒:
let new_functions_hash = engine.functions.functions_hash();
if new_functions_hash != current_functions_hash { // 指纹变了 = 有增删
current_functions_hash = new_functions_hash;
let functions = worker_module.list_function_summaries().await;
// 向所有订阅 functions-available 的 trigger,call 它们绑定的 function,
// 送去 { event: "functions_changed", functions: [...] }
}
}

这个"指纹"就是当前所有 function id 排序后的字符串哈希(engine/src/function.rs:87 functions_hash——收集 keys、排序、format!("{:?}", …))。够糙但够用:只关心"集合变没变", 不关心顺序。广播的动作是对每个订阅者并发 engine.call(spawn 一个 tokio 任务)。

为什么是轮询而不是事件回调? function 的注册来自五湖四海(WS worker、内置 worker、热重载), 集中在一个哈希上做差分,比在每个注册点埋回调更简单、更难漏。代价是最多 5 秒的通知延迟 ——对"发现新能力"这个场景完全可接受。

3.2 worker 广播:握手即通知

worker 侧不同:它是事件驱动的。当一个 worker 调 engine::workers::register 上报元数据时, register_worker 会顺手点燃 workers-available:

// engine/src/workers/engine_fn/mod.rs:1335 register_worker —— 摘要
self.register_worker_metadata(input).await; // 落库元数据
let data = json!({ "event": "worker_metadata_updated", "worker_id": worker_id });
self.engine.fire_triggers(TRIGGER_WORKERS_AVAILABLE, data).await; // 点燃广播

fire_triggers 本身在引擎层(engine/src/engine/mod.rs:1400):它筛出订阅该类型的 trigger, 逐个 spawn 出一个带 trigger span 的任务去 call 订阅者的 function——注意这里已经在为可观测性 埋点了(每个 fan-out 都挂在一个 trigger <fn> span 下),这条线我们在 §6 收束。

顺带一提:worker 上报的 name / description不可信自由文本,会先过一道消毒——剥掉 控制字符、剥掉 Trojan-Source 类的 Unicode 双向覆写字符(CVE-2021-42574)、并截断长度 (mod.rs:47 sanitize_worker_text,mod.rs:42 is_unsafe_display_char)。因为这些字段最终会被 渲染到控制台、CLI、甚至喂给 LLM agent,消毒是在入口边界做的。

3.3 闭环合上了

把 §2 和 §3 合起来看,agent 的发现闭环是这样的:

新 worker 连上 ──▶ engine::workers::register ──▶ 点燃 workers-available
│ │
▼ ▼
它注册的 function 进入 functions 注册表 订阅的 agent 收到 worker 事件

▼ (≤5s 后)
functions_hash 变化 ──▶ 点燃 functions-available ──▶ 订阅的 agent 收到 { functions_changed }


agent 用 engine::functions::info 拿 schema ──▶ call

agent 既可以主动问(§2),也可以被动等通知(§3)。两条路殊途同归,都终结于"拿到一个 可调用的、带 schema 的新能力"。


4. 运行时孵化:引擎怎么把一个新 worker "生"出来

前两节假设 worker "已经在那了"。这节讲更硬核的一步:当系统还没有某个能力时,怎么在运行时 把承载它的 worker 进程孵化出来。这对应闭环里的 ① add

4.1 引擎只管"生和看",不管"装"

设计上有一条清晰的分工:引擎只负责子进程的生命周期(拉起、探活、停掉),而"从哪下载二进制、 怎么跑 OCI 镜像"这些全部甩给外部 CLI iii-worker(engine/src/workers/registry_worker.rs:7 模块文档明说)。引擎孵化一个非内置 worker,本质就是 spawn 一个命令:

// engine/src/workers/registry_worker.rs:115 spawn_args —— 生成的 argv
// 结果形如: iii-worker start <name> --port <port> [--no-wait] [--config <path>]

真正的 spawn 在 ExternalWorkerProcess::spawn(registry_worker.rs:249):它解析 iii-worker 二进制、把子进程 stdout/stderr 重定向到 ~/.iii/logs/<name>/、写一份临时 config,然后 spawn。 一个关键细节是把整棵进程树锚定到本引擎:

// registry_worker.rs:312 —— spawn 前设置环境变量
cmd.env("III_ENGINE_PID", std::process::id().to_string());

因为 iii-worker start 会**分离(detach)**出真正的 worker(VM / 二进制 / 看护 sidecar)然后自己退出, 父子进程之间的"生命线管道"跨不过这条链——于是改用 III_ENGINE_PID 顺着继承的环境往下传,让分离出去的 worker 自己"看着"引擎:引擎一死,它们自杀。没有它,一句 killall -9 iii 会留下一地孤儿 worker (registry_worker.rs:304-312 注释)。

4.2 探活:pidfile + 宽限窗 + "曾经活过"闩锁

孵化出去的是个分离进程,tokio 的 Child 句柄立刻就失效了(因为 iii-worker start 早退了)。 那引擎怎么知道 worker 到底活没活?答案是读 pidfile(registry_worker.rs:403 is_alive)。这是本 模块最精巧的部分,三层机制叠在一起:

is_alive() 判定流程(从上到下,命中即停)
┌────────────────────────────────────────────────────────────┐
│ ① 探两个候选 pidfile,PID 对信号 0 有响应? │
│ ~/.iii/managed/<name>/vm.pid (VM/OCI worker) │
│ ~/.iii/pids/<name>.pid (二进制 worker) │
│ ──是──▶ 活;并【闩锁】was_ever_alive = true │
│ ──否──▼ │
│ ② 曾经活过(was_ever_alive)? │
│ ──是──▶ 死(pidfile 没了 = 确定死亡,不再宽容) │
│ ──否──▼ │
│ ③ 还在 30s 宽限窗内(SPAWN_GRACE)? │
│ ──是──▶ 当作"还在启动",算活 │
│ ──否──▶ 死 │
└────────────────────────────────────────────────────────────┘

三层各自防一类 bug(全部来自源码注释与回归测试):

  • 双候选 pidfile(registry_worker.rs:54 pid_file_candidates):VM worker 和二进制 worker 把 pid 写在不同地方,都要探。
  • 宽限窗(registry_worker.rs:208 SPAWN_GRACE = 30s):冷启动一个 VM 可能要几十秒才写出 pidfile,这段时间没 pidfile 不能判死,否则会触发"重启风暴"。
  • was_ever_alive 闩锁(registry_worker.rs:185 字段 / 416 置位 / 428 判定):一旦见过它活, 之后 pidfile 消失就是确定死亡。这修的是 iii worker add --force 的 bug——该命令会杀掉 worker 并删掉 pidfile;没有闩锁的话,宽限窗会把这次"被杀"误判成"还在启动",于是热重载认为 "没变化",重启永远不发生。有了闩锁,"活过之后又没了"被诚实地报成死。

探活还有一处安全加固:读 pidfile 用 O_NOFOLLOW + 校验文件属主是当前 euid + 必须是普通文件 (registry_worker.rs:79 read_pid_hardened)。这防的是本地用户植入 pidfile 符号链接攻击—— 否则攻击者能把 worker 的 pidfile 软链到 /proc/1/sched 之类,骗过 kill(pid,0),让 is_alive 永远返回真,真 worker 反而永不重启。

4.3 一个后台轮询器把闩锁"主动"点亮

is_alive被调用时才探活的(通常由热重载触发)。但如果一个 worker 从生到死都没赶上一次热重载, 闩锁就永远不会被点亮。为此 spawn 时会额外起一个后台轮询器(registry_worker.rs:362),每 500ms probe_pidfile_alive 一次,一旦看到 pidfile 就把 was_ever_alive 点亮然后退出。它故意没有超时 ——冷缓存拉 OCI 镜像可能几十分钟,任何超时都会在慢启动时误判死亡(registry_worker.rs:336-361 注释)。


5. 热重载:在运行时安全地换掉一批 worker

孵化解决了"从无到有"。热重载解决"从有到新"——运行时改一次 config,系统就把该加的加、该删的删、 该换的换,不用停机。核心在 ReloadManager::reload(engine/src/workers/reload.rs:343),四步流水线:

config 文件变


① parse_and_normalize ─▶ ② diff_entries ─▶ ③ promote_dead_unchanged ─▶ enforce_guards ─▶ ④ commit
解析+补齐强制worker 新旧配置分四桶 把"配置没变但已死"的 拒绝删除 按序执行:
(reload.rs:121) added/removed/ worker 从 unchanged 挪到 强制 worker 改→删→加
changed/unchanged changed(强制重启) (reload.rs:199) (reload.rs:224)
(reload.rs:84) (reload.rs:161)

5.1 diff 的四个桶

diff_entries(reload.rs:84)是个纯函数,把新配置对着旧配置分成四类:added / removed / changed / unchanged。相等性比较 worker 的 name+image+config(结构化比较)。commit (reload.rs:224)严格按 改→删→加 的顺序执行,每个 changed/removed 都先 destroy 旧的再起新的。

5.2 让"活性"反哺"一致性":promote_dead_unchanged

这是热重载和 §4 探活的接合点,也是本章"活"字的点睛之笔。diff_entries 只看配置字段——如果 一个 worker 的配置一字没变,它会落进 unchanged 桶,commit 对它什么都不做。但配置没变不等于 进程还活着iii worker add --force 正是这种情况:它删掉 worker、重写出一个结构完全相同的 配置条目,于是 diff 认为"没变化",而 worker 其实已经死了。

promote_dead_unchanged(reload.rs:161)补上这一刀:它遍历 unchanged,对每个还在跟踪的 worker 问一句 is_alive()(就是 §4.2 那套探活),死的就从 unchanged 挪进 changed,让 commit 去重启它:

// engine/src/workers/reload.rs:174 —— 主干
for name in diff.unchanged.drain(..) {
let is_alive = match running_by_name.get(name.as_str()) {
Some(rw) => rw.worker.is_alive().await, // ← 复用 §4.2 探活
None => true, // 没跟踪 = 无从判断,放过
};
if is_alive {
still_unchanged.push(name);
} else if let Some(entry) = new_by_name.get(name.as_str()) {
diff.changed.push((*entry).clone()); // 死的 → 提升为 CHANGED,强制重启
promoted.push(name);
}
}

于是配置层的"没变"被运行时的"其实已经死了"否决——系统据实自愈。

5.3 作用域回滚:worker 走了,它写进全局注册表的东西也得走

热重载还有个隐患:一个 worker 在活着时会往引擎全局注册表里写一堆 function。如果它被 destroy 了 却不清理,这些 function 会变成"幽灵"——engine::functions::list 还能看到它们,一 call 就失败, 直接破坏 §2 发现闭环的可信度。

iii 用**作用域(scope)**解决:每个 worker 注册 function 时,引擎开一个作用域,把它写进去的 function id 记账下来(reload.rs:29 WorkerRegistrations);worker 被销毁时,按这份账本 精确回滚:

// engine/src/workers/reload.rs:317 start_worker —— 记账
engine.begin_worker_scope(&entry.name); // 开始记账(engine/mod.rs:345)
worker_arc.register_functions(engine.clone()); // 这中间注册的 fn 都被记下
let registrations = engine.end_worker_scope(); // 收账(engine/mod.rs:357)

// engine/src/workers/reload.rs:243 commit 里销毁旧 worker 时 —— 回滚
engine.remove_worker_registrations(&old.registrations); // 精确删掉它写过的 fn(engine/mod.rs:376)
engine.remove_runtime_worker(&old.entry.name);

这保证了一条不变量:内省视图永远只反映当前真实存活的能力。发现闭环之所以可信,靠的正是这套 "谁写的谁负责收"的作用域记账。

此外,enforce_guards(reload.rs:199)会拒绝删除强制 worker——像 iii-engine-functions (§2 那个内省 worker)本身就是 mandatory 的,不能被一次手滑的配置改动删掉,否则整个发现能力就没了。


6. 可观测性:让闭环的"trace"环节真的连得起来

闭环的最后一环是 ④ trace——调用完能回看。这一节讲 iii 为什么能让一条 trace 跨越进程边界 连续下去,以及可观测性数据怎么又流回到 §2 的发现视图里。

6.1 traceparent / baggage:协议里的一等公民

跨进程追踪的难点是:引擎在一个进程里,被调的 worker 在另一个进程、甚至另一种语言里,中间隔着 一条 WebSocket。要让两边的 span 归到同一条 trace,必须把追踪上下文随消息传过去。iii 的做法是把 W3C traceparent + baggage 直接做进调用消息(见 01 章的协议) 和进程内的 Invocation 结构:

// engine/src/invocation/mod.rs:33 Invocation —— 每次调用都携带追踪上下文
pub struct Invocation {
pub id: Uuid,
pub function_id: String,
pub traceparent: Option<String>, // W3C 追踪上下文
pub baggage: Option<String>, // 跨切面上下文
..
}

引擎收到调用时,用这两个头恢复出父上下文并挂到 span 上(engine/src/telemetry.rs:37 SpanExt::with_parent_headers,底层 engine/src/workers/observability/otel.rs:1196 extract_context, 从 carrier 里先解 traceparent 再合并 baggage)。调用要转发给外部 worker 时,又反向注入回出站消息:

// engine/src/worker_connections/traits.rs:101 handle_function —— 转发前把上下文注回消息
let traceparent = inject_traceparent_from_context(&otel_context);
let baggage = inject_baggage_from_context(&otel_context);
self.channel.send(Outbound::Protocol(Message::InvokeFunction {
invocation_id, function_id, data: input,
traceparent, baggage, action: None, // ← 顺着 WS 传给另一语言的 worker
})).await;

于是一条 trace 能一路穿过 引擎 → WebSocket → 另一进程的 worker → 它调的下一个 function,不断链。

6.2 一个精巧决策:引擎主动"让位",不抢 span

这里有个反直觉但很妙的取舍。对于转发给外部 worker 的调用,引擎故意不发自己的 call span (engine/src/invocation/mod.rs:102 suppress_span)。原因:那个 worker 会发它自己的 call span (service = 那个 worker),引擎再发一个就成了同一次逻辑调用的跨服务重复。所以引擎让位,让 worker 的 span 当权威;但它仍然把调用方的上下文extract_context 附着到 dispatch(invocation/mod.rs:140 dispatch_cx),这样 worker 的 span 能正确嵌套在调用方 trace 之下,而不是孤立成一条新 trace。

内置 function(state::* / engine::* 这类)则默认更进一步:它们在引擎内高频执行、trace 价值低、 量大,所以默认不发 span(除非 III_OTEL_TRACE_BUILTINS=true),但依然沿用调用方上下文,让它们 触发的 fan-out(如 state 写触发的 trigger)不至于断链。这套"该发才发"的取舍,就是 iii 追踪信噪比高的原因。

6.3 采样、日志、指标:三条数据回流

追踪只是可观测性的一支。iii 在 observability 模块里还铺了另外两条,并做了统一初始化 (otel.rs:909 init_otel,一进来就装上 TextMapCompositePropagator——TraceContext + Baggage 两个 传播器,otel.rs:921):

支柱做什么源码锚点
采样令牌桶限流 + 按 service/operation 规则采样,避免 trace 淹没sampler.rs:20 TokenBucketAdvancedSampler
日志tracing 事件收进内存存储,并 fan-out 给 trace trigger 订阅方logs_layer.rs LogFieldVisitor、otel.rs:2485 InMemoryLogStorage
指标每个 worker 的 CPU/内存等快照存起来,可按 worker.id 取回metrics.rs get_worker_metrics_from_storage

最后一条尤其点题:worker 的最新指标快照会流回 §2 的发现视图engine::workers::info 返回的 WorkerDetailEnvelope 里带一个 latest_metrics 字段(mod.rs:304),它在 build_worker_detail 里从指标存储取出(mod.rs:773 get_worker_metrics_from_storage)。也就是说——agent 用同一个内省接口, 既能发现一个 worker 有哪些能力,也能顺带看到它此刻的健康状况。发现与可观测,收束在同一个 function 上。


7. 巧妙之处小结

带走这几条"活的系统"的设计精华:

  • 系统状态即 function。 iii 没有为"内省"另开一套带外 API,而是把它做成一批普通的 engine::* function(mod.rs:1034 #[service(name="engine")])。agent 问系统用的是和调业务能力完全一样的 通道——这是"add→discover→call→trace 全是机器接口"能成立的根。

  • 发现的产物直接可用。 functions::info 带 request/response schema(mod.rs:649),发现即拿到调用契约。

  • pull 与 push 双通道。 主动 list/info(§2)与被动订阅 functions-available/workers-available 广播(§3)并存;function 变化靠一个糙但稳的指纹轮询(function.rs:87 functions_hash)侦测。

  • 活性反哺一致性。 探活(registry_worker.rs:403 is_alive,pidfile + 宽限窗 + 闩锁三层)不只用于 监控,还被热重载的 promote_dead_unchanged(reload.rs:161)拿去否决"配置没变"的误判,让系统据实自愈。

  • 作用域记账保证内省不撒谎。 worker 销毁时按 WorkerRegistrations 精确回滚它写过的 function (reload.rs:29 / engine mod.rs:376),内省视图永远只反映真实存活的能力。

  • 追踪是协议的一等公民。 traceparent/baggage 做进调用消息与 Invocation (invocation/mod.rs:33),配合"引擎为外部 worker 让位、不发重复 span"(invocation/mod.rs:102)的取舍, 一条 trace 能干净地跨 WS 边界连续下去。


8. 边界与局限(诚实)

  • worker 身份不认证。 worker 名自报,冒名会导致内省归属被并入他人名下;源码把这明确划为 identity/auth 的待办,而非归属逻辑的 bug(mod.rs:806-823)。agent 信任内省结果时须知此前提。

  • function 广播有 ≤5s 延迟。 靠 5 秒轮询指纹(mod.rs:970),不是实时事件。发现新能力可接受, 对亚秒级敏感的场景不适用。

  • 探活有一个已知窗口竞态。 后台轮询器 500ms 一跳,若 worker <1s 内启动用户恰在此窗口内 iii worker add --force,闩锁可能没点亮,下次重载会跳过重启(registry_worker.rs:348-361 "KNOWN RACE"注释,列了两个后续可选修法)。

  • 孵化强依赖外部 iii-worker 二进制。 引擎只管子进程生命周期,下载/OCI/注册表解析全在 iii-worker 里;找不到该二进制就无法孵化(registry_worker.rs:250 报错)。

  • 探活的安全加固仅限 unix。 O_NOFOLLOW + euid 属主校验只在 #[cfg(unix)] 生效 (registry_worker.rs:79);非 unix 走的是朴素读取(registry_worker.rs:103)。


9. 代码地图(导航索引)

符号名比行号抗漂移,优先用符号 grep 定位。

主题文件路径符号 / 锚点
内省 worker 本体 + engine::* 服务块engine/src/workers/engine_fn/mod.rs:1034#[service(name = "engine")]register_worker!("iii-engine-functions", … mandatory)(:1354)
functions 内省engine/src/workers/engine_fn/mod.rs:1058functions_listfunctions_info(:1132)、build_function_detail(:649)
triggers / registered-triggers 内省engine/src/workers/engine_fn/mod.rs:1172triggers_listregistered_triggers_list(:1224)、registered_triggers_info(:1272)
workers 内省(含 latest_metrics)engine/src/workers/engine_fn/mod.rs:1290workers_listworkers_info(:1317)、build_worker_detail(:748)
归属解析 / 内部过滤engine/src/workers/engine_fn/mod.rs:433function_owner_indexfunctions_list retain(:1065)
广播常量 + 触发engine/src/workers/engine_fn/mod.rs:26TRIGGER_FUNCTIONS_AVAILABLE / TRIGGER_WORKERS_AVAILABLE(:27)、fire_triggers(:384)
function 变化侦测(指纹轮询)engine/src/workers/engine_fn/mod.rs:970start_background_tasks;functions_hash(engine/src/function.rs:87)
worker 元数据消毒engine/src/workers/engine_fn/mod.rs:47sanitize_worker_textis_unsafe_display_char(:42)
引擎层 fan-out(带 span)engine/src/engine/mod.rs:1400fire_triggers
运行时孵化子进程engine/src/workers/registry_worker.rs:249ExternalWorkerProcess::spawnspawn_args(:115)
探活三层机制engine/src/workers/registry_worker.rs:403is_aliveSPAWN_GRACE(:208)、was_ever_alive(:185)
pidfile 安全加固engine/src/workers/registry_worker.rs:79read_pid_hardenedpid_file_candidates(:54)
热重载流水线engine/src/workers/reload.rs:343ReloadManager::reloaddiff_entries(:84)、commit(:224)
活性反哺 / 强制守卫engine/src/workers/reload.rs:161promote_dead_unchangedenforce_guards(:199)
作用域记账 / 回滚engine/src/workers/reload.rs:29WorkerRegistrations;begin/end_worker_scope(engine/src/engine/mod.rs:345/:357)
追踪上下文传播engine/src/invocation/mod.rs:33Invocationhandle_invocation(:76)、suppress_span(:102)
上下文注入(转发出站)engine/src/worker_connections/traits.rs:78handle_function(inject_traceparent_from_context:102)
OTel 初始化 / 传播器engine/src/workers/observability/otel.rs:909init_otelextract_context(:1196)
span 父上下文扩展engine/src/telemetry.rs:37SpanExt::with_parent_headers
采样限流engine/src/workers/observability/sampler.rs:20TokenBucketAdvancedSampler
内存日志 / trace 广播engine/src/workers/observability/otel.rs:2485InMemoryLogStorage

延伸阅读(同组各章,相对链接): index · 01 三原语与线上协议 · 02 引擎中枢:注册表与消息分发 · 03 一次调用的一生 · 04 触发器体系与内置 worker · 06 接入引擎:SDK 与 worker 握手