Harness 工程进展:合并、事件与恢复 · 2026年10月1日 · 10 分钟

完成事实的入口:类型化事件与共享写入边界

当完成声明藏在通用事件包装中,写入许可与消费含义可能错位。Harness 的一次修正把禁止包装的规则放进两条写路共用的校验边界,并以逻辑读视图保留合法历史事件,再用 AST 守卫约束生产者与消费者的演化。

Harness 工程进展:合并、事件与恢复 · 阅读系列导读 →

Agent 说“完成了”,系统随后可能停止重试、解除依赖,并允许下游继续。对一个长期运行的协作系统来说,这句话需要成为可核对的事件:谁完成了哪次运行,依据是什么,哪些条件已经满足。事件一旦进入共享账本,其影响便超出了发出它的那个 Agent。

Harness 用事件记录组织任务、修复与问题处理。本文考察其中一次具体的架构修正:某些完成声明能够藏在通用事件的载荷里,借一条允许直接写入的路径进入账本,再被消费者恢复成完成事件。修正的关键是让外层写入许可与内层消费含义保持一致,把禁止包装的规则放在两条写入路径共同经过的边界上。

设想一个合成任务:生成一份说明页,修复其中的失效链接,验证通过后交给下一步发布。编排器需要知道的并非页面曾被修改,而是这次运行是否按要求结束。一个触碰记录可以说明“有写入活动”;它本身不足以让发布任务解除依赖。但如果载荷中藏着“运行成功”,消费者又把它当成正式完成事实,同一条记录就会获得远高于外层名称所表达的效力。

这里需要保留三个区别。事件类型标识记录声称发生了什么;载荷 schema 约束这个声明怎样表达;提交门检查声明是否满足相应的准入条件。随后,投影和编排器才据此计算状态并作出动作。把事件名字改成 TaskRunCompleted,并不能凭空证明说明页已通过验证;类型化的价值在于让这个声明进入应当处理它的验证链。

通用包装怎样改变一条记录的效力

早期接口尚未覆盖所有领域事件时,通用包装是一种现实的兼容办法。某条路径只允许少数外层类型,生产者便把其他事件写成 NodeTouched,把原来的类型放在 kind,把原来的载荷放在 stub_original_payload。只要消费者知道怎样拆开,新增语义便能先存下来,接口可以逐步完善。

这种安排保存了信息,却把一条记录分成了两种身份。按外层类型建立的索引把它归入 NodeTouched;能够拆包的消费者则把它理解成内层事件。若消费者查询“有哪些任务运行完成”,只查 TaskRunCompleted 的类型索引,包装记录会消失。换一个先拆包的消费者,同一条记录又可能成为成功任务集合的一员。

这不仅影响统计。在说明页的例子中,曾有一种可接受写入的形态,能够被完成任务读者恢复为成功结果,从而停止重派并解锁下游。下面仅展示合成数据的表示差异,省略记录元数据和完整 schema 所需字段;后一种类型化结构仍须经过提交门,不能据此直接落账。

// 原有包装:外层许可与内层完成声明分离
{
  "event_type": "NodeTouched",
  "payload": {
    "kind": "TaskRunCompleted",
    "stub_original_payload": {
      "after_state": {"task_id": "demo-task", "outcome": "success"}
    }
  }
}

// 类型化表示:完成声明直接暴露给对应验证链
{
  "event_type": "TaskRunCompleted",
  "payload": {
    "after_state": {"task_id": "demo-task", "outcome": "success"}
  }
}

图 1 中,两条旧读路从同一包装记录分开:一条只看见触碰,另一条恢复出完成。下半图把完成声明送进正式提交路径,同时由共享校验拒绝触碰包装中的受限声明。记录获得完成语义的位置,决定了准入规则能否约束它。

旧形态 · 一条记录,两种身份
NodeTouched内层 kind: TaskRunCompleted

按外层类型读只看见触碰,漏读完成

拆包后消费恢复完成含义,可能解锁下游

新边界 · 两条写路共享校验
write_directappend_transaction_batch
共享写入校验直接写与事务追加均经过

正式完成声明经相应提交门 → 合规落账

受限完成包装拒绝 → 不产生账本记录

逻辑读视图保留合法历史包装的含义;读取兼容不会重新批准旧记录。

图 1|写入许可与消费含义需要在共享边界上保持一致。

拒绝规则必须落在共同的地方

只修改生成说明页的命令,可以阻止这一处产生错误包装。但另一个命令、后台进程或新 Agent 仍可能构造相同的载荷。只在直接写入路径上拒绝,也会留下另一条路径注入相同包装的可能。系统需要控制的是一类可被解释成完成事实的记录形态,规则因此必须随记录进入共同的写入边界。

2026 年 9 月 25 日的修正,把四类完成或闭合声明纳入禁止包装的集合:TaskRunCompleted、FixCompleted、TaskNodeClosed 和 FindingResolved。它们的语义并不相同:一次运行结束、一次修复完成、任务节点闭合、问题得到解除,是不同层次的生命周期声明。它们共有的要求,是不能借 NodeTouched 的外层许可绕过各自应经过的提交与闭合验证。

两条路径共用载荷校验实现。直接写入的 write_direct 最终进入该实现,事务路径的 append_transaction_batch 也逐条调用它。前者先校验,再进入追加过程;后者先检查整批,再打开追加文件。受禁止的包装会在这两条路径产生账本字节之前被拒绝。判断可以改写成下面的短伪代码,实际实现还执行其他写边界检查。

