运行期不变量
只有拥有**两个可独立观察、可能分叉的运行期事实**的包,才把自己的关系检查注册进共享注册表;
没有这种关系的包不创建 `Invariant.cs`,也不挂 installer。违约时抛出带包名归属的错误,
让"谁的契约被谁违反了"在运行期就能定位,而不是等到数据已经写坏。
对应实现:src/Tether.Invariants/。设计时还有一个离线的对照面:
形式化建模用 Lean 4 对核心语义穷举交错并产出反例报告。
为什么单独一个包
产品层的包彼此不可见,只通过服务接口相遇。这带来一个问题:跨包的时序契约没有天然的归属地。比如”turn/end 不能在还有 step 打开时出现”——这是会话包的契约,但违反它的往往是 Agent 循环包。
做法是把检查的注册集中,检查的所有权留在各包:
public sealed class SessionInvariantPlugin : Plugin
{
public override IReadOnlyList<Type> Inject { get; } = [typeof(IInvariantRegistry)];
protected override void Apply(Context context)
{
var registry = context.Require<IInvariantRegistry>();
// 注册即回收:插件卸载时检查一并摘除
context.Effect(registry.Register("Tether.Core.Session", Install).DisposeAsync);
}
private static void Install(Context context, InvariantFail fail)
{
// context 是本注册独占的子上下文
// fail 已绑定包名,调用即抛 InvariantError
}
}
因此 Tether.Invariants 在依赖图里是按需引用的横切关注点:只有发布真实 runtime invariant 的包引用它;注册表本身只依赖 Cordis。
两个委托
// 抛出带包名归属的违约
public delegate void InvariantFail(string message);
// 把一个包的检查装进该注册独占的子上下文
public delegate void InvariantInstaller(Context context, InvariantFail fail);
InvariantFail 收到的消息不带前缀,最终异常消息由注册表拼成 invariant violated by "包名": 消息。
注册表
IAsyncDisposable Register(string packageName, InvariantInstaller installer);
几条硬性行为:
- 包名必须非空白,且不能包含任何空白字符,否则抛
ArgumentException; - 同一个包名不能重复注册,重复抛
InvalidOperationException; - 包名即使因筛选而未激活,也仍然占位——避免”以为注册上了其实被同名挤掉”;
- 安装器在一个由
_owner.Fork()产生的子上下文里运行,注册的一切随该子上下文回收; - 安装器自身抛异常时,注册表会回滚包名占位并回收子上下文,再把异常抛出。
子上下文隔离的意义是:检查逻辑自己也遵守注册即回收纪律,取消一个包的检查不会残留监听器。
启用与筛选
InvariantRegistryOptions 有三个选项:
| 选项 | 默认 | 含义 |
|---|---|---|
Enabled | true | 全局开关,关掉后所有安装器都不激活。 |
PackageAllowlist | 空 | 大小写敏感的正则;为空表示全部准入。 |
PackageBlocklist | 空 | 在白名单匹配之后再排除。 |
筛选顺序是:先看全局开关,再看白名单(非空时必须命中其一),最后看黑名单(命中即排除)。也就是说黑名单优先级高于白名单。
正则在构造时就编译并校验,三种情况直接抛 ArgumentException:条目为空或有首尾空白、同一列表内有重复正则、正则本身非法。这样配置错误在装配阶段暴露,而不是等到某次检查该跑却没跑。
生产可以整体关掉
这些检查是为开发与验收服务的。Enabled: false 让全部安装器不激活,
包名仍然占位,因此开关本身不改变注册拓扑。
违约错误
public sealed class InvariantError : Exception
{
public const string Code = "INVARIANT";
public string PackageName { get; }
}
Code 是稳定的机器可读码,PackageName 指出契约的所有者。宿主可以据此把违约与普通异常分开上报——Tether.Headless 就是这么做的:
error = ex is InvariantError invariant
? $"{invariant.GetType().Name}:{invariant.PackageName}:{InvariantError.Code}:{ex.Message}"
: $"{ex.GetType().Name}:{ex.Message}";
实例:会话括号结构校验
Tether.Core.Session 注册的检查是理解这套机制最好的例子。它订阅每一条提交的会话事件,维护一份轻量轨迹(最后序列号、当前打开的 turn 与 step、本 step 内未配对的调用 id),并断言:
| 检查 | 违约信息示例 |
|---|---|
| 序列号严格递增 | seq must strictly increase: saw 3 after 5 |
| 不能在已有 turn 打开时再开 turn | turn/start 2 while turn 1 is still open |
| turn 号必须连续 | turn/start expected turn 2, got 5 |
turn/end 必须匹配当前打开的 turn | turn/end 2 does not match open turn 1 |
| turn 关闭时不能还有 step 打开 | turn/end 1 while step 2 is still open |
| step 必须落在正确的 turn 内且编号连续 | step/start in turn 2 but open turn is 1 |
同一 step 内 tool/call 的 id 不重复 | duplicate tool/call abc in this step |
tool/result 必须有前置 tool/call(TOOL_NOT_STARTED 合成闭合除外) | tool/result for abc with no prior tool/call in this step |
tool/result 不能落在任何 step 之外 | tool/result appended outside any open step |
这些正是会话与事件溯源里描述的括号结构与Agent 生命周期里 turn/step 编号规则的可执行版本——文档写的约定,这里是运行期强制。
只检查追加到可见面的 tool/result
配对检查只对 SurfaceOp.Kind 为 Append 的 tool/result 生效。
位置改写(Replace)是对既有结果的修正,不代表一次新的调用,因此不参与配对。
TOOL_NOT_STARTED 闭合没有前置 tool/call
工具默认 Exclusive:同一批里前一个调用执行时,后面的调用还没有写出 tool/call。
这时崩溃,崩溃修复(或 fork 截断)为它们合成的错误结果本来就没有前置
tool/call。带失败标记、且 durable error.code 为 TOOL_NOT_STARTED
的结果因此不参与配对(与上游 invariant.ts 一致);其他错误码仍然必须配对。
实例:Interaction 的 durable companions
Tether.Interaction 把 approval 与 direct command 的审计约束注册到同一个 inspector registry,
所以 live append 在 commit 前失败,cold replay 也不会因“当前 service 通常会配对”而跳过历史校验。
| companion | 强制契约 |
|---|---|
| approval | asked/decided exactly-once;id 不得重复或复用;decision 必须有 prior ask;unknown outcome/policy 拒绝;turn/end 与 cold log tail 不得遗留 pending ask。 |
| command | 强类型 CommandId 的 run/done exactly-once;run 必须带 source.kind=user;done kind 只能是 success/error;sourceEventSeq 必须是非负 JSON safe integer,并指向更早的真实 non-command event。 |
这些事件都是 log-only;检查 durable closure 不会把 command 或 approval 伪装成模型可见
user/message,也不会替 direct command 打开 turn。
新增 invariant 的审查标准
提交 runtime invariant 前必须同时说明六件事:
- owner 关系:
Register的 package name 必须就是源码程序集,失败能归到拥有该契约的包; - 两侧 observation:明确列出两个来自真实运行路径、可以分别观察的事实;
- 可能漂移原因:说明两侧为何会因并发、生命周期、不同 producer 或 replay 而独立分叉;
- 检查时机与 failure routing:写清 pre-commit、awaited policy 或 contained notification,以及失败去向;
- disposer:registry registration 和内部 listener/provider subscription 都必须挂在 child
EffectScope; - negative test:必须构造一次真实可达的分叉并断言 owning package。
以下都不是有效 runtime invariant:empty installer、只检查 service presence、plugin metadata、effect 数量或固定示例。 这些约束应留在依赖图、插件框架或普通 unit/acceptance test;没有独立关系的包不建 companion 文件。
已注册检查的包
| 包名 | 两侧 observation 与漂移来源 |
|---|---|
Tether.AgentTeams | committed team projection ↔ live candidate;event producer 与 reducer 可独立演进。 |
Tether.Core.Agent | exact Agent 上次 status ↔ 新 status event;不同 lifecycle producer 可能重复 transition。 |
Tether.Core.AgentLoop | session 派生消息 ↔ request/header 与请求快照;组装和日志 append 时序可分叉。 |
Tether.Core.Session | 已见 turn/step/call trace ↔ 新 durable event;多个 producer 可破坏括号与配对。 |
Tether.Core.SystemPrompt | 各 contributor 的 section/tool 名 ↔ 最终全局命名空间;独立贡献者可能重名。 |
Tether.Core.Tools | dispatch-start call/name ↔ final result candidate;并发 dispatch 与 result policy 可错配生命周期。 |
Tether.Fs.Local | canonical read/stat version ↔ guarded mutation expected version;观察与发布之间 identity 可漂移。 |
Tether.Fs.Search | glob/grep start ↔ final result;并发调用可错配 call identity。 |
Tether.Fs.Tools | fs tool start ↔ final result;并发调用可错配 call identity。 |
Tether.Interaction | prior approval/command facts ↔ 新 candidate;live producer 与 cold replay 可产生 orphan/duplicate。 |
Tether.Llm.Providers | scheduled retry chain ↔ retry-started fact;scheduler、取消和 replay 可分叉。 |
Tether.Shell.Local | invocation start set ↔ terminal transition;各退出路径可漏报或错配。 |
Tether.Shell.Tools | bash/pwsh start ↔ final identity/typed payload;adapter 与通用 pipeline 可独立变化。 |
Tether.Subprocess.Local | owned process-tree set ↔ close/provider-disposed transition;spawn、completion 与 disposal 可分叉。 |
Tether.Invariants 自己只提供 IInvariantRegistry 与 registry,不注册产品关系,也不需要空 companion。