validate_for_write(type, payload):
    if type == NodeTouched
       and payload.kind is a prohibited kind
       and payload.stub_original_payload is a dictionary:
        reject
    validate_registered_payload(type, payload)

write_direct(record):
    validate_for_write(record.type, record.payload)
    append(record)

append_transaction_batch(records):
    for record in records:
        validate_for_write(record.type, record.payload)
    append_whole_batch(records)

三个条件共同成立才触发这条禁令。普通 NodeTouched 没有字典形态的内层载荷,继续保留触碰语义;不属于禁令集合的合法包装,也不被这条规则拦截。针对四种完成包装的测试要求写入全部失败,并检查账本中没有新增记录;普通触碰与历史依赖边包装作为对照可以写入。它验证了拒绝的具体形态,也验证了限制没有扩大成“所有包装都禁止”。

注册表采用分阶段推进:已进入强制集合的类型不合规就拒绝,部分已注册类型仍只记录影子诊断;完成包装禁令则是独立的拒绝规则。这里核对的覆盖范围是直接写入与事务追加这两条受控接口。

新写入收紧以后,旧事件仍需被看见

拒绝新的完成包装,解决的是准入问题。账本里已有的合法包装,以及仍被允许生产的包装,依然需要正确读取。若此时把所有消费者都改成只读外层类型,说明页的依赖边等历史信息又会丢失。读取兼容与写入收紧需要各自明确的规则。

9 月 30 日的另一项修正提供了共享拆包原语。它先识别外层为 NodeTouched、内层载荷为字典且具有非空逻辑类型名的记录,再返回逻辑类型与逻辑载荷。记录级读视图在逻辑类型属于已知事件枚举时替换这两个字段,并保留原来的事件标识、顺序和来源等元数据。账本字节没有改写,过去的记录也没有因读取而获得一次新的准入认可。

按逻辑类型取数则把两部分合在一起:直接以目标类型存储的记录,以及包装中具有该逻辑类型的记录。结果按原来的序号排序并去重。说明页的依赖查询由此可以看到合法包装的依赖边;需要重放历史的读者也有统一的解释入口。未知的逻辑类型不会被记录级视图强行转换成一个已知领域事件。

图 2 中,“声明含义”与“状态判断”是相邻的两栏。触碰说明有活动,运行完成说明某次运行结束,任务闭合说明任务进入闭合阶段;它们不能任意互换。消费者需要根据具体类型、载荷和所需证据更新状态,一个含有 success 的字典不足以推出全部生命周期结论。

01NodeTouched
声明含义

活动发生

状态判据

不能据此推出任务成功

02TaskRunCompleted
声明含义

一次运行已经结束

状态判据

结合结果与准入证据

03FixCompleted
声明含义

修复流程完成声明

状态判据

不能替代任务闭合

04TaskNodeClosed
声明含义

任务进入闭合阶段

状态判据

须满足自身闭合条件

05FindingResolved
声明含义

问题解除声明

状态判据

不能替代运行完成

图 2|类型标明声明的含义,状态仍由相应证据与判据推导。

统一读取也有代价。带序号下界的增量查询只需检查窗口内的包装候选;没有下界的全历史查询,可能需要扫描大量候选,因此不能把同一个调用方式无条件搬进交互热路径。这次修正确立了共享语义与后续演化规则,但仍保留已登记的例外和旧拆包基线,历史读侧尚未全部迁入新原语。

让后来的代码延续同一份契约

共享函数解决“怎样解释”,却不能保证下一位开发者一定调用它。生产者可能新增一种包装,消费者可能再次只按外层类型读取,也可能复制一份拆包逻辑并修改字段优先级。长时间运行的系统必须把这些演化方式变成可检查的约束。

这里的 AST 守卫从代码结构入手:新增包装生产者必须声明其逻辑类型;读取仍有活跃包装生产者的类型时,消费者须使用逻辑读取原语,或登记具体例外;新出现的手写拆包会被拒绝。AST 是源代码的语法结构,守卫可以据此识别类型读取调用、常量和相关字段访问。守卫测试专门构造错误读者与使用共享原语的对照,检查扫描器能否区分两者。

守卫在明确的扫描范围内固定写者与读者的关系,并保留登记例外。已识别的失配方式再次出现时,开发者便能从检查中获得反馈,把发现问题的时机提前到状态投影漏读或编排器错误解锁之前。

图 3 中,被拒的包装只是一回失败的提交尝试,没有成为账本事件,也没有推进任务状态。随后通过相应验证的成功记录,才供消费者判断运行结果和依赖关系。提交尝试与账本事实在时间上相继出现,却具有不同的效力。

  1. 01

    执行说明页任务

    状态:运行中

  2. 02

    NodeTouched

    修复链接,记录活动

  3. 03

    包装完成声明

    写边界拒绝;状态保持

    这是提交尝试,没有进入账本时间线。
  4. 04

    TaskRunCompleted

    正式提交通过相应验证

  5. 05

    消费合规成功记录

    运行成功;下游依赖可解除

任务节点闭合仍有自己的事件与判据。

图 3|被拒的包装不落账;有效成功记录才参与状态推导。

说明页任务中的活动、运行结果、修复与闭合,由各自明确的事件进入相应验证链,再供消费者计算状态与下游动作。这次修正让两条受控写路在落账前拒绝同一类完成包装,让合法历史包装通过统一读视图保持可见,并让后续写者与读者的相关变化受到结构守卫检查。完成声明怎样表示、从哪里准入、如何参与状态推导,由此成为一份可以共同维护的具体契约。